infinicode 2.3.5 → 2.3.6

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 (43) hide show
  1. package/.opencode/plugins/home-logo-animation.tsx +5 -1
  2. package/.opencode/plugins/mesh-commands.tsx +4 -3
  3. package/.opencode/plugins/routing-mode-display.tsx +6 -2
  4. package/.opencode/tui.json +2 -1
  5. package/dist/cli.js +25 -1
  6. package/dist/commands/mesh-install.d.ts +10 -0
  7. package/dist/commands/mesh-install.js +160 -0
  8. package/dist/commands/run.js +82 -22
  9. package/dist/kernel/agents/backends/cli-backend.d.ts +37 -0
  10. package/dist/kernel/agents/backends/cli-backend.js +132 -0
  11. package/dist/kernel/agents/backends/detect.d.ts +5 -0
  12. package/dist/kernel/agents/backends/detect.js +40 -0
  13. package/dist/kernel/agents/backends/index.d.ts +11 -0
  14. package/dist/kernel/agents/backends/index.js +3 -0
  15. package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
  16. package/dist/kernel/agents/backends/parse-utils.js +72 -0
  17. package/dist/kernel/agents/backends/registry.d.ts +30 -0
  18. package/dist/kernel/agents/backends/registry.js +140 -0
  19. package/dist/kernel/agents/backends/types.d.ts +65 -0
  20. package/dist/kernel/agents/backends/types.js +1 -0
  21. package/dist/kernel/agents/index.d.ts +8 -0
  22. package/dist/kernel/agents/index.js +8 -0
  23. package/dist/kernel/federation/federation.d.ts +54 -1
  24. package/dist/kernel/federation/federation.js +180 -8
  25. package/dist/kernel/federation/types.d.ts +26 -1
  26. package/dist/kernel/free-providers.js +83 -0
  27. package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
  28. package/dist/kernel/gateway/openai-gateway.js +428 -49
  29. package/dist/kernel/index.d.ts +2 -1
  30. package/dist/kernel/index.js +3 -1
  31. package/dist/kernel/logger.d.ts +16 -3
  32. package/dist/kernel/logger.js +38 -0
  33. package/dist/kernel/mcp/mcp-server.js +47 -8
  34. package/dist/kernel/orchestrator.d.ts +4 -0
  35. package/dist/kernel/orchestrator.js +34 -0
  36. package/dist/kernel/provider-manager.d.ts +13 -0
  37. package/dist/kernel/provider-manager.js +58 -5
  38. package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
  39. package/dist/kernel/providers/openai-compatible-provider.js +22 -3
  40. package/dist/kernel/recovery-manager.js +55 -15
  41. package/dist/kernel/router.js +15 -2
  42. package/dist/kernel/types.d.ts +12 -2
  43. package/package.json +1 -1
@@ -109,7 +109,7 @@ function AnimatedLogo(props: { renderer?: any }) {
109
109
  );
110
110
  }
111
111
 
