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.
Files changed (34) hide show
  1. package/bin/robopark.js +2 -0
  2. package/dist/cli.js +26 -1
  3. package/dist/commands/maintain.d.ts +29 -0
  4. package/dist/commands/maintain.js +186 -0
  5. package/dist/commands/mcp.js +1 -1
  6. package/dist/commands/robopark.d.ts +6 -0
  7. package/dist/commands/robopark.js +450 -0
  8. package/dist/commands/serve.d.ts +5 -0
  9. package/dist/commands/serve.js +170 -11
  10. package/dist/kernel/agents/backends/cli-backend.d.ts +4 -0
  11. package/dist/kernel/agents/backends/cli-backend.js +13 -5
  12. package/dist/kernel/agents/backends/detect.d.ts +12 -0
  13. package/dist/kernel/agents/backends/detect.js +102 -2
  14. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  15. package/dist/kernel/agents/backends/profiles.js +48 -0
  16. package/dist/kernel/agents/backends/registry.d.ts +2 -1
  17. package/dist/kernel/agents/backends/registry.js +39 -5
  18. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  19. package/dist/kernel/agents/backends/rotation.js +107 -0
  20. package/dist/kernel/agents/backends/types.d.ts +3 -1
  21. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  22. package/dist/kernel/federation/dashboard-html.js +561 -0
  23. package/dist/kernel/federation/federation.d.ts +75 -1
  24. package/dist/kernel/federation/federation.js +119 -23
  25. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  26. package/dist/kernel/federation/telemetry.d.ts +1 -1
  27. package/dist/kernel/federation/telemetry.js +2 -1
  28. package/dist/kernel/federation/transport-http.d.ts +34 -1
  29. package/dist/kernel/federation/transport-http.js +117 -4
  30. package/dist/kernel/federation/types.d.ts +20 -0
  31. package/dist/kernel/mcp/mcp-server.js +23 -7
  32. package/dist/robopark-cli.d.ts +2 -0
  33. package/dist/robopark-cli.js +23 -0
  34. package/package.json +7 -3
@@ -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 token = opts.token ?? fed.token;
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
- const federation = new Federation({
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
- // neural-mesh map + hardware panels (no InfiniBot changes needed).
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 bin = this.resolveBin();
54
- if (!bin) {
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(bin, argv, {
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 };