infinicode 2.3.6 → 2.5.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/bin/robopark.js +2 -0
- package/dist/cli.js +26 -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/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/serve.d.ts +5 -0
- package/dist/commands/serve.js +170 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +4 -0
- package/dist/kernel/agents/backends/cli-backend.js +13 -5
- package/dist/kernel/agents/backends/detect.d.ts +12 -0
- package/dist/kernel/agents/backends/detect.js +102 -2
- 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 +2 -1
- package/dist/kernel/agents/backends/registry.js +39 -5
- 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 +3 -1
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +561 -0
- package/dist/kernel/federation/federation.d.ts +75 -1
- package/dist/kernel/federation/federation.js +119 -23
- 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 +34 -1
- package/dist/kernel/federation/transport-http.js +117 -4
- package/dist/kernel/federation/types.d.ts +20 -0
- package/dist/kernel/mcp/mcp-server.js +23 -7
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +7 -3
package/dist/commands/serve.js
CHANGED
|
@@ -11,10 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { dirname, join } from 'node:path';
|
|
16
17
|
import { buildKernelFromConfig, registerCloudProviders } from '../kernel/setup.js';
|
|
17
|
-
import { Federation, MoltfedConnector, loadOrCreateIdentity } from '../kernel/index.js';
|
|
18
|
+
import { Federation, MoltfedConnector, loadOrCreateIdentity, loadRole } from '../kernel/index.js';
|
|
19
|
+
import { runMaintenanceTask, loadLastMaintenanceTask } from './maintain.js';
|
|
20
|
+
/** ws(s):// gateway URL -> http(s):// base, for the plain-HTTP command poll
|
|
21
|
+
* (the WS RPC channel is fire-and-forget, no request/response tracking —
|
|
22
|
+
* simpler to poll a raw route than extend that channel to support it). */
|
|
23
|
+
function httpBaseFromWsUrl(wsUrl) {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(wsUrl);
|
|
26
|
+
u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
27
|
+
return u.origin;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
18
33
|
function pkgVersion() {
|
|
19
34
|
try {
|
|
20
35
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,12 +39,56 @@ function pkgVersion() {
|
|
|
24
39
|
return '0.0.0';
|
|
25
40
|
}
|
|
26
41
|
}
|
|
42
|
+
/** Read-only kernel commands — the only ones an unauthenticated, network-exposed
|
|
43
|
+
* node will run. Control commands require a token (see commandGate below). */
|
|
44
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
45
|
+
'/status', '/nodes', '/activity', '/running', '/agents',
|
|
46
|
+
'/health', '/help', '/who', '/role', '/hardware', '/peers',
|
|
47
|
+
]);
|
|
48
|
+
function isLoopback(host) {
|
|
49
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '::1';
|
|
50
|
+
}
|
|
27
51
|
export async function serve(config, opts) {
|
|
28
52
|
const fed = config.get('federation') ?? {};
|
|
29
53
|
const role = (opts.hub ? 'hub' : opts.role ?? fed.role ?? 'peer');
|
|
30
54
|
const port = opts.port ? parseInt(opts.port, 10) : fed.port ?? 47913;
|
|
31
55
|
const seeds = [...(fed.seeds ?? []), ...(opts.seed ?? [])];
|
|
32
|
-
const
|
|
56
|
+
const bindHost = opts.host ?? fed.host ?? '0.0.0.0';
|
|
57
|
+
let token = opts.token ?? fed.token;
|
|
58
|
+
// Secure-by-default: a control dashboard exposed on a routable interface with
|
|
59
|
+
// no token is a wide-open remote. If the operator asked for the dashboard but
|
|
60
|
+
// set no token, mint a session token so the surface isn't unauthenticated.
|
|
61
|
+
if (opts.dashboard && !token && !isLoopback(bindHost)) {
|
|
62
|
+
token = randomBytes(9).toString('base64url');
|
|
63
|
+
console.log(chalk.yellow(` ⚠ dashboard exposed on ${bindHost} without --token — generated a session token.`));
|
|
64
|
+
console.log(chalk.yellow(` peers must now connect with --token ${token}`));
|
|
65
|
+
}
|
|
66
|
+
// Keep a network-exposed, tokenless node read-only: it will answer status/map
|
|
67
|
+
// queries but refuse control commands until a token is set. Loopback-only or
|
|
68
|
+
// token-protected nodes are trusted and run everything.
|
|
69
|
+
const trusted = Boolean(token) || isLoopback(bindHost);
|
|
70
|
+
const commandGate = (input) => {
|
|
71
|
+
if (trusted)
|
|
72
|
+
return { ok: true };
|
|
73
|
+
const name = input.trim().split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
74
|
+
if (READ_ONLY_COMMANDS.has(name))
|
|
75
|
+
return { ok: true };
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: 'this node is network-exposed without a token — only read-only commands are allowed. Restart with --token <secret> to enable control commands.',
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
// Optional TLS material for an HTTPS dashboard/mesh.
|
|
82
|
+
let tls;
|
|
83
|
+
if (opts.tlsCert && opts.tlsKey) {
|
|
84
|
+
try {
|
|
85
|
+
tls = { cert: readFileSync(opts.tlsCert), key: readFileSync(opts.tlsKey) };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.log(chalk.red(` ✗ could not read TLS cert/key: ${err instanceof Error ? err.message : String(err)}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
33
92
|
const kernel = buildKernelFromConfig(config);
|
|
34
93
|
const identity = loadOrCreateIdentity({ role, displayName: opts.name ?? fed.displayName });
|
|
35
94
|
// Manifest inputs are read live from the kernel so the map shows real caps.
|
|
@@ -61,7 +120,26 @@ export async function serve(config, opts) {
|
|
|
61
120
|
config.set('defaultModel', cfg.defaultModel);
|
|
62
121
|
console.log(chalk.green(` ✓ sourced shared config from hub (rev ${cfg.revision}) — ${live} provider(s) live`));
|
|
63
122
|
};
|
|
64
|
-
|
|
123
|
+
// Hub/peer/relay: publish this machine's providers + policy so satellites can
|
|
124
|
+
// auto-source them. Re-publish when the local config changes (live).
|
|
125
|
+
const sharedFromConf = () => ({
|
|
126
|
+
cloudProviders: config.get('cloudProviders'),
|
|
127
|
+
policy: config.get('policy'),
|
|
128
|
+
defaultModel: config.get('defaultModel'),
|
|
129
|
+
});
|
|
130
|
+
// Dashboard "Apply config to fleet" / POST /fed/apply → re-publish shared
|
|
131
|
+
// config to every connected satellite right now (cross-process nudge: the
|
|
132
|
+
// serve daemon holds the mesh; the CLI can't reach satellites directly).
|
|
133
|
+
let federation;
|
|
134
|
+
const onApply = () => {
|
|
135
|
+
if (role === 'satellite') {
|
|
136
|
+
return { ok: false, text: 'this node is a satellite — it sources config from its hub, it does not publish.' };
|
|
137
|
+
}
|
|
138
|
+
const cfg = federation.publishConfig(sharedFromConf());
|
|
139
|
+
const n = federation.peers().filter(p => p.role === 'satellite').length;
|
|
140
|
+
return { ok: true, text: `published config rev ${cfg.revision} to the fleet (${n} satellite peer${n === 1 ? '' : 's'})` };
|
|
141
|
+
};
|
|
142
|
+
federation = new Federation({
|
|
65
143
|
kernel,
|
|
66
144
|
identity,
|
|
67
145
|
describe,
|
|
@@ -75,17 +153,17 @@ export async function serve(config, opts) {
|
|
|
75
153
|
autoUpdate: opts.autoUpdate ?? fed.autoUpdate,
|
|
76
154
|
applyConfig,
|
|
77
155
|
autoPullConfig: role === 'satellite',
|
|
156
|
+
dashboard: opts.dashboard,
|
|
157
|
+
onApply,
|
|
158
|
+
commandGate,
|
|
159
|
+
tls,
|
|
160
|
+
schedulerUrl: opts.schedulerUrl ?? fed.schedulerUrl,
|
|
161
|
+
schedulerToken: opts.schedulerToken,
|
|
162
|
+
schedulerReadOnly: !trusted,
|
|
78
163
|
},
|
|
79
164
|
});
|
|
80
165
|
// Let the kernel's slash-command surface reach the mesh (/nodes, /node, …).
|
|
81
166
|
kernel.attachFederation(federation);
|
|
82
|
-
// Hub/peer/relay: publish this machine's providers + policy so satellites can
|
|
83
|
-
// auto-source them. Re-publish when the local config changes (live).
|
|
84
|
-
const sharedFromConf = () => ({
|
|
85
|
-
cloudProviders: config.get('cloudProviders'),
|
|
86
|
-
policy: config.get('policy'),
|
|
87
|
-
defaultModel: config.get('defaultModel'),
|
|
88
|
-
});
|
|
89
167
|
if (role !== 'satellite') {
|
|
90
168
|
federation.publishConfig(sharedFromConf());
|
|
91
169
|
for (const key of ['cloudProviders', 'policy', 'defaultModel']) {
|
|
@@ -101,6 +179,18 @@ export async function serve(config, opts) {
|
|
|
101
179
|
console.log(` auth: ${chalk.green('token required')}`);
|
|
102
180
|
console.log();
|
|
103
181
|
await federation.start();
|
|
182
|
+
// Control dashboard link — prefer a reachable host over the 0.0.0.0 wildcard.
|
|
183
|
+
if (opts.dashboard) {
|
|
184
|
+
const uiHost = isLoopback(bindHost) || bindHost === '0.0.0.0' ? 'localhost' : bindHost;
|
|
185
|
+
const scheme = tls ? 'https' : 'http';
|
|
186
|
+
const url = `${scheme}://${uiHost}:${port}/${token ? `?token=${token}` : ''}`;
|
|
187
|
+
console.log(chalk.bold(` dashboard: ${chalk.cyan(url)}`));
|
|
188
|
+
if (opts.schedulerUrl)
|
|
189
|
+
console.log(chalk.dim(` scheduler linked: ${opts.schedulerUrl} (sessions + telemetry tabs live)`));
|
|
190
|
+
if (!token)
|
|
191
|
+
console.log(chalk.dim(' (no token — anyone who can reach this port can control the fleet; add --token to lock it)'));
|
|
192
|
+
console.log();
|
|
193
|
+
}
|
|
104
194
|
// Connect to seeds. Satellites auto-source shared config from any hub/relay
|
|
105
195
|
// they connect to (handled inside federation.connect()).
|
|
106
196
|
for (const url of seeds) {
|
|
@@ -119,7 +209,13 @@ export async function serve(config, opts) {
|
|
|
119
209
|
console.log(chalk.green(` ✓ LAN auto-discovery on (udp broadcast) — peers on this subnet connect automatically`));
|
|
120
210
|
}
|
|
121
211
|
// Optional: log this node into an InfiniBot gateway so it renders live on the
|
|
122
|
-
//
|
|
212
|
+
// fleet map + hardware panels via the infinibot-plugin-infinicode plugin.
|
|
213
|
+
// NOTE: these method names target that plugin's registered gateway methods,
|
|
214
|
+
// NOT InfiniBot's core nodes.status/hardware.snapshot/agent.event — those
|
|
215
|
+
// are claimed for InfiniBot's own paired-device list and internal satellite
|
|
216
|
+
// protocol respectively, and agent.event doesn't exist on the core gateway
|
|
217
|
+
// at all. Without the plugin installed on the target gateway, these pushes
|
|
218
|
+
// are harmless no-ops (unknown method).
|
|
123
219
|
let connector;
|
|
124
220
|
if (opts.gateway) {
|
|
125
221
|
connector = new MoltfedConnector({
|
|
@@ -131,12 +227,75 @@ export async function serve(config, opts) {
|
|
|
131
227
|
getNodeStatuses: () => federation.nodeStatuses(),
|
|
132
228
|
getHardware: () => federation.latestHardware(),
|
|
133
229
|
subscribeFrames: (cb) => federation.onFrames(cb),
|
|
230
|
+
methodNodes: 'infinicode.push.status',
|
|
231
|
+
methodHardware: 'infinicode.push.hardware',
|
|
232
|
+
methodAgent: 'infinicode.push.event',
|
|
134
233
|
});
|
|
135
234
|
connector.start();
|
|
136
235
|
console.log(chalk.green(` ✓ linking to InfiniBot gateway ${opts.gateway}`));
|
|
137
236
|
}
|
|
237
|
+
// Optional: poll for on-demand commands queued from the InfiniBot Fleet
|
|
238
|
+
// view (e.g. "run maintenance now"). Plain HTTP GET against the plugin's
|
|
239
|
+
// own route — separate from the WS push channel above, which has no
|
|
240
|
+
// request/response tracking. No-op (empty list) until the operator
|
|
241
|
+
// actually queues something, so this is cheap to leave running.
|
|
242
|
+
let commandPoll;
|
|
243
|
+
const httpBase = opts.gateway ? httpBaseFromWsUrl(opts.gateway) : null;
|
|
244
|
+
if (httpBase) {
|
|
245
|
+
const pollUrl = `${httpBase}/plugins/infinicode/commands?nodeId=${encodeURIComponent(identity.nodeId)}`;
|
|
246
|
+
commandPoll = setInterval(() => {
|
|
247
|
+
void (async () => {
|
|
248
|
+
let commands;
|
|
249
|
+
try {
|
|
250
|
+
const res = await fetch(pollUrl, { signal: AbortSignal.timeout(5000) });
|
|
251
|
+
if (!res.ok)
|
|
252
|
+
return;
|
|
253
|
+
const body = await res.json();
|
|
254
|
+
commands = body.commands ?? [];
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return; // gateway/plugin unreachable this cycle — try again next tick
|
|
258
|
+
}
|
|
259
|
+
if (!commands.length)
|
|
260
|
+
return;
|
|
261
|
+
// The trigger carries no task content by design — replay ONLY the
|
|
262
|
+
// task this device already ran locally (cron/CLI), never anything
|
|
263
|
+
// network-supplied. See loadLastMaintenanceTask.
|
|
264
|
+
const cached = loadLastMaintenanceTask();
|
|
265
|
+
for (const cmd of commands) {
|
|
266
|
+
if (cmd.kind !== 'run-last-maintenance')
|
|
267
|
+
continue;
|
|
268
|
+
if (!cached) {
|
|
269
|
+
console.log(chalk.yellow(' ⚠ "run now" received but no maintenance task has ever run on this device — run `infinicode maintain "..."` once first'));
|
|
270
|
+
connector?.request('infinicode.push.report', {
|
|
271
|
+
nodeId: identity.nodeId,
|
|
272
|
+
role: loadRole()?.role ?? identity.nodeId,
|
|
273
|
+
site: loadRole()?.site,
|
|
274
|
+
taskName: 'run-now (no task configured)',
|
|
275
|
+
startedAt: Date.now(),
|
|
276
|
+
finishedAt: Date.now(),
|
|
277
|
+
durationMs: 0,
|
|
278
|
+
ok: false,
|
|
279
|
+
summary: 'no maintenance task has run on this device yet — run `infinicode maintain "..."` once to establish one',
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
console.log(chalk.cyan(` ▸ re-running last maintenance task: ${cached.taskName ?? cached.taskPrompt.slice(0, 60)}`));
|
|
284
|
+
const report = await runMaintenanceTask(kernel, cached.taskPrompt, {
|
|
285
|
+
nodeId: identity.nodeId,
|
|
286
|
+
role: loadRole(),
|
|
287
|
+
taskName: cached.taskName,
|
|
288
|
+
});
|
|
289
|
+
connector?.request('infinicode.push.report', report);
|
|
290
|
+
console.log(report.ok ? chalk.green(' ✓ done') : chalk.red(' ✗ failed'));
|
|
291
|
+
}
|
|
292
|
+
})();
|
|
293
|
+
}, 10_000);
|
|
294
|
+
}
|
|
138
295
|
const shutdown = async () => {
|
|
139
296
|
console.log(chalk.dim('\n stopping mesh…'));
|
|
297
|
+
if (commandPoll)
|
|
298
|
+
clearInterval(commandPoll);
|
|
140
299
|
connector?.stop();
|
|
141
300
|
await federation.stop();
|
|
142
301
|
kernel.destroy();
|
|
@@ -29,6 +29,10 @@ export declare class CliAgentBackend implements AgentBackend {
|
|
|
29
29
|
readonly displayName: string;
|
|
30
30
|
constructor(spec: CliBackendSpec);
|
|
31
31
|
private resolveBin;
|
|
32
|
+
/** A directly-spawnable target — on Windows unwraps the npm shim to the real
|
|
33
|
+
* executable so `spawn` (array args, no shell) works and multi-line prompts
|
|
34
|
+
* survive intact. */
|
|
35
|
+
private resolveSpawn;
|
|
32
36
|
available(): boolean;
|
|
33
37
|
private extraArgs;
|
|
34
38
|
run(task: BackendTask, ctx: BackendRunContext): Promise<BackendRunResult>;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* everything else — spawning, streaming, cancellation — is shared here.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
|
-
import { findExecutable } from './detect.js';
|
|
13
|
+
import { findExecutable, resolveSpawnTarget } from './detect.js';
|
|
14
14
|
export class CliAgentBackend {
|
|
15
15
|
spec;
|
|
16
16
|
id;
|
|
@@ -26,6 +26,13 @@ export class CliAgentBackend {
|
|
|
26
26
|
return override;
|
|
27
27
|
return findExecutable(this.spec.bin);
|
|
28
28
|
}
|
|
29
|
+
/** A directly-spawnable target — on Windows unwraps the npm shim to the real
|
|
30
|
+
* executable so `spawn` (array args, no shell) works and multi-line prompts
|
|
31
|
+
* survive intact. */
|
|
32
|
+
resolveSpawn() {
|
|
33
|
+
const override = this.spec.binEnv ? process.env[this.spec.binEnv] : undefined;
|
|
34
|
+
return resolveSpawnTarget(this.spec.bin, override);
|
|
35
|
+
}
|
|
29
36
|
available() {
|
|
30
37
|
return this.resolveBin() !== null;
|
|
31
38
|
}
|
|
@@ -38,7 +45,7 @@ export class CliAgentBackend {
|
|
|
38
45
|
const argv = task.sessionId && this.spec.resumeArgs
|
|
39
46
|
? this.spec.resumeArgs(task.sessionId, prompt, task)
|
|
40
47
|
: this.spec.runArgs(prompt, task);
|
|
41
|
-
return this.exec([...argv, ...this.extraArgs()], ctx);
|
|
48
|
+
return this.exec([...argv, ...(task.args ?? []), ...this.extraArgs()], ctx);
|
|
42
49
|
}
|
|
43
50
|
async send(sessionId, message, ctx) {
|
|
44
51
|
const msg = ctx.rolePreamble ? `${ctx.rolePreamble}\n\n${message}` : message;
|
|
@@ -50,15 +57,16 @@ export class CliAgentBackend {
|
|
|
50
57
|
}
|
|
51
58
|
exec(argv, ctx) {
|
|
52
59
|
return new Promise(resolve => {
|
|
53
|
-
const
|
|
54
|
-
if (!
|
|
60
|
+
const target = this.resolveSpawn();
|
|
61
|
+
if (!target) {
|
|
55
62
|
resolve({ status: 'failed', error: `${this.spec.bin} not found on PATH` });
|
|
56
63
|
return;
|
|
57
64
|
}
|
|
58
|
-
const child = spawn(
|
|
65
|
+
const child = spawn(target.command, [...target.prefixArgs, ...argv], {
|
|
59
66
|
cwd: ctx.cwd ?? process.cwd(),
|
|
60
67
|
env: { ...process.env, ...this.spec.env },
|
|
61
68
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
69
|
+
windowsHide: true,
|
|
62
70
|
});
|
|
63
71
|
const acc = {};
|
|
64
72
|
let stdoutBuf = '';
|
|
@@ -3,3 +3,15 @@ export declare function findExecutable(bin: string): string | null;
|
|
|
3
3
|
export declare function hasExecutable(bin: string): boolean;
|
|
4
4
|
/** Test hook — forget cached lookups. */
|
|
5
5
|
export declare function clearExecutableCache(): void;
|
|
6
|
+
/** A directly-spawnable target: a command plus fixed leading args (e.g. node + a .js). */
|
|
7
|
+
export interface SpawnTarget {
|
|
8
|
+
command: string;
|
|
9
|
+
prefixArgs: string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolve `bin` (or an explicit `override` path) to a directly-spawnable target.
|
|
13
|
+
* Non-Windows and real `.exe`/`.com` hits pass through untouched; Windows npm
|
|
14
|
+
* shims are unwrapped to the executable they delegate to. Returns null when
|
|
15
|
+
* nothing is found.
|
|
16
|
+
*/
|
|
17
|
+
export declare function resolveSpawnTarget(bin: string, override?: string): SpawnTarget | null;
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* can advertise availability without spawning `--version`. Results are cached —
|
|
6
6
|
* a device's installed CLIs don't change mid-process.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync } from 'node:fs';
|
|
9
|
-
import { join, delimiter } from 'node:path';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join, delimiter, dirname } from 'node:path';
|
|
10
10
|
const cache = new Map();
|
|
11
11
|
/** Absolute path to `bin` on PATH, or null if not found. */
|
|
12
12
|
export function findExecutable(bin) {
|
|
@@ -38,3 +38,103 @@ export function hasExecutable(bin) {
|
|
|
38
38
|
export function clearExecutableCache() {
|
|
39
39
|
cache.clear();
|
|
40
40
|
}
|
|
41
|
+
function substVars(token, dir) {
|
|
42
|
+
// sh launcher uses $basedir; cmd shim uses %dp0% / %~dp0%. Both = shim dir.
|
|
43
|
+
let t = token
|
|
44
|
+
.replace(/\$\{basedir\}|\$basedir/g, dir)
|
|
45
|
+
.replace(/%~?dp0%\\?|%dp0%\\?/gi, dir + '\\');
|
|
46
|
+
if (process.platform === 'win32')
|
|
47
|
+
t = t.replace(/\//g, '\\').replace(/\\{2,}/g, '\\');
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
function tokenizeShellish(s) {
|
|
51
|
+
const out = [];
|
|
52
|
+
let cur = '';
|
|
53
|
+
let quoted = false;
|
|
54
|
+
let has = false;
|
|
55
|
+
for (const ch of s) {
|
|
56
|
+
if (ch === '"') {
|
|
57
|
+
quoted = !quoted;
|
|
58
|
+
has = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!quoted && /\s/.test(ch)) {
|
|
62
|
+
if (has) {
|
|
63
|
+
out.push(cur);
|
|
64
|
+
cur = '';
|
|
65
|
+
has = false;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
cur += ch;
|
|
70
|
+
has = true;
|
|
71
|
+
}
|
|
72
|
+
if (has)
|
|
73
|
+
out.push(cur);
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
function mapCommand(token) {
|
|
77
|
+
// A bare `node` / `node.exe` invocation → the running interpreter, which is
|
|
78
|
+
// guaranteed present and avoids a second PATH lookup.
|
|
79
|
+
const base = token.replace(/\\/g, '/').split('/').pop()?.toLowerCase();
|
|
80
|
+
if (base === 'node' || base === 'node.exe')
|
|
81
|
+
return process.execPath;
|
|
82
|
+
return token;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read an npm shim and extract the real executable it invokes.
|
|
86
|
+
* Handles the `#!/bin/sh` launcher (`exec "<prog>" [args] "$@"`) and the simple
|
|
87
|
+
* `.cmd` form (`"<prog>" [args] %*`). Returns a SpawnTarget or null.
|
|
88
|
+
*/
|
|
89
|
+
function unwrapNpmShim(shimPath) {
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = readFileSync(shimPath, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const dir = dirname(shimPath);
|
|
98
|
+
// sh launcher: the operative line is `exec <prog> [fixed args] "$@"`.
|
|
99
|
+
const sh = text.match(/^\s*exec\s+(.*?)\s+"\$@"\s*$/m);
|
|
100
|
+
if (sh) {
|
|
101
|
+
const toks = tokenizeShellish(sh[1]).map(t => substVars(t, dir)).filter(Boolean);
|
|
102
|
+
if (toks.length) {
|
|
103
|
+
const [cmd, ...rest] = toks;
|
|
104
|
+
return { command: mapCommand(cmd), prefixArgs: rest };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// .cmd shim: the last line ending in `%*` carries the quoted target + args.
|
|
108
|
+
for (const line of text.split(/\r?\n/).reverse()) {
|
|
109
|
+
if (!/%\*/.test(line))
|
|
110
|
+
continue;
|
|
111
|
+
const quoted = [...line.matchAll(/"([^"]*)"/g)]
|
|
112
|
+
.map(m => substVars(m[1], dir))
|
|
113
|
+
.filter(t => t && !t.includes('%'));
|
|
114
|
+
if (quoted.length) {
|
|
115
|
+
const [cmd, ...rest] = quoted;
|
|
116
|
+
return { command: mapCommand(cmd), prefixArgs: rest };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Resolve `bin` (or an explicit `override` path) to a directly-spawnable target.
|
|
123
|
+
* Non-Windows and real `.exe`/`.com` hits pass through untouched; Windows npm
|
|
124
|
+
* shims are unwrapped to the executable they delegate to. Returns null when
|
|
125
|
+
* nothing is found.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveSpawnTarget(bin, override) {
|
|
128
|
+
const hit = override || findExecutable(bin);
|
|
129
|
+
if (!hit)
|
|
130
|
+
return null;
|
|
131
|
+
if (process.platform !== 'win32')
|
|
132
|
+
return { command: hit, prefixArgs: [] };
|
|
133
|
+
const lower = hit.toLowerCase();
|
|
134
|
+
if (lower.endsWith('.exe') || lower.endsWith('.com'))
|
|
135
|
+
return { command: hit, prefixArgs: [] };
|
|
136
|
+
const unwrapped = unwrapNpmShim(hit);
|
|
137
|
+
if (unwrapped && existsSync(unwrapped.command))
|
|
138
|
+
return unwrapped;
|
|
139
|
+
return { command: hit, prefixArgs: [] };
|
|
140
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenKernel — Coder profiles
|
|
3
|
+
*
|
|
4
|
+
* A *profile* is a named, reusable coder configuration: which backend CLI to
|
|
5
|
+
* run (claude / codex / opencode / gemini), which model, and any extra args/env.
|
|
6
|
+
* Profiles are what the rotation engine (see `rotation.ts`) chooses between, so
|
|
7
|
+
* the orchestrator can say "run this on the `deep` profile" or "rotate across
|
|
8
|
+
* the cheap tier" without hard-coding a backend + model at every call site.
|
|
9
|
+
*
|
|
10
|
+
* Profiles carry lightweight cost/speed/quality metadata so a policy tier
|
|
11
|
+
* (free-first / fastest / highest-quality / …) can order them, plus `tags` a
|
|
12
|
+
* task-type router can match against.
|
|
13
|
+
*
|
|
14
|
+
* Config lives under `coder` in the node config; anything omitted falls back to
|
|
15
|
+
* the built-in defaults below so the mesh works out of the box.
|
|
16
|
+
*/
|
|
17
|
+
import type { BackendId } from './types.js';
|
|
18
|
+
/** A named coder configuration the rotation engine can select. */
|
|
19
|
+
export interface CoderProfile {
|
|
20
|
+
/** Stable name, e.g. "deep", "cheap". Filled in by the loader from the map key. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Which CLI backend runs it. */
|
|
23
|
+
backend: BackendId;
|
|
24
|
+
/** Model id in that backend's native format (e.g. "opus", "gpt-5-codex"). */
|
|
25
|
+
model?: string;
|
|
26
|
+
/** Extra CLI args appended for this profile (via the backend's ARGS env). */
|
|
27
|
+
args?: string[];
|
|
28
|
+
/** Extra process env for this profile. */
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
/** Relative $ per task — lower is cheaper. Used by cost policies. */
|
|
31
|
+
cost?: number;
|
|
32
|
+
/** Relative throughput — higher is faster. Used by the `fastest` policy. */
|
|
33
|
+
speed?: number;
|
|
34
|
+
/** Relative capability — higher is stronger. Used by `highest-quality`. */
|
|
35
|
+
quality?: number;
|
|
36
|
+
/** Task-type tags this profile is a good fit for (matched by the router). */
|
|
37
|
+
tags?: string[];
|
|
38
|
+
}
|
|
39
|
+
export type RotationStrategy = 'auto' | 'tasktype' | 'policy' | 'roundrobin' | 'fallback';
|
|
40
|
+
export type CoderPolicy = 'free-first' | 'cheapest' | 'fastest' | 'highest-quality' | 'balanced';
|
|
41
|
+
/** The `coder` config block. Everything is optional; defaults fill the gaps. */
|
|
42
|
+
export interface CoderConfig {
|
|
43
|
+
profiles: Record<string, CoderProfile>;
|
|
44
|
+
/** Default ordered profile names used by roundrobin/fallback. */
|
|
45
|
+
rotation: string[];
|
|
46
|
+
/** Default strategy when a caller doesn't specify one. */
|
|
47
|
+
strategy: RotationStrategy;
|
|
48
|
+
/** Default policy when strategy resolves to policy ordering. */
|
|
49
|
+
policy: CoderPolicy;
|
|
50
|
+
/** Task-type → ordered profile names (task-type routing). */
|
|
51
|
+
routes: Record<string, string[]>;
|
|
52
|
+
/** Fallback profile when nothing else matches. */
|
|
53
|
+
defaultProfile: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Built-in profiles — one per backend plus a couple of tiers. Models are the
|
|
57
|
+
* common ids; override in config for your accounts. `cost`/`speed`/`quality`
|
|
58
|
+
* are relative (1–10) and only used to order policy tiers.
|
|
59
|
+
*/
|
|
60
|
+
export declare const DEFAULT_PROFILES: Record<string, CoderProfile>;
|
|
61
|
+
/**
|
|
62
|
+
* Merge a raw `coder` config object (from node config) over the built-in
|
|
63
|
+
* defaults, filling `name` from the map key. Safe to call with `undefined`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function loadCoderConfig(raw?: Partial<CoderConfig> & {
|
|
66
|
+
profiles?: Record<string, Partial<CoderProfile>>;
|
|
67
|
+
}): CoderConfig;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in profiles — one per backend plus a couple of tiers. Models are the
|
|
3
|
+
* common ids; override in config for your accounts. `cost`/`speed`/`quality`
|
|
4
|
+
* are relative (1–10) and only used to order policy tiers.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_PROFILES = {
|
|
7
|
+
deep: { name: 'deep', backend: 'claude', model: 'opus', cost: 9, speed: 4, quality: 10, tags: ['refactor', 'architecture', 'debug', 'hard'] },
|
|
8
|
+
fast: { name: 'fast', backend: 'claude', model: 'sonnet', cost: 5, speed: 7, quality: 8, tags: ['edit', 'feature', 'test'] },
|
|
9
|
+
precise: { name: 'precise', backend: 'codex', model: 'gpt-5-codex', cost: 6, speed: 6, quality: 8, tags: ['precision', 'polyglot', 'fix'] },
|
|
10
|
+
cheap: { name: 'cheap', backend: 'gemini', model: 'gemini-2.5-flash', cost: 1, speed: 9, quality: 6, tags: ['bulk', 'docs', 'boilerplate', 'cheap'] },
|
|
11
|
+
oss: { name: 'oss', backend: 'opencode', cost: 2, speed: 6, quality: 6, tags: ['fallback', 'oss'] },
|
|
12
|
+
};
|
|
13
|
+
/** Default rotation across all four backends (balances cost + capability). */
|
|
14
|
+
const DEFAULT_ROTATION = ['fast', 'precise', 'cheap', 'oss', 'deep'];
|
|
15
|
+
/** Sensible task-type routes → ordered profiles (head is primary, rest fallback). */
|
|
16
|
+
const DEFAULT_ROUTES = {
|
|
17
|
+
refactor: ['deep', 'precise', 'fast'],
|
|
18
|
+
architecture: ['deep', 'fast'],
|
|
19
|
+
debug: ['deep', 'precise', 'fast'],
|
|
20
|
+
feature: ['fast', 'precise', 'deep'],
|
|
21
|
+
edit: ['fast', 'cheap', 'precise'],
|
|
22
|
+
test: ['fast', 'precise'],
|
|
23
|
+
docs: ['cheap', 'fast'],
|
|
24
|
+
bulk: ['cheap', 'oss', 'fast'],
|
|
25
|
+
boilerplate: ['cheap', 'oss'],
|
|
26
|
+
fix: ['precise', 'fast', 'deep'],
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Merge a raw `coder` config object (from node config) over the built-in
|
|
30
|
+
* defaults, filling `name` from the map key. Safe to call with `undefined`.
|
|
31
|
+
*/
|
|
32
|
+
export function loadCoderConfig(raw) {
|
|
33
|
+
const profiles = {};
|
|
34
|
+
const merged = { ...DEFAULT_PROFILES, ...(raw?.profiles ?? {}) };
|
|
35
|
+
for (const [key, p] of Object.entries(merged)) {
|
|
36
|
+
if (!p?.backend)
|
|
37
|
+
continue; // a profile must name a backend
|
|
38
|
+
profiles[key] = { ...(DEFAULT_PROFILES[key] ?? {}), ...p, name: key, backend: p.backend };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
profiles,
|
|
42
|
+
rotation: (raw?.rotation ?? DEFAULT_ROTATION).filter(n => profiles[n]),
|
|
43
|
+
strategy: raw?.strategy ?? 'auto',
|
|
44
|
+
policy: raw?.policy ?? 'balanced',
|
|
45
|
+
routes: { ...DEFAULT_ROUTES, ...(raw?.routes ?? {}) },
|
|
46
|
+
defaultProfile: raw?.defaultProfile && profiles[raw.defaultProfile] ? raw.defaultProfile : (profiles['fast'] ? 'fast' : Object.keys(profiles)[0]),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -15,6 +15,7 @@ import { type CliBackendSpec } from './cli-backend.js';
|
|
|
15
15
|
declare const CLAUDE_SPEC: CliBackendSpec;
|
|
16
16
|
declare const OPENCODE_SPEC: CliBackendSpec;
|
|
17
17
|
declare const CODEX_SPEC: CliBackendSpec;
|
|
18
|
+
declare const GEMINI_SPEC: CliBackendSpec;
|
|
18
19
|
export declare class AgentBackendRegistry {
|
|
19
20
|
private map;
|
|
20
21
|
register(backend: AgentBackend): this;
|
|
@@ -27,4 +28,4 @@ export declare class AgentBackendRegistry {
|
|
|
27
28
|
}
|
|
28
29
|
/** Registry seeded with the built-in CLI backends (availability checked lazily). */
|
|
29
30
|
export declare function createDefaultBackendRegistry(): AgentBackendRegistry;
|
|
30
|
-
export { CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC };
|
|
31
|
+
export { CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC, GEMINI_SPEC };
|
|
@@ -9,15 +9,17 @@ const CLAUDE_SPEC = {
|
|
|
9
9
|
bin: 'claude',
|
|
10
10
|
binEnv: 'INFINICODE_CLAUDE_BIN',
|
|
11
11
|
extraArgsEnv: 'INFINICODE_CLAUDE_ARGS',
|
|
12
|
-
runArgs: prompt => [
|
|
12
|
+
runArgs: (prompt, task) => [
|
|
13
13
|
'-p', prompt,
|
|
14
|
+
...(task.model ? ['--model', task.model] : []),
|
|
14
15
|
'--output-format', 'stream-json',
|
|
15
16
|
'--verbose',
|
|
16
17
|
'--dangerously-skip-permissions',
|
|
17
18
|
],
|
|
18
|
-
resumeArgs: (sessionId, message) => [
|
|
19
|
+
resumeArgs: (sessionId, message, task) => [
|
|
19
20
|
'--resume', sessionId,
|
|
20
21
|
'-p', message,
|
|
22
|
+
...(task.model ? ['--model', task.model] : []),
|
|
21
23
|
'--output-format', 'stream-json',
|
|
22
24
|
'--verbose',
|
|
23
25
|
'--dangerously-skip-permissions',
|
|
@@ -84,7 +86,7 @@ const CODEX_SPEC = {
|
|
|
84
86
|
bin: 'codex',
|
|
85
87
|
binEnv: 'INFINICODE_CODEX_BIN',
|
|
86
88
|
extraArgsEnv: 'INFINICODE_CODEX_ARGS',
|
|
87
|
-
runArgs: prompt => ['exec', prompt],
|
|
89
|
+
runArgs: (prompt, task) => ['exec', ...(task.model ? ['--model', task.model] : []), prompt],
|
|
88
90
|
resumeArgs: (sessionId, message) => ['exec', 'resume', sessionId, message],
|
|
89
91
|
parseLine: (line, acc) => {
|
|
90
92
|
const obj = tryJson(line);
|
|
@@ -105,6 +107,37 @@ const CODEX_SPEC = {
|
|
|
105
107
|
acc.content = acc.content ? `${acc.content}\n${line}` : line;
|
|
106
108
|
},
|
|
107
109
|
};
|
|
110
|
+
// ── Gemini ───────────────────────────────────────────────────────────────────
|
|
111
|
+
// `gemini -p "<prompt>"` = non-interactive. `-y` (yolo) auto-approves tool
|
|
112
|
+
// actions. Output is plain text by default, so parse defensively: JSON when a
|
|
113
|
+
// tool version emits it, otherwise accumulate the printed answer.
|
|
114
|
+
const GEMINI_SPEC = {
|
|
115
|
+
id: 'gemini',
|
|
116
|
+
displayName: 'Gemini CLI',
|
|
117
|
+
bin: 'gemini',
|
|
118
|
+
binEnv: 'INFINICODE_GEMINI_BIN',
|
|
119
|
+
extraArgsEnv: 'INFINICODE_GEMINI_ARGS',
|
|
120
|
+
runArgs: (prompt, task) => [
|
|
121
|
+
...(task.model ? ['-m', task.model] : []),
|
|
122
|
+
'-y',
|
|
123
|
+
'-p', prompt,
|
|
124
|
+
],
|
|
125
|
+
parseLine: (line, acc) => {
|
|
126
|
+
const obj = tryJson(line);
|
|
127
|
+
if (obj) {
|
|
128
|
+
const sid = extractSessionId(obj);
|
|
129
|
+
if (sid)
|
|
130
|
+
acc.sessionId ??= sid;
|
|
131
|
+
const t = extractText(obj);
|
|
132
|
+
if (t)
|
|
133
|
+
acc.content = t;
|
|
134
|
+
if (obj.error || obj.is_error === true)
|
|
135
|
+
acc.isError = true;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
acc.content = acc.content ? `${acc.content}\n${line}` : line;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
108
141
|
export class AgentBackendRegistry {
|
|
109
142
|
map = new Map();
|
|
110
143
|
register(backend) {
|
|
@@ -135,6 +168,7 @@ export function createDefaultBackendRegistry() {
|
|
|
135
168
|
return new AgentBackendRegistry()
|
|
136
169
|
.register(new CliAgentBackend(CLAUDE_SPEC))
|
|
137
170
|
.register(new CliAgentBackend(OPENCODE_SPEC))
|
|
138
|
-
.register(new CliAgentBackend(CODEX_SPEC))
|
|
171
|
+
.register(new CliAgentBackend(CODEX_SPEC))
|
|
172
|
+
.register(new CliAgentBackend(GEMINI_SPEC));
|
|
139
173
|
}
|
|
140
|
-
export { CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC };
|
|
174
|
+
export { CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC, GEMINI_SPEC };
|