infinicode 2.3.3 → 2.3.5

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.
@@ -0,0 +1,139 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * infinicode — mesh command palette
4
+ *
5
+ * Adds the OpenKernel mesh/kernel slash-commands natively to the TUI's `/`
6
+ * command palette. Each command is dispatched to a LOCAL mesh node over HTTP
7
+ * (POST /fed/command → kernel.command()), so the same command surface exposed by
8
+ * `infinicode serve`'s console and the CLI is available right inside the TUI.
9
+ *
10
+ * The local node is started (or reused) by `infinicode run`, which injects:
11
+ * INFINICODE_MESH_URL e.g. http://127.0.0.1:47913
12
+ * INFINICODE_MESH_TOKEN optional bearer token
13
+ *
14
+ * Argless commands (/nodes, /running, /activity, /whoami, /mesh-help) run
15
+ * immediately and show output in an alert. Arg-taking commands (/node, /build,
16
+ * /follow, /dispatch, /goal, /mission, /role) open a prompt first.
17
+ */
18
+ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
19
+ import { createBindingLookup } from "@opentui/keymap/extras"
20
+
21
+ type CommandResult = { ok?: boolean; text?: string; data?: unknown }
22
+
23
+ const meshUrl = () => (process.env.INFINICODE_MESH_URL ?? "http://127.0.0.1:47913").replace(/\/+$/, "")
24
+ const meshToken = () => process.env.INFINICODE_MESH_TOKEN
25
+
26
+ /** POST a slash-command to the local mesh node and return its CommandResult. */
27
+ async function send(input: string): Promise<CommandResult> {
28
+ const token = meshToken()
29
+ try {
30
+ const res = await fetch(`${meshUrl()}/fed/command`, {
31
+ method: "POST",
32
+ headers: {
33
+ "content-type": "application/json",
34
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
35
+ },
36
+ body: JSON.stringify({ input }),
37
+ signal: AbortSignal.timeout(30_000),
38
+ })
39
+ if (!res.ok) return { ok: false, text: `mesh error ${res.status} — is a node running? (infinicode serve)` }
40
+ return (await res.json()) as CommandResult
41
+ } catch (e) {
42
+ const msg = e instanceof Error ? e.message : String(e)
43
+ return { ok: false, text: `no local mesh node reachable at ${meshUrl()} (${msg})` }
44
+ }
45
+ }
46
+
47
+ /** Render a CommandResult in a scrollable alert dialog. */
48
+ function show(api: TuiPluginApi, title: string, res: CommandResult): void {
49
+ const DialogAlert = api.ui.DialogAlert
50
+ const message = (res.text && res.text.trim()) || (res.ok ? "(no output)" : "command failed")
51
+ api.ui.dialog.setSize("large")
52
+ api.ui.dialog.replace(() => (
53
+ <DialogAlert title={title} message={message} onConfirm={() => api.ui.dialog.clear()} />
54
+ ))
55
+ }
56
+
57
+ /** Run a fixed slash-command now and show the result. */
58
+ function runNow(api: TuiPluginApi, title: string, input: string): void {
59
+ api.ui.toast({ variant: "info", title: "mesh", message: `${input} …`, duration: 1200 })
60
+ void send(input).then((res) => show(api, title, res))
61
+ }
62
+
63
+ /** Open a prompt, then dispatch `<slash> <entered args>`. */
64
+ function runWithArgs(
65
+ api: TuiPluginApi,
66
+ title: string,
67
+ slash: string,
68
+ placeholder: string,
69
+ ): void {
70
+ const DialogPrompt = api.ui.DialogPrompt
71
+ api.ui.dialog.setSize("medium")
72
+ api.ui.dialog.replace(() => (
73
+ <DialogPrompt
74
+ title={title}
75
+ value=""
76
+ placeholder={placeholder}
77
+ onConfirm={(args: string) => {
78
+ api.ui.dialog.clear()
79
+ const input = args && args.trim() ? `${slash} ${args.trim()}` : slash
80
+ api.ui.toast({ variant: "info", title: "mesh", message: `${input} …`, duration: 1200 })
81
+ void send(input).then((res) => show(api, title, res))
82
+ }}
83
+ onCancel={() => api.ui.dialog.clear()}
84
+ />
85
+ ))
86
+ }
87
+
88
+ interface Spec {
89
+ name: string
90
+ slashName: string
91
+ title: string
92
+ /** if set, prompt for args first with this placeholder; else run immediately */
93
+ arg?: string
94
+ /** the underlying kernel slash-command (defaults to `/${slashName}`) */
95
+ cmd?: string
96
+ }
97
+
98
+ const SPECS: Spec[] = [
99
+ { name: "mesh_nodes", slashName: "nodes", title: "Mesh nodes" },
100
+ { name: "mesh_running", slashName: "running", title: "Running tasks" },
101
+ { name: "mesh_activity", slashName: "activity", title: "Mesh activity" },
102
+ { name: "mesh_whoami", slashName: "whoami", title: "This node" },
103
+ { name: "mesh_help", slashName: "mesh-help", title: "Mesh commands", cmd: "/help" },
104
+ { name: "mesh_node", slashName: "node", title: "Node control", arg: "<node> <message>", cmd: "/node" },
105
+ { name: "mesh_build", slashName: "build", title: "Phased build", arg: "<node> <goal>", cmd: "/build" },
106
+ { name: "mesh_dispatch", slashName: "dispatch", title: "Dispatch task", arg: "<node> <task>", cmd: "/dispatch" },
107
+ { name: "mesh_follow", slashName: "follow", title: "Follow run", arg: "<run-id>", cmd: "/follow" },
108
+ { name: "mesh_goal", slashName: "goal", title: "New goal", arg: "<goal description>", cmd: "/goal" },
109
+ { name: "mesh_mission", slashName: "mission", title: "Mission", arg: "<mission description>", cmd: "/mission" },
110
+ { name: "mesh_role", slashName: "role", title: "Set role", arg: "<role>", cmd: "/role" },
111
+ ]
112
+
113
+ const tui: TuiPlugin = async (api, options) => {
114
+ if (options?.enabled === false) return
115
+ const keys = createBindingLookup({})
116
+
117
+ api.keymap.registerLayer({
118
+ commands: SPECS.map((s) => ({
119
+ name: s.name,
120
+ title: s.title,
121
+ category: "Mesh",
122
+ namespace: "palette" as const,
123
+ slashName: s.slashName,
124
+ run() {
125
+ const cmd = s.cmd ?? `/${s.slashName}`
126
+ if (s.arg) runWithArgs(api, s.title, cmd, s.arg)
127
+ else runNow(api, s.title, cmd)
128
+ },
129
+ })),
130
+ bindings: keys.gather("mesh.global", SPECS.map((s) => s.name)),
131
+ })
132
+ }
133
+
134
+ const plugin: TuiPluginModule & { id: string } = {
135
+ id: "infinicode-mesh-commands",
136
+ tui,
137
+ }
138
+
139
+ export default plugin
package/dist/cli.js CHANGED
@@ -16,7 +16,7 @@ import { serve } from './commands/serve.js';
16
16
  import { mcpCommand } from './commands/mcp.js';
