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.
- package/.opencode/plugins/home-logo-animation.tsx +5 -1
- package/.opencode/plugins/mesh-commands.tsx +4 -3
- package/.opencode/plugins/routing-mode-display.tsx +6 -2
- package/.opencode/tui.json +2 -1
- package/bin/robopark.js +2 -0
- package/dist/cli.js +46 -1
- package/dist/commands/maintain.d.ts +29 -0
- package/dist/commands/maintain.js +186 -0
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mesh-install.d.ts +10 -0
- package/dist/commands/mesh-install.js +160 -0
- package/dist/commands/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/run.js +82 -22
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +152 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
- package/dist/kernel/agents/backends/cli-backend.js +140 -0
- package/dist/kernel/agents/backends/detect.d.ts +17 -0
- package/dist/kernel/agents/backends/detect.js +140 -0
- package/dist/kernel/agents/backends/index.d.ts +11 -0
- package/dist/kernel/agents/backends/index.js +3 -0
- package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
- package/dist/kernel/agents/backends/parse-utils.js +72 -0
- package/dist/kernel/agents/backends/profiles.d.ts +67 -0
- package/dist/kernel/agents/backends/profiles.js +48 -0
- package/dist/kernel/agents/backends/registry.d.ts +31 -0
- package/dist/kernel/agents/backends/registry.js +174 -0
- package/dist/kernel/agents/backends/rotation.d.ts +51 -0
- package/dist/kernel/agents/backends/rotation.js +107 -0
- package/dist/kernel/agents/backends/types.d.ts +67 -0
- package/dist/kernel/agents/backends/types.js +1 -0
- package/dist/kernel/agents/index.d.ts +8 -0
- package/dist/kernel/agents/index.js +8 -0
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +371 -0
- package/dist/kernel/federation/federation.d.ts +112 -2
- package/dist/kernel/federation/federation.js +270 -10
- package/dist/kernel/federation/moltfed-adapter.js +1 -0
- package/dist/kernel/federation/telemetry.d.ts +1 -1
- package/dist/kernel/federation/telemetry.js +2 -1
- package/dist/kernel/federation/transport-http.d.ts +17 -1
- package/dist/kernel/federation/transport-http.js +65 -2
- package/dist/kernel/federation/types.d.ts +46 -1
- package/dist/kernel/free-providers.js +83 -0
- package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
- package/dist/kernel/gateway/openai-gateway.js +428 -49
- package/dist/kernel/index.d.ts +2 -1
- package/dist/kernel/index.js +3 -1
- package/dist/kernel/logger.d.ts +16 -3
- package/dist/kernel/logger.js +38 -0
- package/dist/kernel/mcp/mcp-server.js +65 -10
- package/dist/kernel/orchestrator.d.ts +4 -0
- package/dist/kernel/orchestrator.js +34 -0
- package/dist/kernel/provider-manager.d.ts +13 -0
- package/dist/kernel/provider-manager.js +58 -5
- package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
- package/dist/kernel/providers/openai-compatible-provider.js +22 -3
- package/dist/kernel/recovery-manager.js +55 -15
- package/dist/kernel/router.js +15 -2
- package/dist/kernel/types.d.ts +12 -2
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +6 -3
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* robopark — the RoboPark fleet control CLI.
|
|
3
|
+
*
|
|
4
|
+
* A thin operator layer that rides the infinicode mesh instead of SSH-ing around:
|
|
5
|
+
*
|
|
6
|
+
* robopark setup --role scheduler --hub http://100.x:47913 --start
|
|
7
|
+
* robopark fleet # live table of every node the hub can see
|
|
8
|
+
* robopark run panda-a /status # run a command on one node
|
|
9
|
+
* robopark apply # push shared config (providers/policy) to the fleet
|
|
10
|
+
* robopark dashboard --open # open the web control UI
|
|
11
|
+
*
|
|
12
|
+
* `setup` writes this device's role + mesh config (the nine manual steps,
|
|
13
|
+
* automated). The other verbs talk to a running node's HTTP API (default the
|
|
14
|
+
* local node at 127.0.0.1:<port>), so the CLI never needs its own transport —
|
|
15
|
+
* it reuses the mesh the node already stood up.
|
|
16
|
+
*/
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import { saveRole } from '../kernel/federation/role-context.js';
|
|
20
|
+
import { serve } from './serve.js';
|
|
21
|
+
const DEFAULT_PORT = 47913;
|
|
22
|
+
function nodeUrl(config, urlOpt) {
|
|
23
|
+
if (urlOpt)
|
|
24
|
+
return urlOpt.replace(/\/+$/, '');
|
|
25
|
+
const fed = (config.get('federation') ?? {});
|
|
26
|
+
return `http://127.0.0.1:${fed.port ?? DEFAULT_PORT}`;
|
|
27
|
+
}
|
|
28
|
+
function authToken(config, tokenOpt) {
|
|
29
|
+
const fed = (config.get('federation') ?? {});
|
|
30
|
+
return tokenOpt ?? fed.token;
|
|
31
|
+
}
|
|
32
|
+
function headers(token) {
|
|
33
|
+
const h = { 'content-type': 'application/json' };
|
|
34
|
+
if (token)
|
|
35
|
+
h['authorization'] = `Bearer ${token}`;
|
|
36
|
+
return h;
|
|
37
|
+
}
|
|
38
|
+
async function getStatus(url, token) {
|
|
39
|
+
const res = await fetch(`${url}/fed/status`, { headers: headers(token), signal: AbortSignal.timeout(6000) });
|
|
40
|
+
if (res.status === 401)
|
|
41
|
+
throw new Error('unauthorized — this node requires a token (pass --token <secret>)');
|
|
42
|
+
if (!res.ok)
|
|
43
|
+
throw new Error(`node returned ${res.status}`);
|
|
44
|
+
return (await res.json());
|
|
45
|
+
}
|
|
46
|
+
function unreachable(url, err) {
|
|
47
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
48
|
+
return [
|
|
49
|
+
chalk.red(` ✗ no mesh node reachable at ${url}`),
|
|
50
|
+
chalk.dim(` ${msg}`),
|
|
51
|
+
chalk.dim(' start one with infinicode serve --dashboard or robopark setup --start'),
|
|
52
|
+
].join('\n');
|
|
53
|
+
}
|
|
54
|
+
// ── loadbar / formatting helpers ───────────────────────────────────────────────
|
|
55
|
+
function bar(pct, width = 10) {
|
|
56
|
+
const v = Math.max(0, Math.min(100, Math.round(pct)));
|
|
57
|
+
const filled = Math.round((v / 100) * width);
|
|
58
|
+
const glyph = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
59
|
+
const color = v >= 80 ? chalk.red : v >= 50 ? chalk.yellow : chalk.green;
|
|
60
|
+
return color(glyph);
|
|
61
|
+
}
|
|
62
|
+
function pad(s, n) {
|
|
63
|
+
const len = [...s].length;
|
|
64
|
+
return len >= n ? s : s + ' '.repeat(n - len);
|
|
65
|
+
}
|
|
66
|
+
/** Pad the visible text to `width`, THEN colorize — so ANSI codes never count
|
|
67
|
+
* toward column width (the bug you get from padding an already-colored string). */
|
|
68
|
+
function cell(text, width, color) {
|
|
69
|
+
const p = pad(text, width);
|
|
70
|
+
return color ? color(p) : p;
|
|
71
|
+
}
|
|
72
|
+
function renderFleet(snap) {
|
|
73
|
+
const nodes = snap.nodes ?? [];
|
|
74
|
+
const lines = [];
|
|
75
|
+
const up = nodes.filter(n => n.connected).length;
|
|
76
|
+
const runs = (snap.runs ?? []).filter(r => r.status === 'running').length;
|
|
77
|
+
lines.push('');
|
|
78
|
+
lines.push(chalk.bold(' RoboPark fleet') + chalk.dim(` ${up}/${nodes.length} up · ${runs} active run${runs === 1 ? '' : 's'}`));
|
|
79
|
+
lines.push(chalk.dim(' ' + '─'.repeat(72)));
|
|
80
|
+
if (!nodes.length) {
|
|
81
|
+
lines.push(chalk.dim(' no nodes reporting yet — bring peers online with `infinicode serve`.'));
|
|
82
|
+
return lines.join('\n');
|
|
83
|
+
}
|
|
84
|
+
const W = { name: 22, role: 11, state: 8, load: 16, site: 12 };
|
|
85
|
+
lines.push(' ' +
|
|
86
|
+
chalk.dim(pad('NODE', W.name) + pad('ROLE', W.role) + pad('STATE', W.state) + pad('LOAD', W.load) + pad('SITE', W.site) + 'VER'));
|
|
87
|
+
nodes.sort((a, b) => Number(b.connected) - Number(a.connected));
|
|
88
|
+
for (const n of nodes) {
|
|
89
|
+
const isSelf = snap.self && snap.self.nodeId === n.nodeId;
|
|
90
|
+
const name = n.displayName + (isSelf ? ' ‹this›' : '');
|
|
91
|
+
const state = cell(n.connected ? '● up' : '○ down', W.state, n.connected ? chalk.green : chalk.dim);
|
|
92
|
+
// LOAD: colored bar (fixed 10 visible glyphs) + percent, hand-padded so the
|
|
93
|
+
// bar's ANSI codes don't skew the column.
|
|
94
|
+
let load;
|
|
95
|
+
if (n.connected) {
|
|
96
|
+
const pct = `${Math.round(n.load ?? 0)}%`;
|
|
97
|
+
const visible = 10 + 1 + pct.length; // bar + space + "NN%"
|
|
98
|
+
load = `${bar(n.load ?? 0)} ${pct}` + ' '.repeat(Math.max(0, W.load - visible));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
load = cell('—', W.load, chalk.dim);
|
|
102
|
+
}
|
|
103
|
+
lines.push(' ' +
|
|
104
|
+
cell(name, W.name, isSelf ? chalk.bold.cyan : chalk.bold) +
|
|
105
|
+
cell(n.role, W.role, chalk.yellow) +
|
|
106
|
+
state +
|
|
107
|
+
load +
|
|
108
|
+
cell(n.site ?? '-', W.site, n.site ? undefined : chalk.dim) +
|
|
109
|
+
chalk.dim('v' + (n.version ?? '?')));
|
|
110
|
+
}
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
const ROLE_PRESETS = {
|
|
114
|
+
scheduler: { mesh: 'hub', blurb: 'on-site hub (LiveKit Server 1) — bridges tailnet ↔ LAN, serves the dashboard', dashboard: true, lan: true, tailscale: true },
|
|
115
|
+
robot: { mesh: 'satellite', blurb: 'a robot Pi — accept-only, LAN-discovered, never dials out', dashboard: false, lan: true, tailscale: false },
|
|
116
|
+
laptop: { mesh: 'peer', blurb: 'your control station — drives the fleet over the mesh', dashboard: false, lan: false, tailscale: true },
|
|
117
|
+
relay: { mesh: 'relay', blurb: 'a relay hop between two networks', dashboard: false, lan: true, tailscale: true },
|
|
118
|
+
};
|
|
119
|
+
// Power users can pass raw mesh roles too; fold them onto the matching preset.
|
|
120
|
+
const MESH_ALIASES = { hub: 'scheduler', satellite: 'robot', peer: 'laptop' };
|
|
121
|
+
function resolveRole(input) {
|
|
122
|
+
if (!input)
|
|
123
|
+
return null;
|
|
124
|
+
const key = input.toLowerCase();
|
|
125
|
+
const label = MESH_ALIASES[key] ?? key;
|
|
126
|
+
const preset = ROLE_PRESETS[label];
|
|
127
|
+
return preset ? { label, preset } : null;
|
|
128
|
+
}
|
|
129
|
+
/** Interactive fallback — the streamlined "just run `robopark setup`" path. */
|
|
130
|
+
async function setupWizard(opts) {
|
|
131
|
+
const inquirer = (await import('inquirer')).default;
|
|
132
|
+
const a = (await inquirer.prompt([
|
|
133
|
+
{
|
|
134
|
+
type: 'list',
|
|
135
|
+
name: 'role',
|
|
136
|
+
message: 'What is this device?',
|
|
137
|
+
choices: [
|
|
138
|
+
{ name: 'scheduler — the on-site hub (LiveKit Server 1)', value: 'scheduler' },
|
|
139
|
+
{ name: 'robot — a talking-robot Raspberry Pi', value: 'robot' },
|
|
140
|
+
{ name: 'laptop — your control station (drives the fleet)', value: 'laptop' },
|
|
141
|
+
],
|
|
142
|
+
default: opts.robot ? 'robot' : opts.laptop ? 'laptop' : 'scheduler',
|
|
143
|
+
},
|
|
144
|
+
{ type: 'input', name: 'name', message: 'Name for this device:', default: opts.name },
|
|
145
|
+
{ type: 'input', name: 'site', message: 'Site / location (optional):', default: opts.site ?? '' },
|
|
146
|
+
{
|
|
147
|
+
type: 'input',
|
|
148
|
+
name: 'hub',
|
|
149
|
+
message: 'Hub URL to join (blank if this IS the scheduler):',
|
|
150
|
+
default: opts.hub ?? '',
|
|
151
|
+
when: (ans) => ans.role !== 'scheduler',
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
type: 'input',
|
|
155
|
+
name: 'gateway',
|
|
156
|
+
message: 'InfiniBot gateway ws:// URL (optional — surfaces the fleet in InfiniBot):',
|
|
157
|
+
default: opts.gateway ?? '',
|
|
158
|
+
when: (ans) => ans.role === 'scheduler',
|
|
159
|
+
},
|
|
160
|
+
{ type: 'input', name: 'token', message: 'Shared token every device presents (optional but recommended):', default: opts.token ?? '' },
|
|
161
|
+
{ type: 'confirm', name: 'start', message: 'Start the node now?', default: true },
|
|
162
|
+
]));
|
|
163
|
+
return {
|
|
164
|
+
...opts,
|
|
165
|
+
role: a.role,
|
|
166
|
+
name: a.name || opts.name,
|
|
167
|
+
site: a.site || undefined,
|
|
168
|
+
hub: a.hub || opts.hub,
|
|
169
|
+
gateway: a.gateway || opts.gateway,
|
|
170
|
+
token: a.token || opts.token,
|
|
171
|
+
start: a.start,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async function roboparkSetup(config, opts) {
|
|
175
|
+
// Shorthand flags → --role.
|
|
176
|
+
const roleInput = opts.role ?? (opts.scheduler ? 'scheduler' : opts.robot ? 'robot' : opts.laptop ? 'laptop' : undefined);
|
|
177
|
+
// No role given? Run the guided wizard in a TTY (unless --yes); otherwise error.
|
|
178
|
+
if (!roleInput && !opts.yes) {
|
|
179
|
+
if (process.stdin.isTTY) {
|
|
180
|
+
opts = await setupWizard(opts);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.log(chalk.red(' robopark setup: pass --scheduler | --robot | --laptop (or --role <role>)'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const resolved = resolveRole(opts.role ?? roleInput ?? 'laptop');
|
|
188
|
+
if (!resolved) {
|
|
189
|
+
console.log(chalk.red(` unknown role "${opts.role}". Use: scheduler | robot | laptop | relay`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const { label, preset } = resolved;
|
|
193
|
+
const meshRole = preset.mesh;
|
|
194
|
+
// Preset defaults, overridable by explicit flags.
|
|
195
|
+
const dashboard = opts.dashboard ?? preset.dashboard;
|
|
196
|
+
const lan = opts.lan ?? preset.lan;
|
|
197
|
+
const tailscale = opts.tailscale ?? (preset.tailscale && !opts.hub); // seed URL replaces discovery
|
|
198
|
+
const port = opts.port ? parseInt(opts.port, 10) : DEFAULT_PORT;
|
|
199
|
+
const prev = (config.get('federation') ?? {});
|
|
200
|
+
const fed = {
|
|
201
|
+
...prev,
|
|
202
|
+
enabled: true,
|
|
203
|
+
role: meshRole,
|
|
204
|
+
port,
|
|
205
|
+
displayName: opts.name ?? prev.displayName,
|
|
206
|
+
token: opts.token ?? prev.token,
|
|
207
|
+
seeds: opts.hub ? Array.from(new Set([...(prev.seeds ?? []), opts.hub])) : prev.seeds,
|
|
208
|
+
lan,
|
|
209
|
+
};
|
|
210
|
+
config.set('federation', fed);
|
|
211
|
+
// Persist the device's role/site brief — injected into every task run here.
|
|
212
|
+
const roleCtx = saveRole({
|
|
213
|
+
role: opts.name ?? label,
|
|
214
|
+
site: opts.site,
|
|
215
|
+
architecture: opts.architecture,
|
|
216
|
+
});
|
|
217
|
+
console.log(chalk.bold('\n robopark setup'));
|
|
218
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
219
|
+
console.log(` device: ${chalk.yellow(label)} ${chalk.dim('→ mesh role ' + meshRole)}`);
|
|
220
|
+
console.log(chalk.dim(` ${preset.blurb}`));
|
|
221
|
+
console.log(` name: ${chalk.cyan(opts.name ?? '(hostname)')}${opts.site ? chalk.dim(' @ ' + opts.site) : ''}`);
|
|
222
|
+
console.log(` listen: ${chalk.cyan('0.0.0.0:' + port)}`);
|
|
223
|
+
if (opts.hub)
|
|
224
|
+
console.log(` hub: ${chalk.cyan(opts.hub)}`);
|
|
225
|
+
if (fed.token)
|
|
226
|
+
console.log(` auth: ${chalk.green('token set')}`);
|
|
227
|
+
if (lan)
|
|
228
|
+
console.log(` lan: ${chalk.green('auto-discovery on')}`);
|
|
229
|
+
if (tailscale)
|
|
230
|
+
console.log(` tailnet:${chalk.green(' discovery on')}${opts.tag ? chalk.dim(' · tag ' + opts.tag) : ''}`);
|
|
231
|
+
if (dashboard)
|
|
232
|
+
console.log(` ui: ${chalk.green('dashboard on')}`);
|
|
233
|
+
console.log();
|
|
234
|
+
const serveOpts = {
|
|
235
|
+
role: meshRole,
|
|
236
|
+
name: opts.name,
|
|
237
|
+
port: String(port),
|
|
238
|
+
token: fed.token,
|
|
239
|
+
seed: opts.hub ? [opts.hub] : undefined,
|
|
240
|
+
tailscale,
|
|
241
|
+
lan,
|
|
242
|
+
tag: opts.tag,
|
|
243
|
+
dashboard,
|
|
244
|
+
gateway: opts.gateway,
|
|
245
|
+
gatewayToken: opts.gatewayToken,
|
|
246
|
+
};
|
|
247
|
+
if (opts.start) {
|
|
248
|
+
console.log(chalk.dim(' starting node…\n'));
|
|
249
|
+
await serve(config, serveOpts);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const startCmd = [
|
|
253
|
+
'infinicode serve',
|
|
254
|
+
`--role ${meshRole}`,
|
|
255
|
+
dashboard ? '--dashboard' : '',
|
|
256
|
+
lan ? '--lan' : '',
|
|
257
|
+
tailscale ? '--tailscale' : '',
|
|
258
|
+
opts.tag ? `--tag ${opts.tag}` : '',
|
|
259
|
+
opts.hub ? `--seed ${opts.hub}` : '',
|
|
260
|
+
fed.token ? `--token ${fed.token}` : '',
|
|
261
|
+
opts.gateway ? `--gateway ${opts.gateway}` : '',
|
|
262
|
+
]
|
|
263
|
+
.filter(Boolean)
|
|
264
|
+
.join(' ');
|
|
265
|
+
console.log(chalk.dim(' saved. start it now with: ') + chalk.cyan('robopark setup --' + label + ' --start'));
|
|
266
|
+
console.log(chalk.dim(' or run the node directly: ') + chalk.cyan(startCmd));
|
|
267
|
+
console.log();
|
|
268
|
+
}
|
|
269
|
+
async function roboparkFleet(config, opts) {
|
|
270
|
+
const url = nodeUrl(config, opts.url);
|
|
271
|
+
const token = authToken(config, opts.token);
|
|
272
|
+
const once = async () => {
|
|
273
|
+
let snap;
|
|
274
|
+
try {
|
|
275
|
+
snap = await getStatus(url, token);
|
|
276
|
+
}
|
|
277
|
+
catch (err) {
|
|
278
|
+
console.log(unreachable(url, err));
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (opts.json) {
|
|
282
|
+
console.log(JSON.stringify(snap, null, 2));
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
if (opts.watch)
|
|
286
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
287
|
+
console.log(renderFleet(snap));
|
|
288
|
+
if (opts.watch)
|
|
289
|
+
console.log(chalk.dim(`\n updated ${new Date().toLocaleTimeString()} · Ctrl-C to stop`));
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
if (!opts.watch) {
|
|
294
|
+
await once();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const ok = await once();
|
|
298
|
+
if (!ok)
|
|
299
|
+
return; // don't spin on an unreachable node
|
|
300
|
+
const timer = setInterval(() => void once(), 2000);
|
|
301
|
+
await new Promise(resolve => {
|
|
302
|
+
process.on('SIGINT', () => {
|
|
303
|
+
clearInterval(timer);
|
|
304
|
+
resolve();
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
async function roboparkRun(config, node, command, opts) {
|
|
309
|
+
const url = nodeUrl(config, opts.url);
|
|
310
|
+
const token = authToken(config, opts.token);
|
|
311
|
+
const cmd = command.join(' ').trim();
|
|
312
|
+
if (!cmd) {
|
|
313
|
+
console.log(chalk.red(' nothing to run — usage: robopark run <node> <command…>'));
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
// Local node keywords bypass the /node dispatch wrapper.
|
|
317
|
+
const local = ['.', 'self', 'local', 'here'].includes(node.toLowerCase());
|
|
318
|
+
const input = local ? (cmd.startsWith('/') ? cmd : `/${cmd}`) : `/node ${node} ${cmd}`;
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetch(`${url}/fed/command`, {
|
|
321
|
+
method: 'POST',
|
|
322
|
+
headers: headers(token),
|
|
323
|
+
body: JSON.stringify({ input }),
|
|
324
|
+
signal: AbortSignal.timeout(120_000),
|
|
325
|
+
});
|
|
326
|
+
const body = (await res.json().catch(() => ({ ok: false, text: `HTTP ${res.status}` })));
|
|
327
|
+
const text = body.text ?? JSON.stringify(body);
|
|
328
|
+
console.log(body.ok === false ? chalk.yellow(text) : text);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
console.log(unreachable(url, err));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function roboparkApply(config, opts) {
|
|
335
|
+
const url = nodeUrl(config, opts.url);
|
|
336
|
+
const token = authToken(config, opts.token);
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch(`${url}/fed/apply`, {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
headers: headers(token),
|
|
341
|
+
body: '{}',
|
|
342
|
+
signal: AbortSignal.timeout(15_000),
|
|
343
|
+
});
|
|
344
|
+
const body = (await res.json().catch(() => ({ ok: false, text: `HTTP ${res.status}` })));
|
|
345
|
+
console.log((body.ok === false ? chalk.yellow : chalk.green)(' ' + (body.text ?? 'applied')));
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
console.log(unreachable(url, err));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function openBrowser(url) {
|
|
352
|
+
const platform = process.platform;
|
|
353
|
+
const cmd = platform === 'win32' ? 'cmd' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
354
|
+
const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
355
|
+
try {
|
|
356
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
/* fall back to just printing */
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function roboparkDashboard(config, opts) {
|
|
363
|
+
const base = nodeUrl(config, opts.url);
|
|
364
|
+
const token = authToken(config, opts.token);
|
|
365
|
+
const link = `${base}/${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
|
366
|
+
// Confirm the node is actually serving the dashboard before pointing there.
|
|
367
|
+
try {
|
|
368
|
+
const res = await fetch(link, { headers: headers(token), signal: AbortSignal.timeout(5000) });
|
|
369
|
+
if (res.status === 404) {
|
|
370
|
+
console.log(chalk.yellow(` ⚠ node at ${base} is up but the dashboard is off.`));
|
|
371
|
+
console.log(chalk.dim(' restart it with infinicode serve --dashboard'));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!res.ok)
|
|
375
|
+
throw new Error(`HTTP ${res.status}`);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.log(unreachable(base, err));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
console.log(chalk.bold('\n RoboPark control dashboard'));
|
|
382
|
+
console.log(' ' + chalk.cyan(link) + '\n');
|
|
383
|
+
if (opts.open)
|
|
384
|
+
openBrowser(link);
|
|
385
|
+
}
|
|
386
|
+
// ── wiring ─────────────────────────────────────────────────────────────────────
|
|
387
|
+
/** Attach the robopark verbs to a Commander command (the `robopark` program in
|
|
388
|
+
* the standalone bin, or the `infinicode robopark` group). */
|
|
389
|
+
export function attachRoboparkSubcommands(cmd, config) {
|
|
390
|
+
cmd
|
|
391
|
+
.command('setup')
|
|
392
|
+
.description('Set up this device in one pass. Run bare for a guided wizard, or pass a role to skip it.')
|
|
393
|
+
.option('--scheduler', 'this device is the on-site hub (LiveKit Server 1)')
|
|
394
|
+
.option('--robot', 'this device is a talking-robot Raspberry Pi')
|
|
395
|
+
.option('--laptop', 'this device is your control station')
|
|
396
|
+
.option('--role <role>', 'explicit role: scheduler | robot | laptop | relay (or raw mesh role hub/peer/satellite)')
|
|
397
|
+
.option('--site <site>', 'physical location for fleet grouping, e.g. "Tel Aviv"')
|
|
398
|
+
.option('--name <name>', 'friendly device name (defaults to hostname)')
|
|
399
|
+
.option('--hub <url>', 'hub base URL to join, e.g. http://100.x.y.z:47913 (robots/laptops)')
|
|
400
|
+
.option('--gateway <url>', 'InfiniBot gateway ws:// URL to surface the fleet in InfiniBot (scheduler)')
|
|
401
|
+
.option('--gateway-token <token>', 'auth token for the InfiniBot gateway')
|
|
402
|
+
.option('--token <token>', 'shared bearer token every device must present')
|
|
403
|
+
.option('--port <port>', 'mesh listen port (default 47913)')
|
|
404
|
+
.option('--tag <tag>', 'only discover peers carrying this tag (e.g. tag:robopark)')
|
|
405
|
+
.option('--architecture <text>', "one-line brief of this device's job in the wider system")
|
|
406
|
+
.option('--lan', 'force zero-config LAN auto-discovery (on by default for scheduler/robot)')
|
|
407
|
+
.option('--tailscale', 'force Tailscale peer discovery (on by default for scheduler/laptop)')
|
|
408
|
+
.option('--dashboard', 'force the web control UI (on by default for scheduler)')
|
|
409
|
+
.option('--start', 'launch the node now instead of just writing config')
|
|
410
|
+
.option('--yes', 'skip the guided wizard even with no role (non-interactive)')
|
|
411
|
+
.action(async (opts) => {
|
|
412
|
+
await roboparkSetup(config, opts);
|
|
413
|
+
});
|
|
414
|
+
cmd
|
|
415
|
+
.command('fleet')
|
|
416
|
+
.alias('status')
|
|
417
|
+
.description('Show every node the local (or a given) mesh node can see')
|
|
418
|
+
.option('--url <url>', 'node HTTP base to query (default the local node)')
|
|
419
|
+
.option('--token <token>', 'bearer token if the node requires auth')
|
|
420
|
+
.option('--watch', 'refresh the table every 2s until Ctrl-C')
|
|
421
|
+
.option('--json', 'print the raw status snapshot as JSON')
|
|
422
|
+
.action(async (opts) => {
|
|
423
|
+
await roboparkFleet(config, opts);
|
|
424
|
+
});
|
|
425
|
+
cmd
|
|
426
|
+
.command('run <node> <command...>')
|
|
427
|
+
.description('Run a slash-command on a node (use "." for the local node), e.g. robopark run panda-a /status')
|
|
428
|
+
.option('--url <url>', 'node HTTP base to send through (default the local node)')
|
|
429
|
+
.option('--token <token>', 'bearer token if the node requires auth')
|
|
430
|
+
.action(async (node, command, opts) => {
|
|
431
|
+
await roboparkRun(config, node, command, opts);
|
|
432
|
+
});
|
|
433
|
+
cmd
|
|
434
|
+
.command('apply')
|
|
435
|
+
.description('Publish this hub\'s shared config (providers, keys, policy) to every satellite')
|
|
436
|
+
.option('--url <url>', 'node HTTP base to apply through (default the local node)')
|
|
437
|
+
.option('--token <token>', 'bearer token if the node requires auth')
|
|
438
|
+
.action(async (opts) => {
|
|
439
|
+
await roboparkApply(config, opts);
|
|
440
|
+
});
|
|
441
|
+
cmd
|
|
442
|
+
.command('dashboard')
|
|
443
|
+
.description('Print (and optionally open) the web control dashboard URL')
|
|
444
|
+
.option('--url <url>', 'node HTTP base (default the local node)')
|
|
445
|
+
.option('--token <token>', 'bearer token if the node requires auth')
|
|
446
|
+
.option('--open', 'open the dashboard in the default browser')
|
|
447
|
+
.action(async (opts) => {
|
|
448
|
+
await roboparkDashboard(config, opts);
|
|
449
|
+
});
|
|
450
|
+
}
|
package/dist/commands/run.js
CHANGED
|
@@ -18,12 +18,12 @@ import chalk from 'chalk';
|
|
|
18
18
|
import ora from 'ora';
|
|
19
19
|
import { existsSync, readFileSync } from 'node:fs';
|
|
20
20
|
import { dirname, join, resolve } from 'node:path';
|
|
21
|
-
import { fileURLToPath
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { createRequire } from 'node:module';
|
|
23
23
|
import { ASCII_VIDEO_FPS, ASCII_VIDEO_FRAMES } from '../ascii-video-animation.js';
|
|
24
24
|
import { getProviderPreset } from '../kernel/free-providers.js';
|
|
25
25
|
import { buildKernelFromConfig } from '../kernel/setup.js';
|
|
26
|
-
import { SilentLogger, Federation, loadOrCreateIdentity, KernelGateway } from '../kernel/index.js';
|
|
26
|
+
import { SilentLogger, FileLogger, Federation, loadOrCreateIdentity, KernelGateway, phaseBenchmarkFloor } from '../kernel/index.js';
|
|
27
27
|
import { openAICompatBaseURL } from '../kernel/provider-url.js';
|
|
28
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
29
|
const PROMPT_ANIMATION_MAX_FRAMES = Math.min(ASCII_VIDEO_FRAMES.length, ASCII_VIDEO_FPS * 3);
|
|
@@ -150,7 +150,11 @@ function modelEntry(m) {
|
|
|
150
150
|
// Only set a limit when we have a real context value (> the 8192 default) —
|
|
151
151
|
// a wrong-small limit would make opencode compact large models too early.
|
|
152
152
|
if (m.contextLength && m.contextLength > 8192) {
|
|
153
|
-
|
|
153
|
+
// Generous output headroom so a large single-file generation completes in one
|
|
154
|
+
// turn instead of truncating at the cap and stalling ("continue"). Frontier
|
|
155
|
+
// models support 16–32k output; a too-high ask that a model rejects is caught
|
|
156
|
+
// by the gateway's swap-on-error, so we bias toward completing big writes.
|
|
157
|
+
entry.limit = { context: m.contextLength, output: Math.min(32768, Math.max(4096, Math.floor(m.contextLength / 8))) };
|
|
154
158
|
}
|
|
155
159
|
return entry;
|
|
156
160
|
}
|
|
@@ -442,6 +446,30 @@ function buildGatewayTuiConfig(gatewayUrl, providers) {
|
|
|
442
446
|
},
|
|
443
447
|
};
|
|
444
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Pick the strongest coding-capable model across the FULL fetched catalog (not
|
|
451
|
+
* just health-verified providers). Groq health-checks fastest, so ranking over
|
|
452
|
+
* only-healthy providers at startup hands the default to Groq/Llama before a
|
|
453
|
+
* frontier provider (NVIDIA/HF/Chutes) finishes verifying. Ranking the whole
|
|
454
|
+
* catalog by benchmark lets GLM/Kimi/DeepSeek/Qwen3 win; the gateway swaps on
|
|
455
|
+
* failure anyway, so choosing a not-yet-verified provider is safe.
|
|
456
|
+
*/
|
|
457
|
+
function pickStrongestModel(kernel, modelsByProvider) {
|
|
458
|
+
let best = null;
|
|
459
|
+
let bestBench = -1;
|
|
460
|
+
for (const [providerId, models] of Object.entries(modelsByProvider)) {
|
|
461
|
+
for (const m of models) {
|
|
462
|
+
if (!m.capabilities?.includes('coding'))
|
|
463
|
+
continue;
|
|
464
|
+
const b = kernel.router.benchmarkFor(m);
|
|
465
|
+
if (b > bestBench) {
|
|
466
|
+
bestBench = b;
|
|
467
|
+
best = { providerId, modelId: m.id };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return best;
|
|
472
|
+
}
|
|
445
473
|
export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
446
474
|
const masterUrl = await resolveConfiguredMasterUrl(config);
|
|
447
475
|
const masterReachable = await isOllamaReachable(masterUrl);
|
|
@@ -457,11 +485,13 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
457
485
|
// (/nodes, /build, /activity, …) have a local node to talk to. Reuses a
|
|
458
486
|
// running `infinicode serve` if one is up, else starts an in-process node.
|
|
459
487
|
const mesh = await startLocalMeshNode(config, sessionKernel);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
488
|
+
// Always give the in-TUI mesh commands a target: the node we started, or the
|
|
489
|
+
// default local port (a separate `infinicode serve` may be there / come up).
|
|
490
|
+
const fedCfg = config.get('federation') ?? {};
|
|
491
|
+
process.env.INFINICODE_MESH_URL = mesh?.url ?? `http://127.0.0.1:${fedCfg.port ?? 47913}`;
|
|
492
|
+
const meshToken = mesh?.token ?? fedCfg.token;
|
|
493
|
+
if (meshToken)
|
|
494
|
+
process.env.INFINICODE_MESH_TOKEN = meshToken;
|
|
465
495
|
// One kernel pass: load every provider's full model catalog (in parallel) and
|
|
466
496
|
// route the default model under the policy.
|
|
467
497
|
const routeSpinner = ora('loading model catalog & routing...').start();
|
|
@@ -471,21 +501,52 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
471
501
|
// single provider, so interactive chat gains full kernel parity: auto-routing,
|
|
472
502
|
// escalate-on-error, provider rotation, health/quota, live telemetry.
|
|
473
503
|
const gwProviders = buildGatewayProviders(config, masterUrl, masterReachable, defaultModel, modelsByProvider);
|
|
504
|
+
// Durable trace of routing/swap/truncation/continuation decisions — the gateway's
|
|
505
|
+
// console belongs to the TUI, so without this a mid-task death is invisible.
|
|
506
|
+
const gatewayLogFile = join(dirname(config.path), 'gateway.log');
|
|
474
507
|
let gateway;
|
|
475
508
|
if (!process.env.INFINICODE_NO_GATEWAY && gwProviders.length > 0) {
|
|
476
509
|
try {
|
|
477
|
-
gateway = new KernelGateway({
|
|
510
|
+
gateway = new KernelGateway({
|
|
511
|
+
kernel: sessionKernel,
|
|
512
|
+
providers: gwProviders,
|
|
513
|
+
policyName,
|
|
514
|
+
logger: new FileLogger(gatewayLogFile),
|
|
515
|
+
// Bias interactive coding toward capable models (soft floor, not a pin).
|
|
516
|
+
minBenchmark: phaseBenchmarkFloor(['coding', 'frontend']),
|
|
517
|
+
});
|
|
478
518
|
await gateway.start();
|
|
519
|
+
console.log(chalk.green(` ⚡ kernel gateway on ${gateway.url}`) + chalk.dim(` · log: ${gatewayLogFile}`));
|
|
479
520
|
}
|
|
480
|
-
catch {
|
|
481
|
-
|
|
521
|
+
catch (err) {
|
|
522
|
+
// Surface WHY the kernel gateway couldn't start — without it there's no
|
|
523
|
+
// routing/reliability, so a silent fallback to direct-Llama is exactly the
|
|
524
|
+
// "died / still using llama" symptom. Make it loud.
|
|
525
|
+
console.log(chalk.yellow(`\n ⚠ kernel gateway failed to start — falling back to direct routing (no auto-swap/reliability).`));
|
|
526
|
+
console.log(chalk.dim(` reason: ${err instanceof Error ? err.message : String(err)}`));
|
|
527
|
+
gateway?.stop().catch(() => undefined);
|
|
528
|
+
gateway = undefined;
|
|
482
529
|
}
|
|
483
530
|
}
|
|
484
531
|
let providers;
|
|
485
532
|
let modelRef;
|
|
486
533
|
if (gateway) {
|
|
487
534
|
providers = buildGatewayTuiConfig(gateway.url, gwProviders);
|
|
488
|
-
|
|
535
|
+
// Default to the CONCRETE strongest model (not "auto") so the footer shows a
|
|
536
|
+
// real, capable model and the session stays consistent — but pick it from the
|
|
537
|
+
// FULL catalog by benchmark so it's a frontier model, not fast-but-mid Groq/
|
|
538
|
+
// Llama. Gateway still swaps on failure; "auto" stays selectable in the palette.
|
|
539
|
+
const best = pickStrongestModel(sessionKernel, modelsByProvider) ?? routed;
|
|
540
|
+
if (best) {
|
|
541
|
+
const key = `${best.providerId}/${best.modelId}`;
|
|
542
|
+
const gwModels = providers['infinicode'].models;
|
|
543
|
+
if (!gwModels[key])
|
|
544
|
+
gwModels[key] = modelEntry({ id: key, name: key });
|
|
545
|
+
modelRef = `infinicode/${key}`;
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
modelRef = 'infinicode/auto';
|
|
549
|
+
}
|
|
489
550
|
}
|
|
490
551
|
else {
|
|
491
552
|
// Fallback: talk to providers directly (no kernel in the loop).
|
|
@@ -511,15 +572,15 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
511
572
|
const routingInfo = gateway
|
|
512
573
|
? `KERNEL ${policy} ${providerCount}P ${pinnedCount}pins`
|
|
513
574
|
: `AUTO ${policy} ${providerCount}P ${pinnedCount}pins`;
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
575
|
+
// TUI plugins (theme, animated logo, routing display, AND the mesh slash-command
|
|
576
|
+
// palette) load ONLY from a `tui` config file — NOT from OPENCODE_CONFIG_CONTENT
|
|
577
|
+
// (that's the main/server config; a TUI plugin there never has its `tui` export
|
|
578
|
+
// invoked, so its /commands never register). Point the TUI at the package's
|
|
579
|
+
// tui.json via OPENCODE_TUI_CONFIG so all three plugins load regardless of cwd.
|
|
580
|
+
// Its plugin paths are relative and resolve next to tui.json (../plugins/*.tsx).
|
|
581
|
+
const tuiConfigFile = join(resolve(__dirname, '..', '..'), '.opencode', 'tui.json');
|
|
582
|
+
if (existsSync(tuiConfigFile) && !process.env.OPENCODE_TUI_CONFIG) {
|
|
583
|
+
process.env.OPENCODE_TUI_CONFIG = tuiConfigFile;
|
|
523
584
|
}
|
|
524
585
|
process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
|
|
525
586
|
$schema: 'https://opencode.ai/config.json',
|
|
@@ -527,7 +588,6 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
|
|
|
527
588
|
model: modelRef,
|
|
528
589
|
small_model: modelRef,
|
|
529
590
|
enabled_providers: Object.keys(providers),
|
|
530
|
-
...(pluginPaths.length > 0 ? { plugin: pluginPaths } : {}),
|
|
531
591
|
agent: {
|
|
532
592
|
build: {
|
|
533
593
|
model: modelRef,
|
package/dist/commands/serve.d.ts
CHANGED