infinicode 2.0.0 → 2.2.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.
- package/README.md +42 -0
- package/dist/cli.js +7 -3
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +28 -6
- package/dist/commands/serve.d.ts +2 -0
- package/dist/commands/serve.js +35 -14
- package/dist/kernel/config-schema.d.ts +4 -0
- package/dist/kernel/federation/federation.d.ts +21 -3
- package/dist/kernel/federation/federation.js +74 -4
- package/dist/kernel/federation/index.d.ts +2 -0
- package/dist/kernel/federation/index.js +1 -0
- package/dist/kernel/federation/lan-discovery.d.ts +43 -0
- package/dist/kernel/federation/lan-discovery.js +135 -0
- package/dist/kernel/federation/transport-http.d.ts +2 -0
- package/dist/kernel/federation/transport-http.js +5 -0
- package/dist/kernel/setup.d.ts +8 -1
- package/dist/kernel/setup.js +27 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,6 +90,48 @@ infinicode mission status <id>
|
|
|
90
90
|
infinicode mission list
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### As a device mesh (multi-device, new in v2)
|
|
94
|
+
|
|
95
|
+
Install infinicode on every machine — laptop, workstation, Raspberry Pi — and they become one fleet. Spawn AI agents on any device from any device (no SSH), stream activity/hardware back, and drive it all from an MCP host like Claude Code or InfiniBot.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# install on each device
|
|
99
|
+
npm install -g infinicode
|
|
100
|
+
|
|
101
|
+
# same LAN — zero config: each node broadcasts a UDP beacon and auto-connects
|
|
102
|
+
infinicode serve --hub --lan # your main machine
|
|
103
|
+
infinicode serve --role satellite --lan # a Pi / other box
|
|
104
|
+
|
|
105
|
+
# across LAN + remote — auto-discover over Tailscale
|
|
106
|
+
infinicode serve --hub --tailscale
|
|
107
|
+
infinicode serve --role satellite --tailscale --tag tag:robopark
|
|
108
|
+
|
|
109
|
+
# or point at exact peers
|
|
110
|
+
infinicode serve --role satellite --seed http://192.168.1.20:47913
|
|
111
|
+
|
|
112
|
+
# verify the mesh — ask any running node for its peers (self + connected)
|
|
113
|
+
curl http://localhost:47913/fed/nodes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Set your cloud provider keys + policy **once on the hub**; every satellite auto-sources them (over `--seed`/`--lan`/`--tailscale`) and registers the providers + models live, no restart.
|
|
117
|
+
|
|
118
|
+
Drive the fleet from an MCP host — register the control server (spawn / dispatch / follow / role / voice / mesh-map tools):
|
|
119
|
+
|
|
120
|
+
```jsonc
|
|
121
|
+
// ~/.claude.json
|
|
122
|
+
{ "mcpServers": { "infinicode": { "command": "infinicode", "args": ["mcp", "--lan"] } } }
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Link a node onto an **InfiniBot** neural-mesh map (no InfiniBot changes needed):
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
infinicode serve --hub --gateway ws://<infinibot-host>:18789 --gateway-token <token>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Discovery options: `--lan` (same subnet, zero-config UDP broadcast, port 47915), `--tailscale` (LAN+remote), `--seed <url>` (exact) — they combine. Full guide: `docs/user-manual.html`; design deep-dive: `docs/federation-architecture.html`.
|
|
132
|
+
|
|
133
|
+
> The Windows TUI binaries are not shipped in the npm package (they're large and platform-specific), so `infinicode run` needs a local TUI build; the mesh commands (`serve`, `mcp`, `mission`, `console`) run everywhere as pure Node.
|
|
134
|
+
|
|
93
135
|
### As a library
|
|
94
136
|
|
|
95
137
|
```ts
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { consoleRepl, consoleRun } from './commands/console.js';
|
|
|
15
15
|
import { serve } from './commands/serve.js';
|
|
16
16
|
import { mcpCommand } from './commands/mcp.js';
|
|
17
17
|
import { DEFAULT_CONFIG } from './kernel/config-schema.js';
|
|
18
|
-
const VERSION = '2.
|
|
18
|
+
const VERSION = '2.2.0';
|
|
19
19
|
const config = new Conf({
|
|
20
20
|
projectName: 'infinicode',
|
|
21
21
|
defaults: DEFAULT_CONFIG,
|
|
@@ -287,7 +287,9 @@ program
|
|
|
287
287
|
.option('--seed <url...>', 'peer base URL(s) to connect to on start')
|
|
288
288
|
.option('--auto-update', 'accept auto-updates announced by a hub/relay')
|
|
289
289
|
.option('--tailscale', 'auto-discover + connect peers over Tailscale')
|
|
290
|
-
.option('--
|
|
290
|
+
.option('--lan', 'auto-discover + connect peers on the same LAN via UDP broadcast (no seed/Tailscale needed)')
|
|
291
|
+
.option('--lan-port <port>', 'UDP discovery port for --lan (default 47915)')
|
|
292
|
+
.option('--tag <tag>', 'only discover peers carrying this tag (Tailscale ACL tag or LAN beacon tag)')
|
|
291
293
|
.option('--gateway <url>', 'link to an InfiniBot gateway (ws://host:18789) — appear on its neural-mesh map')
|
|
292
294
|
.option('--gateway-token <token>', 'auth token for the InfiniBot gateway')
|
|
293
295
|
.option('--gateway-password <password>', 'auth password for the InfiniBot gateway')
|
|
@@ -304,7 +306,9 @@ program
|
|
|
304
306
|
.option('--token <token>', 'shared bearer token peers must present')
|
|
305
307
|
.option('--seed <url...>', 'peer base URL(s) to connect to on start')
|
|
306
308
|
.option('--tailscale', 'auto-discover + connect peers over Tailscale')
|
|
307
|
-
.option('--
|
|
309
|
+
.option('--lan', 'auto-discover + connect peers on the same LAN via UDP broadcast (no seed/Tailscale needed)')
|
|
310
|
+
.option('--lan-port <port>', 'UDP discovery port for --lan (default 47915)')
|
|
311
|
+
.option('--tag <tag>', 'only discover peers carrying this tag (Tailscale ACL tag or LAN beacon tag)')
|
|
308
312
|
.action(async (opts) => {
|
|
309
313
|
await mcpCommand(config, opts);
|
|
310
314
|
});
|
package/dist/commands/mcp.d.ts
CHANGED
package/dist/commands/mcp.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { dirname, join } from 'node:path';
|
|
15
|
-
import { buildKernelFromConfig } from '../kernel/setup.js';
|
|
15
|
+
import { buildKernelFromConfig, registerCloudProviders } from '../kernel/setup.js';
|
|
16
16
|
import { Federation, GoalLoop, loadOrCreateIdentity, serveMcpStdio, SilentLogger, } from '../kernel/index.js';
|
|
17
17
|
function pkgVersion() {
|
|
18
18
|
try {
|
|
@@ -51,21 +51,39 @@ export async function mcpCommand(config, opts) {
|
|
|
51
51
|
};
|
|
52
52
|
};
|
|
53
53
|
const applyConfig = (cfg) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
let live = 0;
|
|
55
|
+
if (cfg.cloudProviders) {
|
|
56
|
+
const providers = cfg.cloudProviders;
|
|
57
|
+
config.set('cloudProviders', providers);
|
|
58
|
+
live = registerCloudProviders(kernel, providers); // live → models available now
|
|
59
|
+
}
|
|
60
|
+
if (cfg.policy) {
|
|
57
61
|
config.set('policy', cfg.policy);
|
|
62
|
+
kernel.setDefaultPolicy(cfg.policy);
|
|
63
|
+
}
|
|
58
64
|
if (cfg.defaultModel)
|
|
59
65
|
config.set('defaultModel', cfg.defaultModel);
|
|
60
|
-
stderrLogger.info(`
|
|
66
|
+
stderrLogger.info(`sourced shared config from hub (rev ${cfg.revision}) — ${live} provider(s) live`);
|
|
61
67
|
};
|
|
62
68
|
const federation = new Federation({
|
|
63
69
|
kernel,
|
|
64
70
|
identity,
|
|
65
71
|
describe,
|
|
66
72
|
logger: stderrLogger,
|
|
67
|
-
options: { version, port, token, autoUpdate: fed.autoUpdate, applyConfig },
|
|
73
|
+
options: { version, port, token, autoUpdate: fed.autoUpdate, applyConfig, autoPullConfig: role === 'satellite' },
|
|
68
74
|
});
|
|
75
|
+
// Hub/peer/relay: publish local providers + policy for satellites to source.
|
|
76
|
+
const sharedFromConf = () => ({
|
|
77
|
+
cloudProviders: config.get('cloudProviders'),
|
|
78
|
+
policy: config.get('policy'),
|
|
79
|
+
defaultModel: config.get('defaultModel'),
|
|
80
|
+
});
|
|
81
|
+
if (role !== 'satellite') {
|
|
82
|
+
federation.publishConfig(sharedFromConf());
|
|
83
|
+
for (const key of ['cloudProviders', 'policy', 'defaultModel']) {
|
|
84
|
+
config.onDidChange(key, () => federation.publishConfig(sharedFromConf()));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
69
87
|
try {
|
|
70
88
|
await federation.start();
|
|
71
89
|
}
|
|
@@ -81,6 +99,10 @@ export async function mcpCommand(config, opts) {
|
|
|
81
99
|
const n = await federation.discoverAndConnect(opts.tag).catch(() => 0);
|
|
82
100
|
stderrLogger.info(`tailscale discovery connected ${n} peer(s)`);
|
|
83
101
|
}
|
|
102
|
+
if (opts.lan ?? fed.lan) {
|
|
103
|
+
federation.startLanDiscovery(opts.tag, opts.lanPort ? parseInt(opts.lanPort, 10) : fed.lanPort);
|
|
104
|
+
stderrLogger.info('LAN auto-discovery on (udp broadcast)');
|
|
105
|
+
}
|
|
84
106
|
// Proactive autonomy: a goal loop the MCP host can populate (add_goal/…).
|
|
85
107
|
const autonomy = new GoalLoop({ kernel, logger: stderrLogger });
|
|
86
108
|
autonomy.start();
|
package/dist/commands/serve.d.ts
CHANGED
package/dist/commands/serve.js
CHANGED
|
@@ -13,7 +13,7 @@ import chalk from 'chalk';
|
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
|
-
import { buildKernelFromConfig } from '../kernel/setup.js';
|
|
16
|
+
import { buildKernelFromConfig, registerCloudProviders } from '../kernel/setup.js';
|
|
17
17
|
import { Federation, MoltfedConnector, loadOrCreateIdentity } from '../kernel/index.js';
|
|
18
18
|
function pkgVersion() {
|
|
19
19
|
try {
|
|
@@ -44,15 +44,22 @@ export async function serve(config, opts) {
|
|
|
44
44
|
providers: kernel.providerManager.listInfos().map(p => p.id),
|
|
45
45
|
};
|
|
46
46
|
};
|
|
47
|
-
// Satellite: persist shared config
|
|
47
|
+
// Satellite: persist shared config AND hot-apply it to the running kernel so
|
|
48
|
+
// the hub's providers + their models are available immediately (no restart).
|
|
48
49
|
const applyConfig = (cfg) => {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
let live = 0;
|
|
51
|
+
if (cfg.cloudProviders) {
|
|
52
|
+
const providers = cfg.cloudProviders;
|
|
53
|
+
config.set('cloudProviders', providers);
|
|
54
|
+
live = registerCloudProviders(kernel, providers); // live registration → models appear now
|
|
55
|
+
}
|
|
56
|
+
if (cfg.policy) {
|
|
52
57
|
config.set('policy', cfg.policy);
|
|
58
|
+
kernel.setDefaultPolicy(cfg.policy);
|
|
59
|
+
}
|
|
53
60
|
if (cfg.defaultModel)
|
|
54
61
|
config.set('defaultModel', cfg.defaultModel);
|
|
55
|
-
console.log(chalk.green(` ✓
|
|
62
|
+
console.log(chalk.green(` ✓ sourced shared config from hub (rev ${cfg.revision}) — ${live} provider(s) live`));
|
|
56
63
|
};
|
|
57
64
|
const federation = new Federation({
|
|
58
65
|
kernel,
|
|
@@ -67,8 +74,22 @@ export async function serve(config, opts) {
|
|
|
67
74
|
heartbeatMs: (fed.heartbeatSec ?? 30) * 1000,
|
|
68
75
|
autoUpdate: opts.autoUpdate ?? fed.autoUpdate,
|
|
69
76
|
applyConfig,
|
|
77
|
+
autoPullConfig: role === 'satellite',
|
|
70
78
|
},
|
|
71
79
|
});
|
|
80
|
+
// Hub/peer/relay: publish this machine's providers + policy so satellites can
|
|
81
|
+
// auto-source them. Re-publish when the local config changes (live).
|
|
82
|
+
const sharedFromConf = () => ({
|
|
83
|
+
cloudProviders: config.get('cloudProviders'),
|
|
84
|
+
policy: config.get('policy'),
|
|
85
|
+
defaultModel: config.get('defaultModel'),
|
|
86
|
+
});
|
|
87
|
+
if (role !== 'satellite') {
|
|
88
|
+
federation.publishConfig(sharedFromConf());
|
|
89
|
+
for (const key of ['cloudProviders', 'policy', 'defaultModel']) {
|
|
90
|
+
config.onDidChange(key, () => federation.publishConfig(sharedFromConf()));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
72
93
|
console.log(chalk.bold(`\n infinicode mesh node`));
|
|
73
94
|
console.log(chalk.dim(' ' + '-'.repeat(46)));
|
|
74
95
|
console.log(` node: ${chalk.cyan(identity.displayName)} ${chalk.dim('(' + identity.nodeId + ')')}`);
|
|
@@ -78,23 +99,23 @@ export async function serve(config, opts) {
|
|
|
78
99
|
console.log(` auth: ${chalk.green('token required')}`);
|
|
79
100
|
console.log();
|
|
80
101
|
await federation.start();
|
|
81
|
-
// Connect to seeds
|
|
102
|
+
// Connect to seeds. Satellites auto-source shared config from any hub/relay
|
|
103
|
+
// they connect to (handled inside federation.connect()).
|
|
82
104
|
for (const url of seeds) {
|
|
83
105
|
const peer = await federation.connect(url);
|
|
84
|
-
if (peer)
|
|
106
|
+
if (peer)
|
|
85
107
|
console.log(chalk.green(` ✓ connected to ${peer.displayName} @ ${url}`));
|
|
86
|
-
if (role === 'satellite') {
|
|
87
|
-
const applied = await federation.pullConfig(peer.nodeId).catch(() => false);
|
|
88
|
-
if (applied)
|
|
89
|
-
console.log(chalk.green(` ✓ pulled shared config from ${peer.displayName}`));
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
108
|
}
|
|
93
109
|
// Optional: auto-discover peers over Tailscale (no --seed needed).
|
|
94
110
|
if (opts.tailscale) {
|
|
95
111
|
const n = await federation.discoverAndConnect(opts.tag).catch(() => 0);
|
|
96
112
|
console.log(chalk.green(` ✓ tailscale discovery connected ${n} peer(s)`));
|
|
97
113
|
}
|
|
114
|
+
// Optional: zero-config LAN discovery — auto-connect peers on this subnet.
|
|
115
|
+
if (opts.lan ?? fed.lan) {
|
|
116
|
+
federation.startLanDiscovery(opts.tag, opts.lanPort ? parseInt(opts.lanPort, 10) : fed.lanPort);
|
|
117
|
+
console.log(chalk.green(` ✓ LAN auto-discovery on (udp broadcast) — peers on this subnet connect automatically`));
|
|
118
|
+
}
|
|
98
119
|
// Optional: log this node into an InfiniBot gateway so it renders live on the
|
|
99
120
|
// neural-mesh map + hardware panels (no InfiniBot changes needed).
|
|
100
121
|
let connector;
|
|
@@ -37,6 +37,10 @@ export interface FederationConfig {
|
|
|
37
37
|
heartbeatSec?: number;
|
|
38
38
|
/** Accept + apply auto-updates announced by a hub/relay. */
|
|
39
39
|
autoUpdate?: boolean;
|
|
40
|
+
/** Zero-config LAN discovery: broadcast + auto-connect peers on this subnet. */
|
|
41
|
+
lan?: boolean;
|
|
42
|
+
/** UDP discovery port for LAN discovery (default 47915). */
|
|
43
|
+
lanPort?: number;
|
|
40
44
|
}
|
|
41
45
|
export interface InfinicodeConfig {
|
|
42
46
|
masterUrl: string;
|
|
@@ -11,7 +11,7 @@
|
|
|
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, SharedConfig, StreamFrame } from './types.js';
|
|
14
|
+
import type { HardwareSnapshot, NodeIdentity, 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';
|
|
@@ -36,6 +36,10 @@ export interface FederationOptions {
|
|
|
36
36
|
autoUpdate?: boolean;
|
|
37
37
|
/** Satellite: how to apply an incoming shared config (register providers etc.). */
|
|
38
38
|
applyConfig?: (cfg: SharedConfig) => void | Promise<void>;
|
|
39
|
+
/** Satellite: auto-pull shared config from any hub/relay we connect to. */
|
|
40
|
+
autoPullConfig?: boolean;
|
|
41
|
+
/** Satellite: how often to re-pull shared config (ms, default 30000; 0 disables). */
|
|
42
|
+
configPullMs?: number;
|
|
39
43
|
}
|
|
40
44
|
export interface FederationDeps {
|
|
41
45
|
kernel: KernelLike;
|
|
@@ -54,6 +58,10 @@ export declare class Federation {
|
|
|
54
58
|
private telemetryTimer?;
|
|
55
59
|
private lastHw?;
|
|
56
60
|
private unsub?;
|
|
61
|
+
private lan?;
|
|
62
|
+
/** nodeIds we're currently dialing via LAN discovery (dedup guard). */
|
|
63
|
+
private lanConnecting;
|
|
64
|
+
private configPullTimer?;
|
|
57
65
|
readonly configSync: ConfigSync;
|
|
58
66
|
readonly autoUpdate: AutoUpdate;
|
|
59
67
|
readonly compute: ComputeRouter;
|
|
@@ -106,12 +114,22 @@ export declare class Federation {
|
|
|
106
114
|
publishConfig(cfg: Omit<SharedConfig, 'revision' | 'updatedAt'>): SharedConfig;
|
|
107
115
|
/** Satellite: pull shared config from a peer (usually the hub). */
|
|
108
116
|
pullConfig(nodeId: string): Promise<boolean>;
|
|
109
|
-
connect(url: string): Promise<
|
|
117
|
+
connect(url: string): Promise<PeerInfo | null>;
|
|
118
|
+
/** Satellite: pull shared config from a freshly-connected hub/relay. */
|
|
119
|
+
private maybeAutoPull;
|
|
120
|
+
/** Satellite: re-pull from the first connected hub/relay (periodic refresh). */
|
|
121
|
+
private autoPullFromHub;
|
|
110
122
|
/** Auto-discover peers over Tailscale and connect to each (skips self/known). */
|
|
111
123
|
discoverAndConnect(tag?: string): Promise<number>;
|
|
124
|
+
/**
|
|
125
|
+
* Start zero-config LAN discovery: broadcast a beacon on the local subnet and
|
|
126
|
+
* auto-connect to any peer we hear (no --seed / Tailscale needed). Continuous —
|
|
127
|
+
* new nodes that come online later are picked up too. Idempotent.
|
|
128
|
+
*/
|
|
129
|
+
startLanDiscovery(tag?: string, discoveryPort?: number): void;
|
|
112
130
|
/** Neutral node-status list (self + peers) — feeds the mesh map. */
|
|
113
131
|
nodeStatuses(): NodeStatus[];
|
|
114
|
-
peers():
|
|
132
|
+
peers(): PeerInfo[];
|
|
115
133
|
/** Latest local hardware snapshot (for the MOLTFED connector / panels). */
|
|
116
134
|
latestHardware(): HardwareSnapshot | undefined;
|
|
117
135
|
/** Subscribe to the merged mesh frame stream (local + peer frames). */
|
|
@@ -9,6 +9,7 @@ import { buildManifest } from './node-identity.js';
|
|
|
9
9
|
import { envelope, reply, frame, newId } from './protocol.js';
|
|
10
10
|
import { loadRole, saveRole, rolePreamble } from './role-context.js';
|
|
11
11
|
import { discoverTailscalePeers } from './discovery.js';
|
|
12
|
+
import { LanBeacon } from './lan-discovery.js';
|
|
12
13
|
import { DEFAULT_MESH_PORT } from './types.js';
|
|
13
14
|
export class Federation {
|
|
14
15
|
deps;
|
|
@@ -20,6 +21,10 @@ export class Federation {
|
|
|
20
21
|
telemetryTimer;
|
|
21
22
|
lastHw;
|
|
22
23
|
unsub;
|
|
24
|
+
lan;
|
|
25
|
+
/** nodeIds we're currently dialing via LAN discovery (dedup guard). */
|
|
26
|
+
lanConnecting = new Set();
|
|
27
|
+
configPullTimer;
|
|
23
28
|
configSync;
|
|
24
29
|
autoUpdate;
|
|
25
30
|
compute = new ComputeRouter();
|
|
@@ -53,6 +58,7 @@ export class Federation {
|
|
|
53
58
|
token: options.token,
|
|
54
59
|
logger,
|
|
55
60
|
getManifest: () => buildManifest(identity, this.deps.describe()),
|
|
61
|
+
getNodes: () => this.nodeStatuses(),
|
|
56
62
|
onRpc: (env, remote) => this.onRpc(env, remote),
|
|
57
63
|
});
|
|
58
64
|
await this.server.start();
|
|
@@ -78,9 +84,14 @@ export class Federation {
|
|
|
78
84
|
this.lastHw = this.telemetry.collect();
|
|
79
85
|
this.server?.broadcast(frame('hw', identity.nodeId, this.lastHw));
|
|
80
86
|
}, telMs);
|
|
81
|
-
// Connect to seed peers.
|
|
87
|
+
// Connect to seed peers (via this.connect so satellites auto-pull config).
|
|
82
88
|
for (const url of options.seeds ?? [])
|
|
83
|
-
void this.
|
|
89
|
+
void this.connect(url);
|
|
90
|
+
// Satellite: periodically re-pull shared config so hub changes (new
|
|
91
|
+
// providers/keys/policy) propagate without a reconnect.
|
|
92
|
+
if (options.autoPullConfig && options.configPullMs !== 0) {
|
|
93
|
+
this.configPullTimer = setInterval(() => void this.autoPullFromHub(), options.configPullMs ?? 30_000);
|
|
94
|
+
}
|
|
84
95
|
logger.info(`[federation] node ${identity.displayName} (${identity.role}) online on :${port}`);
|
|
85
96
|
}
|
|
86
97
|
// ── Inbound RPC ────────────────────────────────────────────────────────────
|
|
@@ -271,7 +282,26 @@ export class Federation {
|
|
|
271
282
|
return false;
|
|
272
283
|
}
|
|
273
284
|
async connect(url) {
|
|
274
|
-
|
|
285
|
+
const peer = (await this.mesh?.connect(url)) ?? null;
|
|
286
|
+
if (peer)
|
|
287
|
+
await this.maybeAutoPull(peer);
|
|
288
|
+
return peer;
|
|
289
|
+
}
|
|
290
|
+
/** Satellite: pull shared config from a freshly-connected hub/relay. */
|
|
291
|
+
async maybeAutoPull(peer) {
|
|
292
|
+
if (!this.deps.options.autoPullConfig)
|
|
293
|
+
return;
|
|
294
|
+
if (peer.role !== 'hub' && peer.role !== 'relay')
|
|
295
|
+
return;
|
|
296
|
+
const applied = await this.pullConfig(peer.nodeId).catch(() => false);
|
|
297
|
+
if (applied)
|
|
298
|
+
this.deps.logger.info(`[federation] pulled shared config from ${peer.displayName}`);
|
|
299
|
+
}
|
|
300
|
+
/** Satellite: re-pull from the first connected hub/relay (periodic refresh). */
|
|
301
|
+
async autoPullFromHub() {
|
|
302
|
+
const hub = (this.mesh?.connected() ?? []).find(p => p.role === 'hub' || p.role === 'relay');
|
|
303
|
+
if (hub)
|
|
304
|
+
await this.maybeAutoPull(hub);
|
|
275
305
|
}
|
|
276
306
|
/** Auto-discover peers over Tailscale and connect to each (skips self/known). */
|
|
277
307
|
async discoverAndConnect(tag) {
|
|
@@ -279,12 +309,49 @@ export class Federation {
|
|
|
279
309
|
const urls = await discoverTailscalePeers({ port, tag, logger: this.deps.logger });
|
|
280
310
|
let connected = 0;
|
|
281
311
|
for (const url of urls) {
|
|
282
|
-
const peer = await this.
|
|
312
|
+
const peer = await this.connect(url);
|
|
283
313
|
if (peer && peer.nodeId !== this.deps.identity.nodeId)
|
|
284
314
|
connected++;
|
|
285
315
|
}
|
|
286
316
|
return connected;
|
|
287
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Start zero-config LAN discovery: broadcast a beacon on the local subnet and
|
|
320
|
+
* auto-connect to any peer we hear (no --seed / Tailscale needed). Continuous —
|
|
321
|
+
* new nodes that come online later are picked up too. Idempotent.
|
|
322
|
+
*/
|
|
323
|
+
startLanDiscovery(tag, discoveryPort) {
|
|
324
|
+
if (this.lan)
|
|
325
|
+
return;
|
|
326
|
+
const { identity, logger, options } = this.deps;
|
|
327
|
+
this.lan = new LanBeacon({
|
|
328
|
+
nodeId: identity.nodeId,
|
|
329
|
+
name: identity.displayName,
|
|
330
|
+
role: identity.role,
|
|
331
|
+
meshPort: options.port ?? DEFAULT_MESH_PORT,
|
|
332
|
+
tag,
|
|
333
|
+
auth: !!options.token,
|
|
334
|
+
discoveryPort,
|
|
335
|
+
logger,
|
|
336
|
+
onPeer: ({ nodeId, url, name }) => {
|
|
337
|
+
if (nodeId === identity.nodeId)
|
|
338
|
+
return; // self
|
|
339
|
+
if (this.mesh?.get(nodeId))
|
|
340
|
+
return; // already connected
|
|
341
|
+
if (this.lanConnecting.has(nodeId))
|
|
342
|
+
return; // dial in flight
|
|
343
|
+
this.lanConnecting.add(nodeId);
|
|
344
|
+
void this.connect(url)
|
|
345
|
+
.then(p => {
|
|
346
|
+
if (p)
|
|
347
|
+
logger.info(`[federation] lan discovered ${p.displayName ?? name} @ ${url}`);
|
|
348
|
+
})
|
|
349
|
+
.catch(() => { })
|
|
350
|
+
.finally(() => this.lanConnecting.delete(nodeId));
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
this.lan.start();
|
|
354
|
+
}
|
|
288
355
|
// ── Views ────────────────────────────────────────────────────────────────
|
|
289
356
|
/** Neutral node-status list (self + peers) — feeds the mesh map. */
|
|
290
357
|
nodeStatuses() {
|
|
@@ -327,6 +394,9 @@ export class Federation {
|
|
|
327
394
|
async stop() {
|
|
328
395
|
this.unsub?.();
|
|
329
396
|
this.bridge?.stop();
|
|
397
|
+
this.lan?.stop();
|
|
398
|
+
if (this.configPullTimer)
|
|
399
|
+
clearInterval(this.configPullTimer);
|
|
330
400
|
if (this.telemetryTimer)
|
|
331
401
|
clearInterval(this.telemetryTimer);
|
|
332
402
|
this.mesh?.stop();
|
|
@@ -25,6 +25,8 @@ export type { ComputeCandidate, ComputeNeed } from './compute-router.js';
|
|
|
25
25
|
export { loadRole, saveRole, rolePreamble } from './role-context.js';
|
|
26
26
|
export { discoverTailscalePeers, tailscaleAvailable } from './discovery.js';
|
|
27
27
|
export type { DiscoverOptions } from './discovery.js';
|
|
28
|
+
export { LanBeacon, DEFAULT_LAN_DISCOVERY_PORT } from './lan-discovery.js';
|
|
29
|
+
export type { LanBeaconOptions, DiscoveredLanPeer } from './lan-discovery.js';
|
|
28
30
|
export { envelope, reply, frame, frameToSSE, parseSSE, newId } from './protocol.js';
|
|
29
31
|
export * as moltfed from './moltfed-adapter.js';
|
|
30
32
|
export { MoltfedConnector } from './moltfed-connector.js';
|
|
@@ -18,6 +18,7 @@ export { AutoUpdate, compareVersions } from './auto-update.js';
|
|
|
18
18
|
export { ComputeRouter } from './compute-router.js';
|
|
19
19
|
export { loadRole, saveRole, rolePreamble } from './role-context.js';
|
|
20
20
|
export { discoverTailscalePeers, tailscaleAvailable } from './discovery.js';
|
|
21
|
+
export { LanBeacon, DEFAULT_LAN_DISCOVERY_PORT } from './lan-discovery.js';
|
|
21
22
|
export { envelope, reply, frame, frameToSSE, parseSSE, newId } from './protocol.js';
|
|
22
23
|
export * as moltfed from './moltfed-adapter.js';
|
|
23
24
|
export { MoltfedConnector } from './moltfed-connector.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Logger } from '../types.js';
|
|
2
|
+
export declare const DEFAULT_LAN_DISCOVERY_PORT = 47915;
|
|
3
|
+
export interface DiscoveredLanPeer {
|
|
4
|
+
nodeId: string;
|
|
5
|
+
name: string;
|
|
6
|
+
role: string;
|
|
7
|
+
url: string;
|
|
8
|
+
address: string;
|
|
9
|
+
tag?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface LanBeaconOptions {
|
|
12
|
+
nodeId: string;
|
|
13
|
+
name: string;
|
|
14
|
+
role: string;
|
|
15
|
+
/** The mesh (HTTP+SSE) port this node advertises. */
|
|
16
|
+
meshPort: number;
|
|
17
|
+
/** Only match peers carrying this tag (mirrors Tailscale --tag). */
|
|
18
|
+
tag?: string;
|
|
19
|
+
/** Whether this node's mesh requires a token (informational only). */
|
|
20
|
+
auth?: boolean;
|
|
21
|
+
/** UDP port to broadcast/listen on (default 47915). */
|
|
22
|
+
discoveryPort?: number;
|
|
23
|
+
/** Broadcast cadence (default 5s). */
|
|
24
|
+
intervalMs?: number;
|
|
25
|
+
logger?: Logger;
|
|
26
|
+
/** Called when a *different* node's beacon is heard. */
|
|
27
|
+
onPeer: (peer: DiscoveredLanPeer) => void;
|
|
28
|
+
}
|
|
29
|
+
/** Periodically broadcasts a discovery beacon and reports peers it hears. */
|
|
30
|
+
export declare class LanBeacon {
|
|
31
|
+
private opts;
|
|
32
|
+
private sock?;
|
|
33
|
+
private timer?;
|
|
34
|
+
private readonly port;
|
|
35
|
+
private readonly intervalMs;
|
|
36
|
+
private stopped;
|
|
37
|
+
constructor(opts: LanBeaconOptions);
|
|
38
|
+
start(): void;
|
|
39
|
+
private beaconPayload;
|
|
40
|
+
private broadcast;
|
|
41
|
+
private onMessage;
|
|
42
|
+
stop(): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenKernel — LAN Peer Discovery (UDP broadcast)
|
|
3
|
+
*
|
|
4
|
+
* Zero-config, dependency-free same-subnet discovery. Each node periodically
|
|
5
|
+
* broadcasts a tiny beacon (nodeId, name, role, mesh port, optional tag) over
|
|
6
|
+
* UDP; any node that hears a beacon learns a peer's mesh URL and connects. No
|
|
7
|
+
* --seed, no Tailscale needed on a shared LAN.
|
|
8
|
+
*
|
|
9
|
+
* Broadcast packets are NOT forwarded by routers, so this stays within the local
|
|
10
|
+
* subnet by design — discovery only ever reaches physically co-located, trusted
|
|
11
|
+
* machines. Actual mesh RPC is still gated by the shared --token if one is set.
|
|
12
|
+
*/
|
|
13
|
+
import dgram from 'node:dgram';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
export const DEFAULT_LAN_DISCOVERY_PORT = 47915;
|
|
16
|
+
const MAGIC = 'ICMESH1';
|
|
17
|
+
/** Periodically broadcasts a discovery beacon and reports peers it hears. */
|
|
18
|
+
export class LanBeacon {
|
|
19
|
+
opts;
|
|
20
|
+
sock;
|
|
21
|
+
timer;
|
|
22
|
+
port;
|
|
23
|
+
intervalMs;
|
|
24
|
+
stopped = false;
|
|
25
|
+
constructor(opts) {
|
|
26
|
+
this.opts = opts;
|
|
27
|
+
this.port = opts.discoveryPort ?? DEFAULT_LAN_DISCOVERY_PORT;
|
|
28
|
+
this.intervalMs = opts.intervalMs ?? 5000;
|
|
29
|
+
}
|
|
30
|
+
start() {
|
|
31
|
+
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: true });
|
|
32
|
+
this.sock = sock;
|
|
33
|
+
sock.on('error', err => {
|
|
34
|
+
this.opts.logger?.debug?.(`[federation] lan discovery socket error: ${err.message}`);
|
|
35
|
+
});
|
|
36
|
+
sock.on('message', (buf, rinfo) => this.onMessage(buf, rinfo));
|
|
37
|
+
sock.bind(this.port, () => {
|
|
38
|
+
try {
|
|
39
|
+
sock.setBroadcast(true);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* some platforms disallow; best-effort */
|
|
43
|
+
}
|
|
44
|
+
this.broadcast();
|
|
45
|
+
this.timer = setInterval(() => this.broadcast(), this.intervalMs);
|
|
46
|
+
this.opts.logger?.info?.(`[federation] lan discovery listening on udp/${this.port}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
beaconPayload() {
|
|
50
|
+
const wire = {
|
|
51
|
+
t: MAGIC,
|
|
52
|
+
v: 1,
|
|
53
|
+
nodeId: this.opts.nodeId,
|
|
54
|
+
name: this.opts.name,
|
|
55
|
+
role: this.opts.role,
|
|
56
|
+
meshPort: this.opts.meshPort,
|
|
57
|
+
tag: this.opts.tag,
|
|
58
|
+
auth: this.opts.auth,
|
|
59
|
+
};
|
|
60
|
+
return Buffer.from(JSON.stringify(wire));
|
|
61
|
+
}
|
|
62
|
+
broadcast() {
|
|
63
|
+
if (this.stopped || !this.sock)
|
|
64
|
+
return;
|
|
65
|
+
const msg = this.beaconPayload();
|
|
66
|
+
for (const addr of broadcastAddresses()) {
|
|
67
|
+
this.sock.send(msg, this.port, addr, err => {
|
|
68
|
+
if (err)
|
|
69
|
+
this.opts.logger?.debug?.(`[federation] lan beacon send ${addr} failed: ${err.message}`);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
onMessage(buf, rinfo) {
|
|
74
|
+
let data;
|
|
75
|
+
try {
|
|
76
|
+
data = JSON.parse(buf.toString('utf8'));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (!data || data.t !== MAGIC)
|
|
82
|
+
return;
|
|
83
|
+
if (typeof data.nodeId !== 'string' || typeof data.meshPort !== 'number')
|
|
84
|
+
return;
|
|
85
|
+
if (data.nodeId === this.opts.nodeId)
|
|
86
|
+
return; // ignore our own beacon
|
|
87
|
+
if (this.opts.tag && data.tag !== this.opts.tag)
|
|
88
|
+
return; // tag filter
|
|
89
|
+
const address = rinfo.address;
|
|
90
|
+
this.opts.onPeer({
|
|
91
|
+
nodeId: data.nodeId,
|
|
92
|
+
name: data.name ?? data.nodeId,
|
|
93
|
+
role: data.role ?? 'peer',
|
|
94
|
+
url: `http://${address}:${data.meshPort}`,
|
|
95
|
+
address,
|
|
96
|
+
tag: data.tag,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
stop() {
|
|
100
|
+
this.stopped = true;
|
|
101
|
+
if (this.timer)
|
|
102
|
+
clearInterval(this.timer);
|
|
103
|
+
try {
|
|
104
|
+
this.sock?.close();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* ignore */
|
|
108
|
+
}
|
|
109
|
+
this.sock = undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Limited broadcast + each interface's directed broadcast address (IPv4). */
|
|
113
|
+
function broadcastAddresses() {
|
|
114
|
+
const addrs = new Set(['255.255.255.255']);
|
|
115
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
116
|
+
for (const ni of list ?? []) {
|
|
117
|
+
// Node 20 reports family as 'IPv4'; guard the numeric form defensively.
|
|
118
|
+
if (ni.family !== 'IPv4' && ni.family !== 4)
|
|
119
|
+
continue;
|
|
120
|
+
if (ni.internal)
|
|
121
|
+
continue;
|
|
122
|
+
const b = directedBroadcast(ni.address, ni.netmask);
|
|
123
|
+
if (b)
|
|
124
|
+
addrs.add(b);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return [...addrs];
|
|
128
|
+
}
|
|
129
|
+
function directedBroadcast(ip, mask) {
|
|
130
|
+
const ipp = ip.split('.').map(Number);
|
|
131
|
+
const mp = mask.split('.').map(Number);
|
|
132
|
+
if (ipp.length !== 4 || mp.length !== 4 || [...ipp, ...mp].some(n => Number.isNaN(n)))
|
|
133
|
+
return null;
|
|
134
|
+
return ipp.map((o, i) => (o & mp[i]) | (~mp[i] & 0xff)).join('.');
|
|
135
|
+
}
|
|
@@ -7,6 +7,8 @@ export interface MeshServerOptions {
|
|
|
7
7
|
/** Optional shared token; requests must present it as Bearer auth. */
|
|
8
8
|
token?: string;
|
|
9
9
|
getManifest: () => NodeManifest;
|
|
10
|
+
/** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
|
|
11
|
+
getNodes?: () => unknown;
|
|
10
12
|
onRpc: RpcHandler;
|
|
11
13
|
logger: Logger;
|
|
12
14
|
}
|
|
@@ -69,6 +69,11 @@ export class MeshServer {
|
|
|
69
69
|
res.end(JSON.stringify(this.opts.getManifest()));
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
+
if (req.method === 'GET' && url.startsWith('/fed/nodes')) {
|
|
73
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
74
|
+
res.end(JSON.stringify(this.opts.getNodes?.() ?? []));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
72
77
|
if (req.method === 'GET' && url.startsWith('/fed/stream')) {
|
|
73
78
|
this.openStream(req, res);
|
|
74
79
|
return;
|
package/dist/kernel/setup.d.ts
CHANGED
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
import type Conf from 'conf';
|
|
7
7
|
import { Kernel } from '../kernel/index.js';
|
|
8
8
|
import type { Logger } from '../kernel/index.js';
|
|
9
|
-
import type { InfinicodeConfig } from './config-schema.js';
|
|
9
|
+
import type { InfinicodeConfig, CloudProviderConfig } from './config-schema.js';
|
|
10
10
|
export type { InfinicodeConfig, CloudProviderConfig, WorkerModelPref } from './config-schema.js';
|
|
11
|
+
/**
|
|
12
|
+
* Register (or re-register) cloud providers on a live kernel from config.
|
|
13
|
+
* Used at build time and for satellite hot-apply of hub-shared config, so new
|
|
14
|
+
* providers + their models become available without a restart. Returns the
|
|
15
|
+
* count of enabled providers registered.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerCloudProviders(kernel: Kernel, cloudProviders: CloudProviderConfig[]): number;
|
|
11
18
|
export declare function buildKernelFromConfig(config: Conf<InfinicodeConfig>, options?: {
|
|
12
19
|
logger?: Logger;
|
|
13
20
|
}): Kernel;
|
package/dist/kernel/setup.js
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { Kernel, OllamaProvider, OpenAICompatibleProvider, GeminiProvider } from '../kernel/index.js';
|
|
2
2
|
import { getProviderPreset } from './free-providers.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
fallbackURL: tailscaleMasterUrl,
|
|
12
|
-
defaultModel,
|
|
13
|
-
};
|
|
14
|
-
const kernel = new Kernel({ loadBuiltinWorkers: true, logger: options?.logger });
|
|
15
|
-
kernel.registerProvider('ollama', new OllamaProvider(ollamaOptions));
|
|
16
|
-
// Register cloud providers
|
|
17
|
-
const cloudProviders = config.get('cloudProviders') ?? [];
|
|
3
|
+
/**
|
|
4
|
+
* Register (or re-register) cloud providers on a live kernel from config.
|
|
5
|
+
* Used at build time and for satellite hot-apply of hub-shared config, so new
|
|
6
|
+
* providers + their models become available without a restart. Returns the
|
|
7
|
+
* count of enabled providers registered.
|
|
8
|
+
*/
|
|
9
|
+
export function registerCloudProviders(kernel, cloudProviders) {
|
|
10
|
+
let n = 0;
|
|
18
11
|
for (const cfg of cloudProviders) {
|
|
19
12
|
if (!cfg.enabled)
|
|
20
13
|
continue;
|
|
@@ -28,6 +21,7 @@ export function buildKernelFromConfig(config, options) {
|
|
|
28
21
|
knownModels: preset?.knownModels ?? [],
|
|
29
22
|
};
|
|
30
23
|
kernel.registerProvider(cfg.id, new GeminiProvider(geminiOptions));
|
|
24
|
+
n++;
|
|
31
25
|
continue;
|
|
32
26
|
}
|
|
33
27
|
const options = {
|
|
@@ -39,7 +33,25 @@ export function buildKernelFromConfig(config, options) {
|
|
|
39
33
|
knownModels: preset?.knownModels ?? [],
|
|
40
34
|
};
|
|
41
35
|
kernel.registerProvider(cfg.id, new OpenAICompatibleProvider(options));
|
|
36
|
+
n++;
|
|
42
37
|
}
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
export function buildKernelFromConfig(config, options) {
|
|
41
|
+
const masterUrl = config.get('masterUrl');
|
|
42
|
+
const tailscaleMasterUrl = config.get('tailscaleMasterUrl');
|
|
43
|
+
const defaultModel = config.get('defaultModel');
|
|
44
|
+
const ollamaOptions = {
|
|
45
|
+
id: 'ollama',
|
|
46
|
+
name: 'Ollama',
|
|
47
|
+
baseURL: masterUrl,
|
|
48
|
+
fallbackURL: tailscaleMasterUrl,
|
|
49
|
+
defaultModel,
|
|
50
|
+
};
|
|
51
|
+
const kernel = new Kernel({ loadBuiltinWorkers: true, logger: options?.logger });
|
|
52
|
+
kernel.registerProvider('ollama', new OllamaProvider(ollamaOptions));
|
|
53
|
+
// Register cloud providers
|
|
54
|
+
registerCloudProviders(kernel, config.get('cloudProviders') ?? []);
|
|
43
55
|
// Apply per-worker model pins — the worker runtime honors them when routing.
|
|
44
56
|
const workerModels = config.get('workerModels') ?? {};
|
|
45
57
|
if (workerModels && Object.keys(workerModels).length > 0) {
|
package/package.json
CHANGED