112
- export const tui = async (api: any) => {
112
+ const tui = async (api: any) => {
113
113
  api.slots.register({
114
114
  slots: {
115
115
  home_logo() {
@@ -118,3 +118,7 @@ export const tui = async (api: any) => {
118
118
  },
119
119
  });
120
120
  };
121
+
122
+ // The TUI plugin loader reads `mod.default` (strict mode) and requires an object
123
+ // with a tui() function — a bare `export const tui` is silently skipped.
124
+ export default { id: "infinicode-home-logo", tui };
@@ -16,7 +16,6 @@
16
16
  * /follow, /dispatch, /goal, /mission, /role) open a prompt first.
17
17
  */
18
18
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
19
- import { createBindingLookup } from "@opentui/keymap/extras"
20
19
 
21
20
  type CommandResult = { ok?: boolean; text?: string; data?: unknown }
22
21
 
@@ -112,8 +111,10 @@ const SPECS: Spec[] = [
112
111
 
113
112
  const tui: TuiPlugin = async (api, options) => {
114
113
  if (options?.enabled === false) return
115
- const keys = createBindingLookup({})
116
114
 
115
+ // No keybindings — these are palette-only slash commands. Empty bindings avoids
116
+ // any dependency on the keymap-extras runtime module (which may not resolve for
117
+ // an externally-loaded plugin) so registration can never fail to load.
117
118
  api.keymap.registerLayer({
118
119
  commands: SPECS.map((s) => ({
119
120
  name: s.name,
@@ -127,7 +128,7 @@ const tui: TuiPlugin = async (api, options) => {
127
128
  else runNow(api, s.title, cmd)
128
129
  },
129
130
  })),
130
- bindings: keys.gather("mesh.global", SPECS.map((s) => s.name)),
131
+ bindings: [],
131
132
  })
132
133
  }
133
134
 
@@ -85,7 +85,7 @@ function buildRoutingBadge(cfg: InfinicodeConfig | null, tuiModel?: string): Rou
85
85
 
86
86
  // ── Plugin ──────────────────────────────────────────────────────────────────
87
87
 
88
- export const tui = async (api: any) => {
88
+ const tui = async (api: any) => {
89
89
  const [cfg] = createSignal(loadConfig());
90
90
 
91
91
  const getTuiModel = (): string | undefined => {
@@ -132,4 +132,8 @@ export const tui = async (api: any) => {
132
132
  },
133
133
  },
134
134
  });
135
- };
135
+ };
136
+
137
+ // The TUI plugin loader reads `mod.default` (strict mode) and requires an object
138
+ // with a tui() function — a bare `export const tui` is silently skipped.
139
+ export default { id: "infinicode-routing-mode", tui };
@@ -3,6 +3,7 @@
3
3
  "theme": "infinibot-gold",
4
4
  "plugin": [
5
5
  "./plugins/home-logo-animation.tsx",
6
- "./plugins/routing-mode-display.tsx"
6
+ "./plugins/routing-mode-display.tsx",
7
+ "./plugins/mesh-commands.tsx"
7
8
  ]
8
9
  }
package/dist/cli.js CHANGED
@@ -14,9 +14,10 @@ import { providersList, providersAdd, providersRemove, providersTest } from './c
14
14
  import { consoleRepl, consoleRun } from './commands/console.js';
15
15
  import { serve } from './commands/serve.js';
16
16
  import { mcpCommand } from './commands/mcp.js';
17
+ import { meshInstall, meshUninstall } from './commands/mesh-install.js';
17
18
  import { installTui } from './commands/install-tui.js';
18
19
  import { DEFAULT_CONFIG } from './kernel/config-schema.js';