17
17
  import { installTui } from './commands/install-tui.js';
18
18
  import { DEFAULT_CONFIG } from './kernel/config-schema.js';
19
- const VERSION = '2.3.3';
19
+ const VERSION = '2.3.5';
20
20
  const config = new Conf({
21
21
  projectName: 'infinicode',
22
22
  defaults: DEFAULT_CONFIG,
@@ -16,14 +16,14 @@
16
16
  */
17
17
  import chalk from 'chalk';
18
18
  import ora from 'ora';
19
- import { existsSync } from 'node:fs';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
20
  import { dirname, join, resolve } from 'node:path';
21
- import { fileURLToPath } from 'node:url';
21
+ import { fileURLToPath, pathToFileURL } 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 } from '../kernel/index.js';
26
+ import { SilentLogger, Federation, loadOrCreateIdentity, KernelGateway } 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);
@@ -257,8 +257,11 @@ async function pickDefaultModel(config, masterReachable) {
257
257
  * in parallel (so all models are exposed, incl. large ones like GLM-5.2 on HF
258
258
  * and Nemotron-Ultra on NVIDIA), then route the default model under the policy.
259
259
  */
260
- async function prepareRouting(config, policyName) {
261
- const kernel = buildKernelFromConfig(config, { logger: new SilentLogger() });
260
+ async function prepareRouting(config, policyName, sharedKernel) {
261
+ // Reuse the caller's session kernel when provided (so the gateway routes
262
+ // against the same warmed-up provider pool) — only destroy a kernel we own.
263
+ const kernel = sharedKernel ?? buildKernelFromConfig(config, { logger: new SilentLogger() });
264
+ const ownsKernel = !sharedKernel;
262
265
  const modelsByProvider = {};
263
266
  try {
264
267
  // Let background token-verified health checks run first.
@@ -297,7 +300,8 @@ async function prepareRouting(config, policyName) {
297
300
  return { routed: null, modelsByProvider };
298
301
  }
299
302
  finally {
300
- kernel.destroy();
303
+ if (ownsKernel)
304
+ kernel.destroy();
301
305
  }
302
306
  }
303
307
  /** Ensure the routed model exists in the TUI's injected provider config. */
@@ -309,6 +313,135 @@ function ensureModelInjected(providers, providerId, modelId) {
309
313
  if (!p.models[modelId])
310
314
  p.models[modelId] = { name: modelId };
311
315
  }
316
+ function runPkgVersion() {
317
+ try {
318
+ return JSON.parse(readFileSync(join(resolve(__dirname, '..', '..'), 'package.json'), 'utf8')).version ?? '0.0.0';
319
+ }
320
+ catch {
321
+ return '0.0.0';
322
+ }
323
+ }
324
+ /** Is a mesh node already listening at this URL? (e.g. a separate `infinicode serve`). */
325
+ async function meshAlive(url, token) {
326
+ try {
327
+ const res = await fetch(`${url}/fed/manifest`, {
328
+ headers: token ? { authorization: `Bearer ${token}` } : undefined,
329
+ signal: AbortSignal.timeout(1500),
330
+ });
331
+ return res.ok;
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ /**
338
+ * Make the TUI a mesh participant so the in-TUI slash commands (/nodes, /build,
339
+ * /activity …) have a local node to talk to. If a node is already running on the
340
+ * configured port (a separate `infinicode serve`), point the TUI at THAT one;
341
+ * otherwise start an in-process node for the session. Best-effort: on any
342
+ * failure the TUI still launches, just without the live mesh palette.
343
+ */
344
+ async function startLocalMeshNode(config, kernel) {
345
+ if (process.env.INFINICODE_NO_MESH)
346
+ return null;
347
+ const fed = config.get('federation') ?? {};
348
+ const port = fed.port ?? 47913;
349
+ const token = fed.token;
350
+ const url = `http://127.0.0.1:${port}`;
351
+ try {
352
+ // Reuse an already-running node (don't double-bind the port).
353
+ if (await meshAlive(url, token))
354
+ return { url, token, stop: async () => { } };
355
+ const role = (fed.role ?? 'peer');
356
+ const identity = loadOrCreateIdentity({ role, displayName: fed.displayName });
357
+ const version = runPkgVersion();
358
+ const describe = () => {
359
+ const defs = kernel.workerRuntime.listRegisteredDefinitions();
360
+ return {
361
+ version,
362
+ capabilities: [...new Set(defs.flatMap(d => d.capabilities))],
363
+ workers: defs.map(d => d.type),
364
+ commands: kernel.commands.list().map(c => c.name),
365
+ providers: kernel.providerManager.listInfos().map(p => p.id),
366
+ };
367
+ };
368
+ const federation = new Federation({
369
+ kernel, identity, describe, logger: new SilentLogger(),
370
+ options: {
371
+ version, port, host: fed.host, token, seeds: fed.seeds,
372
+ autoPullConfig: role === 'satellite',
373
+ },
374
+ });
375
+ kernel.attachFederation(federation);
376
+ await federation.start();
377
+ if (fed.lan)
378
+ federation.startLanDiscovery(undefined, fed.lanPort);
379
+ for (const s of fed.seeds ?? [])
380
+ await federation.connect(s).catch(() => undefined);
381
+ // The kernel is owned by runAgent — the mesh only stops the federation.
382
+ return {
383
+ url, token,
384
+ stop: async () => { await federation.stop(); },
385
+ };
386
+ }
387
+ catch {
388
+ return null;
389
+ }
390
+ }
391
+ /** Build the gateway's provider table (baseURL/apiKey/models) from config. */
392
+ function buildGatewayProviders(config, masterUrl, masterReachable, defaultModel, modelsByProvider) {
393
+ const out = [];
394
+ const catalog = (id, fallback) => {
395
+ const fetched = modelsByProvider[id];
396
+ return fetched && fetched.length ? fetched : fallback;
397
+ };
398
+ const shape = (models) => models.map(m => ({ id: m.id, name: m.name, contextLength: m.contextLength }));
399
+ if (masterReachable) {
400
+ out.push({
401
+ id: 'ollama',
402
+ baseURL: `${masterUrl}/v1`,
403
+ models: shape(catalog('ollama', [{ id: defaultModel, name: defaultModel, contextLength: 8192 }])),
404
+ });
405
+ }
406
+ for (const cfg of config.get('cloudProviders') ?? []) {
407
+ if (!cfg.enabled || !cfg.apiKey)
408
+ continue;
409
+ const preset = getProviderPreset(cfg.id);
410
+ const fallback = (preset?.knownModels ?? []).map(m => ({ id: m.id, name: m.name, contextLength: m.contextLength }));
411
+ const models = shape(catalog(cfg.id, fallback));
412
+ if (cfg.id === 'gemini') {
413
+ // Google's OpenAI-compatibility endpoint keeps the relay uniform.
414
+ out.push({ id: 'gemini', baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai', apiKey: cfg.apiKey, models });
415
+ }
416
+ else {
417
+ const headers = cfg.headerName && cfg.headerName !== 'Authorization' ? { [cfg.headerName]: cfg.apiKey } : undefined;
418
+ out.push({ id: cfg.id, baseURL: openAICompatBaseURL(cfg.baseURL), apiKey: cfg.apiKey, headers, models });
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+ /**
424
+ * Build the TUI's provider config as a SINGLE `infinicode` provider pointing at
425
+ * the local kernel gateway. Real API keys stay server-side (the TUI only gets a
426
+ * dummy token); every model in the pool is exposed as `providerId/modelId`, plus
427
+ * an `auto` entry that lets the kernel router pick per request.
428
+ */
429
+ function buildGatewayTuiConfig(gatewayUrl, providers) {
430
+ const models = { auto: { name: 'AUTO — kernel routes best model' } };
431
+ for (const p of providers) {
432
+ for (const m of p.models) {
433
+ models[`${p.id}/${m.id}`] = modelEntry({ id: `${p.id}/${m.id}`, name: m.name ?? m.id, contextLength: m.contextLength });
434
+ }
435
+ }
436
+ return {
437
+ infinicode: {
438
+ npm: '@ai-sdk/openai-compatible',
439
+ name: 'infinicode (kernel)',
440
+ options: { baseURL: gatewayUrl, apiKey: 'infinicode-gateway' },
441
+ models,
442
+ },
443
+ };
444
+ }
312
445
  export async function runAgent(_masterUrl, _model, _useTui, config) {
313
446
  const masterUrl = await resolveConfiguredMasterUrl(config);
314
447
  const masterReachable = await isOllamaReachable(masterUrl);
@@ -316,36 +449,85 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
316
449
  const cloudProviders = config.get('cloudProviders') ?? [];
317
450
  const enabledCloud = cloudProviders.filter(p => p.enabled);
318
451
  const policyName = config.get('policy') ?? 'balanced';
452
+ // Session kernel — lives for the whole TUI session. It is the routing/recovery
453
+ // brain the gateway and the mesh node both share, so every chat turn the TUI
454
+ // makes flows through the same warmed-up provider pool + event bus.
455
+ const sessionKernel = buildKernelFromConfig(config, { logger: new SilentLogger() });
456
+ // Make this TUI session a mesh participant so the in-TUI slash commands
457
+ // (/nodes, /build, /activity, …) have a local node to talk to. Reuses a
458
+ // running `infinicode serve` if one is up, else starts an in-process node.
459
+ 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
+ }
319
465
  // One kernel pass: load every provider's full model catalog (in parallel) and
320
466
  // route the default model under the policy.
321
467
  const routeSpinner = ora('loading model catalog & routing...').start();
322
- const { routed, modelsByProvider } = await prepareRouting(config, policyName);
468
+ const { routed, modelsByProvider } = await prepareRouting(config, policyName, sessionKernel);
323
469
  routeSpinner.stop();
324
- const providers = buildProviderConfig(masterUrl, defaultModel, cloudProviders, masterReachable, modelsByProvider);
325
- let defaultProvider;
326
- let pickedModel;
327
- if (routed && providers[routed.providerId]) {
328
- defaultProvider = routed.providerId;
329
- pickedModel = routed.modelId;
330
- ensureModelInjected(providers, routed.providerId, routed.modelId);
470
+ // Stand up the OpenAI-compatible kernel gateway. The TUI points at THIS as its
471
+ // single provider, so interactive chat gains full kernel parity: auto-routing,
472
+ // escalate-on-error, provider rotation, health/quota, live telemetry.
473
+ const gwProviders = buildGatewayProviders(config, masterUrl, masterReachable, defaultModel, modelsByProvider);
474
+ let gateway;
475
+ if (!process.env.INFINICODE_NO_GATEWAY && gwProviders.length > 0) {
476
+ try {
477
+ gateway = new KernelGateway({ kernel: sessionKernel, providers: gwProviders, policyName, logger: new SilentLogger() });
478
+ await gateway.start();
479
+ }
480
+ catch {
481
+ gateway = undefined; // fall back to direct-provider config below
482
+ }
483
+ }
484
+ let providers;
485
+ let modelRef;
486
+ if (gateway) {
487
+ providers = buildGatewayTuiConfig(gateway.url, gwProviders);
488
+ modelRef = 'infinicode/auto'; // default to kernel auto-routing
331
489
  }
332
490
  else {
333
- const fb = await pickDefaultModel(config, masterReachable);
334
- defaultProvider = fb.provider;
335
- pickedModel = fb.model;
491
+ // Fallback: talk to providers directly (no kernel in the loop).
492
+ providers = buildProviderConfig(masterUrl, defaultModel, cloudProviders, masterReachable, modelsByProvider);
493
+ let defaultProvider;
494
+ let pickedModel;
495
+ if (routed && providers[routed.providerId]) {
496
+ defaultProvider = routed.providerId;
497
+ pickedModel = routed.modelId;
498
+ ensureModelInjected(providers, routed.providerId, routed.modelId);
499
+ }
500
+ else {
501
+ const fb = await pickDefaultModel(config, masterReachable);
502
+ defaultProvider = fb.provider;
503
+ pickedModel = fb.model;
504
+ }
505
+ modelRef = `${defaultProvider}/${pickedModel}`;
336
506
  }
337
- const modelRef = `${defaultProvider}/${pickedModel}`;
338
- const providerCount = Object.keys(providers).length;
507
+ const providerCount = gateway ? gwProviders.length : Object.keys(providers).length;
339
508
  const modelCount = Object.values(providers).reduce((n, p) => n + Object.keys(p.models ?? {}).length, 0);
340
509
  const policy = config.get('policy') ?? 'local-first';
341
510
  const pinnedCount = Object.keys(config.get('workerModels') ?? {}).length;
342
- const routingInfo = `AUTO ${policy} ${providerCount}P ${pinnedCount}pins`;
511
+ const routingInfo = gateway
512
+ ? `KERNEL ${policy} ${providerCount}P ${pinnedCount}pins`
513
+ : `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);
523
+ }
343
524
  process.env.OPENCODE_CONFIG_CONTENT = JSON.stringify({
344
525
  $schema: 'https://opencode.ai/config.json',
345
526
  provider: providers,
346
527
  model: modelRef,
347
528
  small_model: modelRef,
348
529
  enabled_providers: Object.keys(providers),
530
+ ...(pluginPaths.length > 0 ? { plugin: pluginPaths } : {}),
349
531
  agent: {
350
532
  build: {
351
533
  model: modelRef,
@@ -360,6 +542,9 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
360
542
  },
361
543
  });
362
544
  console.log(chalk.dim(`\ninfinicode — launching TUI with ${chalk.cyan(String(providerCount))} provider(s), ${chalk.cyan(String(modelCount))} model(s)`));
545
+ if (gateway) {
546
+ console.log(chalk.dim(` kernel gateway: ${chalk.green(gateway.url)} ${chalk.dim('(auto-routing + escalate-on-error)')}`));
547
+ }
363
548
  console.log(chalk.dim(` default model: ${chalk.cyan(modelRef)}`));
364
549
  console.log(chalk.dim(` policy: ${chalk.cyan(config.get('policy') ?? 'local-first')}`));
365
550
  if (enabledCloud.length > 0) {
@@ -384,6 +569,11 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
384
569
  console.log(chalk.cyan(' infinicode install-tui --url <url>') + chalk.dim(' # or download from a hosted binary'));
385
570
  process.exit(1);
386
571
  }
572
+ const cleanup = async () => {
573
+ await gateway?.stop().catch(() => undefined);
574
+ await mesh?.stop().catch(() => undefined);
575
+ sessionKernel.destroy();
576
+ };
387
577
  const { execa } = await import('execa');
388
578
  try {
389
579
  await execa(tuiBinary, [], {
@@ -392,10 +582,12 @@ export async function runAgent(_masterUrl, _model, _useTui, config) {
392
582
  });
393
583
  }
394
584
  catch (e) {
585
+ await cleanup();
395
586
  const err = e;
396
587
  if (err.exitCode) {
397
588
  process.exit(err.exitCode);
398
589
  }
399
590
  throw e;
400
591
  }
592
+ await cleanup();
401
593
  }
@@ -61,6 +61,7 @@ export class Federation {
61
61
  logger,
62
62
  getManifest: () => buildManifest(identity, this.deps.describe()),
63
63
  getNodes: () => this.nodeStatuses(),
64
+ runCommand: (input) => this.deps.kernel.command(input),
64
65
  onRpc: (env, remote) => this.onRpc(env, remote),
65
66
  });
66
67
  await this.server.start();
@@ -9,6 +9,9 @@ export interface MeshServerOptions {
9
9
  getManifest: () => NodeManifest;
10
10
  /** Optional: full mesh view (self + peers) for the GET /fed/nodes probe. */
11
11
  getNodes?: () => unknown;
12
+ /** Optional: run a kernel slash-command on this node (POST /fed/command).
13
+ * Powers the in-TUI mesh command palette. */
14
+ runCommand?: (input: string) => Promise<unknown>;
12
15
  onRpc: RpcHandler;
13
16
  logger: Logger;
14
17
  }
@@ -78,6 +78,31 @@ export class MeshServer {
78
78
  this.openStream(req, res);
79
79
  return;
80
80
  }
81
+ if (req.method === 'POST' && url.startsWith('/fed/command')) {
82
+ if (!this.opts.runCommand) {
83
+ res.writeHead(404).end('no command handler');
84
+ return;
85
+ }
86
+ this.readBody(req)
87
+ .then(async (body) => {
88
+ let input = '';
89
+ try {
90
+ input = JSON.parse(body).input ?? '';
91
+ }
92
+ catch {
93
+ res.writeHead(400).end('bad json');
94
+ return;
95
+ }
96
+ const result = await this.opts.runCommand(input);
97
+ res.writeHead(200, { 'content-type': 'application/json' });
98
+ res.end(JSON.stringify(result ?? { ok: false, text: 'no result' }));
99
+ })
100
+ .catch(err => {
101
+ this.opts.logger.warn(`[federation] command error: ${err instanceof Error ? err.message : String(err)}`);
102
+ res.writeHead(500).end('command failed');
103
+ });
104
+ return;
105
+ }
81
106
  if (req.method === 'POST' && url.startsWith('/fed/rpc')) {
82
107
  this.readBody(req)
83
108
  .then(async (body) => {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OpenKernel — Gateway module.
3
+ *
4
+ * A local OpenAI-compatible endpoint backed by the kernel's routing + recovery,
5
+ * so the interactive TUI path gains 1:1 parity with the headless mission path.
6
+ */
7
+ export { KernelGateway, FetchExecutor, type KernelGatewayOptions, type GatewayProvider, type GatewayTarget, type ProviderExecutor, } from './openai-gateway.js';
@@ -0,0 +1,7 @@
1
+ /**
2
+ * OpenKernel — Gateway module.
3
+ *
4
+ * A local OpenAI-compatible endpoint backed by the kernel's routing + recovery,
5
+ * so the interactive TUI path gains 1:1 parity with the headless mission path.
6
+ */
7
+ export { KernelGateway, FetchExecutor, } from './openai-gateway.js';
@@ -0,0 +1,85 @@
1
+ import type { Kernel } from '../kernel.js';
2
+ import type { Logger, Capability } from '../types.js';
3
+ /** A provider the gateway can execute against (resolved from config). */
4
+ export interface GatewayProvider {
5
+ id: string;
6
+ /** OpenAI-compatible base URL ending in the version segment (…/v1). */
7
+ baseURL: string;
8
+ apiKey?: string;
9
+ /** Extra headers (e.g. a custom auth header name for providers that need it). */
10
+ headers?: Record<string, string>;
11
+ models: Array<{
12
+ id: string;
13
+ name?: string;
14
+ contextLength?: number;
15
+ }>;
16
+ }
17
+ /** A fully-resolved call target (provider + chosen model). */
18
+ export interface GatewayTarget {
19
+ providerId: string;
20
+ baseURL: string;
21
+ apiKey?: string;
22
+ headers?: Record<string, string>;
23
+ model: string;
24
+ }
25
+ /**
26
+ * The execution plane. Swap this to route calls through Bifrost / a remote
27
+ * gateway / a mock without changing any kernel routing/recovery logic.
28
+ */
29
+ export interface ProviderExecutor {
30
+ execute(target: GatewayTarget, body: Record<string, unknown>, signal: AbortSignal): Promise<Response>;
31
+ }
32
+ /** Default executor: call the upstream OpenAI-compatible endpoint directly. */
33
+ export declare class FetchExecutor implements ProviderExecutor {
34
+ execute(target: GatewayTarget, body: Record<string, unknown>, signal: AbortSignal): Promise<Response>;
35
+ }
36
+ export interface KernelGatewayOptions {
37
+ kernel: Kernel;
38
+ providers: GatewayProvider[];
39
+ /** Policy name to route under (e.g. 'balanced'). */
40
+ policyName: string;
41
+ host?: string;
42
+ /** Preferred port; the gateway scans upward if it is taken. */
43
+ port?: number;
44
+ logger: Logger;
45
+ executor?: ProviderExecutor;
46
+ /** Capabilities to route interactive chat under. */
47
+ capabilities?: Capability[];
48
+ }
49
+ export declare class KernelGateway {
50
+ private opts;
51
+ private server?;
52
+ private table;
53
+ private executor;
54
+ private boundPort;
55
+ private modelsCache?;
56
+ constructor(opts: KernelGatewayOptions);
57
+ get port(): number;
58
+ get url(): string;
59
+ /** Start listening. Scans ports [port .. port+10] to avoid collisions. */
60
+ start(): Promise<void>;
61
+ private listen;
62
+ stop(): Promise<void>;
63
+ private handle;
64
+ private listModels;
65
+ private handleChat;
66
+ /** Stream bytes through untouched, or relay the JSON body for non-streaming. */
67
+ private relay;
68
+ /** Resolve the initial target: honor an explicit provider/model, else route. */
69
+ private resolveTarget;
70
+ /** Ask the kernel router for the best untried, executable model. */
71
+ private routerPick;
72
+ /**
73
+ * Choose the next target after a failure. 429 / provider-down → rotate to a
74
+ * different provider; everything else (incl. Groq's tool_use_failed) →
75
+ * escalate to a stronger model via the kernel ladder, then fall back to rank.
76
+ */
77
+ private nextTarget;
78
+ private toTarget;
79
+ private rank;
80
+ private routingRequest;
81
+ private recordError;
82
+ private emitSwitch;
83
+ private sendJson;
84
+ private readBody;
85
+ }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * OpenKernel — OpenAI-compatible Gateway
3
+ *
4
+ * A local HTTP endpoint (`/v1/chat/completions`, `/v1/models`) that sits between
5
+ * the opencode TUI (the Harness) and the real LLM providers. Every chat turn the
6
+ * TUI makes flows THROUGH the kernel, so the interactive path gains full 1:1
7
+ * parity with the headless mission path:
8
+ *
9
+ * • routing — CapabilityRouter.rank() picks the best model per request
10
+ * (model "auto"), honoring the active policy + benchmarks.
11
+ * • escalation — on a provider error (e.g. Groq 400 "tool_use_failed"),
12
+ * RecoveryManager.classify() + router.escalate() swap to a
13
+ * stronger model (or rotate provider) and retry, transparently.
14
+ * • health/quota — recordSuccess/recordFailure/record429 feed the pool.
15
+ * • telemetry — MODEL_CHANGED / PROVIDER_CHANGED / RECOVERY events fire on
16
+ * the kernel bus, so /activity + the mesh see TUI traffic.
17
+ *
18
+ * Because the TUI *and* every upstream speak the OpenAI wire format, the gateway
19
+ * is a TRANSPARENT RELAY: it forwards messages/tools/tool_choice verbatim and
20
+ * streams the response back byte-for-byte, only choosing WHICH provider+model to
21
+ * hit. The kernel is purely the routing/recovery brain — no lossy re-encoding.
22
+ *
23
+ * The provider CALL is isolated behind the ProviderExecutor interface. The default
24
+ * FetchExecutor calls the upstream directly; a BifrostExecutor (or any other
25
+ * execution plane) can be dropped in later without touching the routing logic.
26
+ */
27
+ import { createServer } from 'node:http';
28
+ /** Default executor: call the upstream OpenAI-compatible endpoint directly. */
29
+ export class FetchExecutor {
30
+ async execute(target, body, signal) {
31
+ const headers = { 'content-type': 'application/json' };
32
+ if (target.apiKey)
33
+ headers['authorization'] = `Bearer ${target.apiKey}`;
34
+ Object.assign(headers, target.headers ?? {}); // custom headers win (e.g. non-Authorization key header)
35
+ const url = `${target.baseURL.replace(/\/+$/, '')}/chat/completions`;
36
+ return fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
37
+ }
38
+ }
39
+ const MAX_ATTEMPTS = 4;
40
+ const MODELS_TTL_MS = 30_000;
41
+ export class KernelGateway {
42
+ opts;
43
+ server;
44
+ table = new Map();
45
+ executor;
46
+ boundPort = 0;
47
+ modelsCache;
48
+ constructor(opts) {
49
+ this.opts = opts;
50
+ for (const p of opts.providers)
51
+ this.table.set(p.id, p);
52
+ this.executor = opts.executor ?? new FetchExecutor();
53
+ }
54
+ get port() {
55
+ return this.boundPort;
56
+ }
57
+ get url() {
58
+ return `http://${this.opts.host ?? '127.0.0.1'}:${this.boundPort}/v1`;
59
+ }
60
+ /** Start listening. Scans ports [port .. port+10] to avoid collisions. */
61
+ async start() {
62
+ const host = this.opts.host ?? '127.0.0.1';
63
+ const first = this.opts.port ?? 47915;
64
+ let lastErr;
65
+ for (let candidate = first; candidate <= first + 10; candidate++) {
66
+ try {
67
+ await this.listen(host, candidate);
68
+ this.boundPort = candidate;
69
+ this.opts.logger.info(`[gateway] OpenAI-compatible endpoint on ${host}:${candidate} (${this.table.size} providers)`);
70
+ return;
71
+ }
72
+ catch (err) {
73
+ lastErr = err;
74
+ if (err.code !== 'EADDRINUSE')
75
+ throw err;
76
+ }
77
+ }
78
+ throw lastErr ?? new Error('gateway: no free port');
79
+ }
80
+ listen(host, port) {
81
+ return new Promise((resolve, reject) => {
82
+ const server = createServer((req, res) => void this.handle(req, res));
83
+ server.once('error', reject);
84
+ server.listen(port, host, () => {
85
+ server.off('error', reject);
86
+ this.server = server;
87
+ resolve();
88
+ });
89
+ });
90
+ }
91
+ async stop() {
92
+ if (this.server) {
93
+ await new Promise(resolve => this.server.close(() => resolve()));
94
+ this.server = undefined;
95
+ }
96
+ }
97
+ // ── HTTP ────────────────────────────────────────────────────────────────────
98
+ async handle(req, res) {
99
+ const url = req.url ?? '/';
100
+ try {
101
+ if (req.method === 'GET' && (url.startsWith('/v1/models') || url === '/v1' || url === '/')) {
102
+ this.sendJson(res, 200, this.listModels());
103
+ return;
104
+ }
105
+ if (req.method === 'POST' && url.startsWith('/v1/chat/completions')) {
106
+ const body = JSON.parse(await this.readBody(req));
107
+ await this.handleChat(body, res);
108
+ return;
109
+ }
110
+ this.sendJson(res, 404, { error: { message: 'not found', type: 'invalid_request_error' } });
111
+ }
112
+ catch (err) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ this.opts.logger.warn(`[gateway] ${message}`);
115
+ if (!res.headersSent)
116
+ this.sendJson(res, 500, { error: { message, type: 'gateway_error' } });
117
+ else
118
+ try {
119
+ res.end();
120
+ }
121
+ catch { /* ignore */ }
122
+ }
123
+ }
124
+ listModels() {
125
+ const data = [
126
+ { id: 'auto', object: 'model', created: 0, owned_by: 'infinicode' },
127
+ ];
128
+ for (const p of this.table.values()) {
129
+ for (const m of p.models)
130
+ data.push({ id: `${p.id}/${m.id}`, object: 'model', created: 0, owned_by: p.id });
131
+ }
132
+ return { object: 'list', data };
133
+ }
134
+ // ── Chat: route → execute → escalate-on-error → relay ────────────────────────
135
+ async handleChat(body, res) {
136
+ const stream = body['stream'] === true;
137
+ const messages = Array.isArray(body['messages']) ? body['messages'] : [];
138
+ const estTokens = Math.ceil(JSON.stringify(messages).length / 4);
139
+ const requested = typeof body['model'] === 'string' ? body['model'] : '';
140
+ const tried = new Set();
141
+ let target = await this.resolveTarget(requested, estTokens, tried);
142
+ if (!target) {
143
+ this.sendJson(res, 503, { error: { message: 'no providers available', type: 'gateway_error' } });
144
+ return;
145
+ }
146
+ const controller = new AbortController();
147
+ res.on('close', () => controller.abort());
148
+ let lastStatus = 502;
149
+ let lastErr = 'no attempt made';
150
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS && target; attempt++) {
151
+ tried.add(`${target.providerId}/${target.model}`);
152
+ const upstream = { ...body, model: target.model, stream };
153
+ try {
154
+ const resp = await this.executor.execute(target, upstream, controller.signal);
155
+ if (resp.ok) {
156
+ this.opts.kernel.providerManager.recordSuccess(target.providerId);
157
+ await this.relay(resp, res, stream);
158
+ return;
159
+ }
160
+ lastStatus = resp.status;
161
+ lastErr = await resp.text().catch(() => `${resp.status}`);
162
+ this.recordError(target.providerId, resp.status, lastErr);
163
+ }
164
+ catch (err) {
165
+ lastStatus = 502;
166
+ lastErr = err instanceof Error ? err.message : String(err);
167
+ if (controller.signal.aborted)
168
+ return; // client hung up
169
+ this.recordError(target.providerId, 0, lastErr);
170
+ }
171
+ const next = await this.nextTarget(target, `${lastStatus} ${lastErr}`, estTokens, tried);
172
+ if (!next)
173
+ break;
174
+ this.emitSwitch(target, next, `${lastStatus} ${lastErr}`);
175
+ target = next;
176
+ }
177
+ if (!res.headersSent) {
178
+ this.sendJson(res, lastStatus >= 400 ? lastStatus : 502, {
179
+ error: { message: `all routes failed: ${lastErr}`, type: 'gateway_error' },
180
+ });
181
+ }
182
+ }
183
+ /** Stream bytes through untouched, or relay the JSON body for non-streaming. */
184
+ async relay(resp, res, stream) {
185
+ if (stream && resp.body) {
186
+ res.writeHead(200, {
187
+ 'content-type': resp.headers.get('content-type') ?? 'text/event-stream',
188
+ 'cache-control': 'no-cache',
189
+ connection: 'keep-alive',
190
+ });
191
+ const reader = resp.body.getReader();
192
+ for (;;) {
193
+ const { done, value } = await reader.read();
194
+ if (done)
195
+ break;
196
+ res.write(Buffer.from(value));
197
+ }
198
+ res.end();
199
+ return;
200
+ }
201
+ const text = await resp.text();
202
+ res.writeHead(resp.status, { 'content-type': resp.headers.get('content-type') ?? 'application/json' });
203
+ res.end(text);
204
+ }
205
+ // ── Routing helpers ──────────────────────────────────────────────────────────
206
+ /** Resolve the initial target: honor an explicit provider/model, else route. */
207
+ async resolveTarget(requested, estTokens, tried) {
208
+ const raw = requested.trim();
209
+ const isAuto = raw === '' || raw === 'auto' || raw === 'infinicode/auto' || raw.endsWith('/auto');
210
+ if (!isAuto) {
211
+ const slash = raw.indexOf('/');
212
+ if (slash > 0) {
213
+ const providerId = raw.slice(0, slash);
214
+ const modelId = raw.slice(slash + 1);
215
+ const gp = this.table.get(providerId);
216
+ if (gp)
217
+ return this.toTarget(gp, modelId);
218
+ }
219
+ // bare model id — find its owner
220
+ for (const gp of this.table.values()) {
221
+ if (gp.models.some(m => m.id === raw))
222
+ return this.toTarget(gp, raw);
223
+ }
224
+ }
225
+ return this.routerPick(estTokens, tried);
226
+ }
227
+ /** Ask the kernel router for the best untried, executable model. */
228
+ async routerPick(estTokens, tried) {
229
+ const ranked = await this.rank(estTokens);
230
+ for (const d of ranked) {
231
+ const gp = this.table.get(d.providerId);
232
+ if (gp && !tried.has(`${d.providerId}/${d.modelId}`))
233
+ return this.toTarget(gp, d.modelId);
234
+ }
235
+ // last resort: first configured model not yet tried
236
+ for (const gp of this.table.values()) {
237
+ for (const m of gp.models) {
238
+ if (!tried.has(`${gp.id}/${m.id}`))
239
+ return this.toTarget(gp, m.id);
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+ /**
245
+ * Choose the next target after a failure. 429 / provider-down → rotate to a
246
+ * different provider; everything else (incl. Groq's tool_use_failed) →
247
+ * escalate to a stronger model via the kernel ladder, then fall back to rank.
248
+ */
249
+ async nextTarget(current, errText, estTokens, tried) {
250
+ const kind = this.opts.kernel.recovery.classify(errText);
251
+ const ranked = await this.rank(estTokens);
252
+ const usable = ranked.filter(d => this.table.has(d.providerId) && !tried.has(`${d.providerId}/${d.modelId}`));
253
+ if (kind === '429' || kind === 'provider-down') {
254
+ const diffProvider = usable.find(d => d.providerId !== current.providerId);
255
+ if (diffProvider)
256
+ return this.toTarget(this.table.get(diffProvider.providerId), diffProvider.modelId);
257
+ return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
258
+ }
259
+ // escalate: strictly stronger model than the current one
260
+ const esc = this.opts.kernel.router.escalate(await this.routingRequest(estTokens), current.providerId, current.model);
261
+ if (esc && this.table.has(esc.providerId) && !tried.has(`${esc.providerId}/${esc.modelId}`)) {
262
+ return this.toTarget(this.table.get(esc.providerId), esc.modelId);
263
+ }
264
+ return usable[0] ? this.toTarget(this.table.get(usable[0].providerId), usable[0].modelId) : null;
265
+ }
266
+ toTarget(gp, model) {
267
+ return { providerId: gp.id, baseURL: gp.baseURL, apiKey: gp.apiKey, headers: gp.headers, model };
268
+ }
269
+ async rank(estTokens) {
270
+ try {
271
+ return this.opts.kernel.router.rank(await this.routingRequest(estTokens));
272
+ }
273
+ catch {
274
+ return [];
275
+ }
276
+ }
277
+ async routingRequest(estTokens) {
278
+ const kernel = this.opts.kernel;
279
+ const now = Date.now();
280
+ if (!this.modelsCache || now - this.modelsCache.at > MODELS_TTL_MS) {
281
+ this.modelsCache = { at: now, models: await kernel.providerManager.getAvailableModels() };
282
+ }
283
+ const policy = kernel.policies.get(this.opts.policyName);
284
+ return {
285
+ capabilities: this.opts.capabilities ?? ['coding', 'reasoning'],
286
+ preferences: { reasoning: 88, speed: 70, cost: 'medium', contextLength: Math.max(16_000, estTokens * 2) },
287
+ policy,
288
+ availableProviders: kernel.providerManager.listInfos(),
289
+ availableModels: this.modelsCache.models,
290
+ estimatedRequestTokens: estTokens,
291
+ };
292
+ }
293
+ // ── Telemetry / health ───────────────────────────────────────────────────────
294
+ recordError(providerId, status, error) {
295
+ if (status === 429)
296
+ this.opts.kernel.providerManager.record429(providerId);
297
+ else
298
+ this.opts.kernel.providerManager.recordFailure(providerId);
299
+ this.opts.kernel.eventBus.emit({
300
+ type: 'RECOVERY',
301
+ timestamp: Date.now(),
302
+ data: { source: 'gateway', providerId, status, error: error.slice(0, 300) },
303
+ });
304
+ }
305
+ emitSwitch(from, to, reason) {
306
+ const bus = this.opts.kernel.eventBus;
307
+ if (from.providerId !== to.providerId) {
308
+ bus.emit({
309
+ type: 'PROVIDER_CHANGED',
310
+ timestamp: Date.now(),
311
+ data: { source: 'gateway', previousProviderId: from.providerId, providerId: to.providerId, reason: reason.slice(0, 200) },
312
+ });
313
+ }
314
+ bus.emit({
315
+ type: 'MODEL_CHANGED',
316
+ timestamp: Date.now(),
317
+ data: {
318
+ source: 'gateway',
319
+ previousModelId: `${from.providerId}/${from.model}`,
320
+ modelId: `${to.providerId}/${to.model}`,
321
+ providerId: to.providerId,
322
+ reason: reason.slice(0, 200),
323
+ },
324
+ });
325
+ this.opts.logger.info(`[gateway] ${from.providerId}/${from.model} → ${to.providerId}/${to.model}`);
326
+ }
327
+ // ── IO ────────────────────────────────────────────────────────────────────────
328
+ sendJson(res, status, body) {
329
+ res.writeHead(status, { 'content-type': 'application/json' });
330
+ res.end(JSON.stringify(body));
331
+ }
332
+ readBody(req) {
333
+ return new Promise((resolve, reject) => {
334
+ let data = '';
335
+ req.on('data', chunk => {
336
+ data += chunk;
337
+ if (data.length > 32_000_000)
338
+ reject(new Error('body too large'));
339
+ });
340
+ req.on('end', () => resolve(data));
341
+ req.on('error', reject);
342
+ });
343
+ }
344
+ }
@@ -37,6 +37,7 @@ export type { MissionPlannerDeps, PlanOptions } from './planner.js';
37
37
  export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.js';
38
38
  export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
39
39
  export * from './federation/index.js';
40
+ export * from './gateway/index.js';
40
41
  export { createMcpServer, serveMcpStdio } from './mcp/index.js';
41
42
  export type { McpServerDeps } from './mcp/index.js';
42
43
  export { GoalLoop } from './autonomy/index.js';
@@ -29,6 +29,8 @@ export { normalizeCapabilities, CANONICAL_CAPABILITIES } from './capability-map.
29
29
  export { phaseBenchmarkFloor, phaseLabel } from './scheduler.js';
30
30
  // Federation — neutral device mesh (peers, telemetry, config sync, MOLTFED interop)
31
31
  export * from './federation/index.js';
32
+ // Gateway — local OpenAI-compatible endpoint backed by kernel routing/recovery
33
+ export * from './gateway/index.js';
32
34
  // MCP — north-facing control surface (spawn/dispatch/role/voice/map over the mesh)
33
35
  export { createMcpServer, serveMcpStdio } from './mcp/index.js';
34
36
  // Autonomy — proactive unattended goal loop (interval/idle/once triggers)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.3.3",
3
+ "version": "2.3.5",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",