19
- const VERSION = '2.3.5';
20
+ const VERSION = '2.3.6';
20
21
  const config = new Conf({
21
22
  projectName: 'infinicode',
22
23
  defaults: DEFAULT_CONFIG,
@@ -298,6 +299,29 @@ program
298
299
  .action(async (opts) => {
299
300
  await serve(config, opts);
300
301
  });
302
+ // ── infinicode: mesh — one-command MCP registration (no hand-edited JSON) ──
303
+ const meshCmd = program
304
+ .command('mesh')
305
+ .description('Join this device to the agent mesh by auto-registering the MCP server into your coding host');
306
+ meshCmd
307
+ .command('install', { isDefault: true })
308
+ .description('Register the infinicode MCP server into Claude Code (and/or OpenCode) — no manual file edits')
309
+ .option('--host <host>', 'target host: claude | opencode | all', 'claude')
310
+ .option('--token <token>', 'shared bearer token every device must present (recommended off-LAN)')
311
+ .option('--name <name>', 'friendly node name for this device')
312
+ .option('--tailscale', 'discover peers over Tailscale instead of / as well as the LAN')
313
+ .option('--no-lan', 'disable zero-config LAN auto-discovery')
314
+ .option('--print', 'dry run — show what would be written without changing anything')
315
+ .action(async (opts) => {
316
+ await meshInstall(opts);
317
+ });
318
+ meshCmd
319
+ .command('uninstall')
320
+ .description('Remove the infinicode MCP server from your coding host')
321
+ .option('--host <host>', 'target host: claude | opencode | all', 'claude')
322
+ .action(async (opts) => {
323
+ await meshUninstall(opts);
324
+ });
301
325
  // ── infinicode: install the TUI binary into this install ───────────────────
302
326
  program
303
327
  .command('install-tui')
@@ -0,0 +1,10 @@
1
+ export interface MeshInstallOptions {
2
+ host?: string;
3
+ token?: string;
4
+ name?: string;
5
+ tailscale?: boolean;
6
+ lan?: boolean;
7
+ print?: boolean;
8
+ }
9
+ export declare function meshInstall(opts: MeshInstallOptions): Promise<void>;
10
+ export declare function meshUninstall(opts: MeshInstallOptions): Promise<void>;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * infinicode mesh install — register the infinicode MCP server into a coding
3
+ * host's config automatically, so the user never hand-edits a JSON file.
4
+ *
5
+ * infinicode mesh install → add to Claude Code (~/.claude.json)
6
+ * infinicode mesh install --host all → Claude Code + OpenCode
7
+ * infinicode mesh install --token secret → gated mesh
8
+ * infinicode mesh uninstall → remove it again
9
+ *
10
+ * After it runs, the user just restarts their coding host and the mesh tools
11
+ * (list_nodes / spawn_agent / send_message …) are available. On another laptop
12
+ * with the same one command, the two nodes auto-discover over the LAN.
13
+ */
14
+ import chalk from 'chalk';
15
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, mkdirSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { join, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { findExecutable } from '../kernel/agents/backends/detect.js';
20
+ /** How a host should spawn the infinicode MCP node. Resolves a global binary
21
+ * if present, else falls back to `node <this package's cli.js>`. */
22
+ function resolveLaunch() {
23
+ const onPath = findExecutable('infinicode');
24
+ if (onPath)
25
+ return { command: 'infinicode', baseArgs: [] };
26
+ const cliJs = join(dirname(fileURLToPath(import.meta.url)), '..', 'cli.js');
27
+ return { command: process.execPath, baseArgs: [cliJs] };
28
+ }
29
+ /** The `infinicode mcp …` argv the host should run. */
30
+ function meshArgs(opts) {
31
+ const args = ['mcp'];
32
+ if (opts.lan !== false)
33
+ args.push('--lan');
34
+ if (opts.tailscale)
35
+ args.push('--tailscale');
36
+ if (opts.token)
37
+ args.push('--token', opts.token);
38
+ if (opts.name)
39
+ args.push('--name', opts.name);
40
+ return args;
41
+ }
42
+ function readJson(path) {
43
+ if (!existsSync(path))
44
+ return {};
45
+ try {
46
+ return JSON.parse(readFileSync(path, 'utf8'));
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ /** Back up a config once before the first infinicode edit, then write pretty JSON. */
53
+ function writeJson(path, data) {
54
+ const dir = dirname(path);
55
+ if (!existsSync(dir))
56
+ mkdirSync(dir, { recursive: true });
57
+ if (existsSync(path)) {
58
+ const bak = `${path}.infinicode.bak`;
59
+ if (!existsSync(bak))
60
+ copyFileSync(path, bak);
61
+ }
62
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
63
+ }
64
+ const CLAUDE_CONFIG = () => join(homedir(), '.claude.json');
65
+ const OPENCODE_CONFIG = () => join(homedir(), '.config', 'opencode', 'opencode.json');
66
+ /** Claude Code: mcpServers.infinicode = { command, args }. */
67
+ function installClaude(opts) {
68
+ const path = CLAUDE_CONFIG();
69
+ const cfg = readJson(path);
70
+ const servers = cfg.mcpServers ?? {};
71
+ const existed = 'infinicode' in servers;
72
+ const { command, baseArgs } = resolveLaunch();
73
+ const entry = { command, args: [...baseArgs, ...meshArgs(opts)] };
74
+ if (!opts.print) {
75
+ cfg.mcpServers = { ...servers, infinicode: entry };
76
+ writeJson(path, cfg);
77
+ }
78
+ return { path, entry, existed };
79
+ }
80
+ /** OpenCode: mcp.infinicode = { type:'local', command:[...], enabled:true }. */
81
+ function installOpencode(opts) {
82
+ const path = OPENCODE_CONFIG();
83
+ const cfg = readJson(path);
84
+ const mcp = cfg.mcp ?? {};
85
+ const existed = 'infinicode' in mcp;
86
+ const { command, baseArgs } = resolveLaunch();
87
+ const entry = { type: 'local', command: [command, ...baseArgs, ...meshArgs(opts)], enabled: true };
88
+ if (!opts.print) {
89
+ cfg.mcp = { ...mcp, infinicode: entry };
90
+ writeJson(path, cfg);
91
+ }
92
+ return { path, entry, existed };
93
+ }
94
+ function removeFrom(path, key) {
95
+ if (!existsSync(path))
96
+ return false;
97
+ const cfg = readJson(path);
98
+ const bag = cfg[key];
99
+ if (!bag || !('infinicode' in bag))
100
+ return false;
101
+ delete bag.infinicode;
102
+ cfg[key] = bag;
103
+ writeJson(path, cfg);
104
+ return true;
105
+ }
106
+ function selectedHosts(host) {
107
+ const h = (host ?? 'claude').toLowerCase();
108
+ if (h === 'all')
109
+ return ['claude', 'opencode'];
110
+ if (h === 'opencode')
111
+ return ['opencode'];
112
+ if (h === 'claude' || h === 'claude-code')
113
+ return ['claude'];
114
+ return ['claude'];
115
+ }
116
+ export async function meshInstall(opts) {
117
+ const hosts = selectedHosts(opts.host);
118
+ console.log(chalk.bold('\n🕸 infinicode mesh — MCP registration\n'));
119
+ for (const host of hosts) {
120
+ const res = host === 'claude' ? installClaude(opts) : installOpencode(opts);
121
+ const label = host === 'claude' ? 'Claude Code' : 'OpenCode';
122
+ const verb = opts.print ? 'would write' : res.existed ? 'updated' : 'added';
123
+ console.log(` ${chalk.green('✓')} ${chalk.cyan(label)} — ${verb} ${chalk.dim(res.path)}`);
124
+ console.log(chalk.dim(` ${JSON.stringify(res.entry)}`));
125
+ }
126
+ // Warn if the target CLI backend isn't installed on THIS machine — a worker
127
+ // node can only run agents for CLIs it actually has.
128
+ const present = ['claude', 'codex', 'opencode'].filter(b => findExecutable(b));
129
+ console.log();
130
+ if (present.length) {
131
+ console.log(` ${chalk.green('•')} coding CLIs detected here (can execute agents): ${chalk.bold(present.join(', '))}`);
132
+ }
133
+ else {
134
+ console.log(` ${chalk.yellow('•')} no coding CLI (claude/codex/opencode) found on PATH — this device can drive the mesh but can't execute agents until one is installed.`);
135
+ }
136
+ if (opts.print) {
137
+ console.log(chalk.dim('\n (dry run — nothing was written)\n'));
138
+ return;
139
+ }
140
+ console.log(chalk.bold('\n Next:'));
141
+ console.log(` 1. Run the SAME command on the other laptop.`);
142
+ console.log(` 2. Restart your coding host (Claude Code) on both.`);
143
+ console.log(` 3. Ask it: ${chalk.italic('"list_nodes"')} then ${chalk.italic('"spawn_agent on <laptop> with agent claude to …"')}`);
144
+ if (!opts.token) {
145
+ console.log(chalk.dim(`\n Tip: on an untrusted network, re-run with --token <secret> on every device.`));
146
+ }
147
+ console.log();
148
+ }
149
+ export async function meshUninstall(opts) {
150
+ const hosts = selectedHosts(opts.host);
151
+ console.log(chalk.bold('\n🕸 infinicode mesh — removing MCP registration\n'));
152
+ for (const host of hosts) {
153
+ const path = host === 'claude' ? CLAUDE_CONFIG() : OPENCODE_CONFIG();
154
+ const key = host === 'claude' ? 'mcpServers' : 'mcp';
155
+ const removed = removeFrom(path, key);
156
+ const label = host === 'claude' ? 'Claude Code' : 'OpenCode';
157
+ console.log(` ${removed ? chalk.green('✓ removed from') : chalk.dim('· not present in')} ${chalk.cyan(label)} ${chalk.dim(path)}`);
158
+ }
159
+ console.log(chalk.dim('\n Restart your coding host to apply.\n'));
160
+ }
@@ -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, pathToFileURL } from 'node:url';
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
- entry.limit = { context: m.contextLength, output: Math.min(16384, Math.max(2048, Math.floor(m.contextLength / 16))) };
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
- if (mesh) {
461
- process.env.INFINICODE_MESH_URL = mesh.url;
462
- if (mesh.token)
463
- process.env.INFINICODE_MESH_TOKEN = mesh.token;
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({ kernel: sessionKernel, providers: gwProviders, policyName, logger: new SilentLogger() });
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
- gateway = undefined; // fall back to direct-provider config below
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
- modelRef = 'infinicode/auto'; // default to kernel auto-routing
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
- // Runtime TUI plugin that adds the mesh slash-commands (/nodes, /build, …)
515
- // to the `/` palette. Shipped in the package at .opencode/plugins; loaded via
516
- // a file:// URL so it resolves regardless of the TUI's cwd. Only injected when
517
- // a local mesh node is available (else the commands would have nothing to hit).
518
- const pluginPaths = [];
519
- if (mesh) {
520
- const pluginFile = join(resolve(__dirname, '..', '..'), '.opencode', 'plugins', 'mesh-commands.tsx');
521
- if (existsSync(pluginFile))
522
- pluginPaths.push(pathToFileURL(pluginFile).href);
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,
@@ -0,0 +1,37 @@
1
+ import type { AgentBackend, BackendId, BackendRunContext, BackendRunResult, BackendTask } from './types.js';
2
+ /** Accumulator threaded through per-line parsing. */
3
+ export interface CliParsed {
4
+ sessionId?: string;
5
+ content?: string;
6
+ isError?: boolean;
7
+ }
8
+ export interface CliBackendSpec {
9
+ id: BackendId;
10
+ displayName: string;
11
+ /** Binary name looked up on PATH (overridable via `binEnv`). */
12
+ bin: string;
13
+ /** argv for a fresh run. `prompt` already includes any role preamble. */
14
+ runArgs(prompt: string, task: BackendTask): string[];
15
+ /** argv to continue an existing session with a new message. */
16
+ resumeArgs?(sessionId: string, message: string, task: BackendTask): string[];
17
+ /** Parse one output line (or the whole raw output, as a fallback) into `acc`. */
18
+ parseLine(line: string, acc: CliParsed): void;
19
+ /** Extra process env. */
20
+ env?: NodeJS.ProcessEnv;
21
+ /** Env var overriding the binary path (e.g. INFINICODE_CLAUDE_BIN). */
22
+ binEnv?: string;
23
+ /** Env var of extra whitespace-split args appended to every invocation. */
24
+ extraArgsEnv?: string;
25
+ }
26
+ export declare class CliAgentBackend implements AgentBackend {
27
+ private spec;
28
+ readonly id: BackendId;
29
+ readonly displayName: string;
30
+ constructor(spec: CliBackendSpec);
31
+ private resolveBin;
32
+ available(): boolean;
33
+ private extraArgs;
34
+ run(task: BackendTask, ctx: BackendRunContext): Promise<BackendRunResult>;
35
+ send(sessionId: string, message: string, ctx: BackendRunContext): Promise<BackendRunResult>;
36
+ private exec;
37
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * OpenKernel — Generic CLI agent backend
3
+ *
4
+ * Runs a third-party coding CLI (Claude Code / Codex / OpenCode) as the executor
5
+ * for a dispatched mesh task. Spawns the process, streams every stdout/stderr
6
+ * line up the mesh as a frame, and parses the CLI's output into a session id +
7
+ * final text so the run can be followed and (later) messaged again.
8
+ *
9
+ * A `CliBackendSpec` captures the per-CLI differences (binary, argv, parsing);
10
+ * everything else — spawning, streaming, cancellation — is shared here.
11
+ */
12
+ import { spawn } from 'node:child_process';
13
+ import { findExecutable } from './detect.js';
14
+ export class CliAgentBackend {
15
+ spec;
16
+ id;
17
+ displayName;
18
+ constructor(spec) {
19
+ this.spec = spec;
20
+ this.id = spec.id;
21
+ this.displayName = spec.displayName;
22
+ }
23
+ resolveBin() {
24
+ const override = this.spec.binEnv ? process.env[this.spec.binEnv] : undefined;
25
+ if (override)
26
+ return override;
27
+ return findExecutable(this.spec.bin);
28
+ }
29
+ available() {
30
+ return this.resolveBin() !== null;
31
+ }
32
+ extraArgs() {
33
+ const raw = this.spec.extraArgsEnv ? process.env[this.spec.extraArgsEnv] : undefined;
34
+ return raw ? raw.split(/\s+/).filter(Boolean) : [];
35
+ }
36
+ async run(task, ctx) {
37
+ const prompt = task.prompt && ctx.rolePreamble ? `${ctx.rolePreamble}\n\n${task.prompt}` : task.prompt;
38
+ const argv = task.sessionId && this.spec.resumeArgs
39
+ ? this.spec.resumeArgs(task.sessionId, prompt, task)
40
+ : this.spec.runArgs(prompt, task);
41
+ return this.exec([...argv, ...this.extraArgs()], ctx);
42
+ }
43
+ async send(sessionId, message, ctx) {
44
+ const msg = ctx.rolePreamble ? `${ctx.rolePreamble}\n\n${message}` : message;
45
+ const task = { prompt: msg, sessionId };
46
+ const argv = this.spec.resumeArgs
47
+ ? this.spec.resumeArgs(sessionId, msg, task)
48
+ : this.spec.runArgs(msg, task);
49
+ return this.exec([...argv, ...this.extraArgs()], ctx);
50
+ }
51
+ exec(argv, ctx) {
52
+ return new Promise(resolve => {
53
+ const bin = this.resolveBin();
54
+ if (!bin) {
55
+ resolve({ status: 'failed', error: `${this.spec.bin} not found on PATH` });
56
+ return;
57
+ }
58
+ const child = spawn(bin, argv, {
59
+ cwd: ctx.cwd ?? process.cwd(),
60
+ env: { ...process.env, ...this.spec.env },
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ });
63
+ const acc = {};
64
+ let stdoutBuf = '';
65
+ let rawAll = '';
66
+ let stderrTail = '';
67
+ let settled = false;
68
+ const onAbort = () => {
69
+ try {
70
+ child.kill();
71
+ }
72
+ catch { /* already gone */ }
73
+ };
74
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
75
+ const finish = (result) => {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ ctx.signal?.removeEventListener('abort', onAbort);
80
+ resolve(result);
81
+ };
82
+ child.stdout.setEncoding('utf8');
83
+ child.stdout.on('data', (chunk) => {
84
+ rawAll += chunk;
85
+ stdoutBuf += chunk;
86
+ let nl;
87
+ while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
88
+ const line = stdoutBuf.slice(0, nl).replace(/\r$/, '');
89
+ stdoutBuf = stdoutBuf.slice(nl + 1);
90
+ if (!line.trim())
91
+ continue;
92
+ try {
93
+ this.spec.parseLine(line, acc);
94
+ }
95
+ catch { /* tolerant parsing */ }
96
+ ctx.onFrame({ kind: 'log', text: line });
97
+ }
98
+ });
99
+ child.stderr.setEncoding('utf8');
100
+ child.stderr.on('data', (chunk) => {
101
+ stderrTail = (stderrTail + chunk).slice(-2000);
102
+ const trimmed = chunk.replace(/\s+$/, '');
103
+ if (trimmed)
104
+ ctx.onFrame({ kind: 'log', text: trimmed });
105
+ });
106
+ child.on('error', err => finish({ status: 'failed', error: err.message }));
107
+ child.on('close', code => {
108
+ // Flush a trailing partial line.
109
+ if (stdoutBuf.trim()) {
110
+ try {
111
+ this.spec.parseLine(stdoutBuf.trim(), acc);
112
+ }
113
+ catch { /* ignore */ }
114
+ }
115
+ // Fallback for CLIs that pretty-print one multi-line JSON object.
116
+ if (!acc.content && !acc.sessionId && rawAll.trim()) {
117
+ try {
118
+ this.spec.parseLine(rawAll.trim(), acc);
119
+ }
120
+ catch { /* ignore */ }
121
+ }
122
+ const ok = !acc.isError && (code === 0 || !!acc.content);
123
+ finish({
124
+ status: ok ? 'completed' : 'failed',
125
+ sessionId: acc.sessionId,
126
+ content: acc.content,
127
+ error: ok ? undefined : (stderrTail.trim() || acc.content || `${this.spec.bin} exited with code ${code}`),
128
+ });
129
+ });
130
+ });
131
+ }
132
+ }
@@ -0,0 +1,5 @@
1
+ /** Absolute path to `bin` on PATH, or null if not found. */
2
+ export declare function findExecutable(bin: string): string | null;
3
+ export declare function hasExecutable(bin: string): boolean;
4
+ /** Test hook — forget cached lookups. */
5
+ export declare function clearExecutableCache(): void;