infinicode 2.3.5 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) 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/bin/robopark.js +2 -0
  6. package/dist/cli.js +46 -1
  7. package/dist/commands/maintain.d.ts +29 -0
  8. package/dist/commands/maintain.js +186 -0
  9. package/dist/commands/mcp.js +1 -1
  10. package/dist/commands/mesh-install.d.ts +10 -0
  11. package/dist/commands/mesh-install.js +160 -0
  12. package/dist/commands/robopark.d.ts +6 -0
  13. package/dist/commands/robopark.js +450 -0
  14. package/dist/commands/run.js +82 -22
  15. package/dist/commands/serve.d.ts +1 -0
  16. package/dist/commands/serve.js +152 -11
  17. package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
  18. package/dist/kernel/agents/backends/cli-backend.js +140 -0
  19. package/dist/kernel/agents/backends/detect.d.ts +17 -0
  20. package/dist/kernel/agents/backends/detect.js +140 -0
  21. package/dist/kernel/agents/backends/index.d.ts +11 -0
  22. package/dist/kernel/agents/backends/index.js +3 -0
  23. package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
  24. package/dist/kernel/agents/backends/parse-utils.js +72 -0
  25. package/dist/kernel/agents/backends/profiles.d.ts +67 -0
  26. package/dist/kernel/agents/backends/profiles.js +48 -0
  27. package/dist/kernel/agents/backends/registry.d.ts +31 -0
  28. package/dist/kernel/agents/backends/registry.js +174 -0
  29. package/dist/kernel/agents/backends/rotation.d.ts +51 -0
  30. package/dist/kernel/agents/backends/rotation.js +107 -0
  31. package/dist/kernel/agents/backends/types.d.ts +67 -0
  32. package/dist/kernel/agents/backends/types.js +1 -0
  33. package/dist/kernel/agents/index.d.ts +8 -0
  34. package/dist/kernel/agents/index.js +8 -0
  35. package/dist/kernel/federation/dashboard-html.d.ts +13 -0
  36. package/dist/kernel/federation/dashboard-html.js +371 -0
  37. package/dist/kernel/federation/federation.d.ts +112 -2
  38. package/dist/kernel/federation/federation.js +270 -10
  39. package/dist/kernel/federation/moltfed-adapter.js +1 -0
  40. package/dist/kernel/federation/telemetry.d.ts +1 -1
  41. package/dist/kernel/federation/telemetry.js +2 -1
  42. package/dist/kernel/federation/transport-http.d.ts +17 -1
  43. package/dist/kernel/federation/transport-http.js +65 -2
  44. package/dist/kernel/federation/types.d.ts +46 -1
  45. package/dist/kernel/free-providers.js +83 -0
  46. package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
  47. package/dist/kernel/gateway/openai-gateway.js +428 -49
  48. package/dist/kernel/index.d.ts +2 -1
  49. package/dist/kernel/index.js +3 -1
  50. package/dist/kernel/logger.d.ts +16 -3
  51. package/dist/kernel/logger.js +38 -0
  52. package/dist/kernel/mcp/mcp-server.js +65 -10
  53. package/dist/kernel/orchestrator.d.ts +4 -0
  54. package/dist/kernel/orchestrator.js +34 -0
  55. package/dist/kernel/provider-manager.d.ts +13 -0
  56. package/dist/kernel/provider-manager.js +58 -5
  57. package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
  58. package/dist/kernel/providers/openai-compatible-provider.js +22 -3
  59. package/dist/kernel/recovery-manager.js +55 -15
  60. package/dist/kernel/router.js +15 -2
  61. package/dist/kernel/types.d.ts +12 -2
  62. package/dist/robopark-cli.d.ts +2 -0
  63. package/dist/robopark-cli.js +23 -0
  64. package/package.json +6 -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,45 @@ 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
+ };
33
81
  const kernel = buildKernelFromConfig(config);
34
82
  const identity = loadOrCreateIdentity({ role, displayName: opts.name ?? fed.displayName });
35
83
  // Manifest inputs are read live from the kernel so the map shows real caps.
@@ -61,7 +109,26 @@ export async function serve(config, opts) {
61
109
  config.set('defaultModel', cfg.defaultModel);
62
110
  console.log(chalk.green(` ✓ sourced shared config from hub (rev ${cfg.revision}) — ${live} provider(s) live`));
63
111
  };
64
- const federation = new Federation({
112
+ // Hub/peer/relay: publish this machine's providers + policy so satellites can
113
+ // auto-source them. Re-publish when the local config changes (live).
114
+ const sharedFromConf = () => ({
115
+ cloudProviders: config.get('cloudProviders'),
116
+ policy: config.get('policy'),
117
+ defaultModel: config.get('defaultModel'),
118
+ });
119
+ // Dashboard "Apply config to fleet" / POST /fed/apply → re-publish shared
120
+ // config to every connected satellite right now (cross-process nudge: the
121
+ // serve daemon holds the mesh; the CLI can't reach satellites directly).
122
+ let federation;
123
+ const onApply = () => {
124
+ if (role === 'satellite') {
125
+ return { ok: false, text: 'this node is a satellite — it sources config from its hub, it does not publish.' };
126
+ }
127
+ const cfg = federation.publishConfig(sharedFromConf());
128
+ const n = federation.peers().filter(p => p.role === 'satellite').length;
129
+ return { ok: true, text: `published config rev ${cfg.revision} to the fleet (${n} satellite peer${n === 1 ? '' : 's'})` };
130
+ };
131
+ federation = new Federation({
65
132
  kernel,
66
133
  identity,
67
134
  describe,
@@ -75,17 +142,13 @@ export async function serve(config, opts) {
75
142
  autoUpdate: opts.autoUpdate ?? fed.autoUpdate,
76
143
  applyConfig,
77
144
  autoPullConfig: role === 'satellite',
145
+ dashboard: opts.dashboard,
146
+ onApply,
147
+ commandGate,
78
148
  },
79
149
  });
80
150
  // Let the kernel's slash-command surface reach the mesh (/nodes, /node, …).
81
151
  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
152
  if (role !== 'satellite') {
90
153
  federation.publishConfig(sharedFromConf());
91
154
  for (const key of ['cloudProviders', 'policy', 'defaultModel']) {
@@ -101,6 +164,15 @@ export async function serve(config, opts) {
101
164
  console.log(` auth: ${chalk.green('token required')}`);
102
165
  console.log();
103
166
  await federation.start();
167
+ // Control dashboard link — prefer a reachable host over the 0.0.0.0 wildcard.
168
+ if (opts.dashboard) {
169
+ const uiHost = isLoopback(bindHost) || bindHost === '0.0.0.0' ? 'localhost' : bindHost;
170
+ const url = `http://${uiHost}:${port}/${token ? `?token=${token}` : ''}`;
171
+ console.log(chalk.bold(` dashboard: ${chalk.cyan(url)}`));
172
+ if (!token)
173
+ console.log(chalk.dim(' (no token — anyone who can reach this port can control the fleet; add --token to lock it)'));
174
+ console.log();
175
+ }
104
176
  // Connect to seeds. Satellites auto-source shared config from any hub/relay
105
177
  // they connect to (handled inside federation.connect()).
106
178
  for (const url of seeds) {
@@ -119,7 +191,13 @@ export async function serve(config, opts) {
119
191
  console.log(chalk.green(` ✓ LAN auto-discovery on (udp broadcast) — peers on this subnet connect automatically`));
120
192
  }
121
193
  // Optional: log this node into an InfiniBot gateway so it renders live on the
122
- // neural-mesh map + hardware panels (no InfiniBot changes needed).
194
+ // fleet map + hardware panels via the infinibot-plugin-infinicode plugin.
195
+ // NOTE: these method names target that plugin's registered gateway methods,
196
+ // NOT InfiniBot's core nodes.status/hardware.snapshot/agent.event — those
197
+ // are claimed for InfiniBot's own paired-device list and internal satellite
198
+ // protocol respectively, and agent.event doesn't exist on the core gateway
199
+ // at all. Without the plugin installed on the target gateway, these pushes
200
+ // are harmless no-ops (unknown method).
123
201
  let connector;
124
202
  if (opts.gateway) {
125
203
  connector = new MoltfedConnector({
@@ -131,12 +209,75 @@ export async function serve(config, opts) {
131
209
  getNodeStatuses: () => federation.nodeStatuses(),
132
210
  getHardware: () => federation.latestHardware(),
133
211
  subscribeFrames: (cb) => federation.onFrames(cb),
212
+ methodNodes: 'infinicode.push.status',
213
+ methodHardware: 'infinicode.push.hardware',
214
+ methodAgent: 'infinicode.push.event',
134
215
  });
135
216
  connector.start();
136
217
  console.log(chalk.green(` ✓ linking to InfiniBot gateway ${opts.gateway}`));
137
218
  }
219
+ // Optional: poll for on-demand commands queued from the InfiniBot Fleet
220
+ // view (e.g. "run maintenance now"). Plain HTTP GET against the plugin's
221
+ // own route — separate from the WS push channel above, which has no
222
+ // request/response tracking. No-op (empty list) until the operator
223
+ // actually queues something, so this is cheap to leave running.
224
+ let commandPoll;
225
+ const httpBase = opts.gateway ? httpBaseFromWsUrl(opts.gateway) : null;
226
+ if (httpBase) {
227
+ const pollUrl = `${httpBase}/plugins/infinicode/commands?nodeId=${encodeURIComponent(identity.nodeId)}`;
228
+ commandPoll = setInterval(() => {
229
+ void (async () => {
230
+ let commands;
231
+ try {
232
+ const res = await fetch(pollUrl, { signal: AbortSignal.timeout(5000) });
233
+ if (!res.ok)
234
+ return;
235
+ const body = await res.json();
236
+ commands = body.commands ?? [];
237
+ }
238
+ catch {
239
+ return; // gateway/plugin unreachable this cycle — try again next tick
240
+ }
241
+ if (!commands.length)
242
+ return;
243
+ // The trigger carries no task content by design — replay ONLY the
244
+ // task this device already ran locally (cron/CLI), never anything
245
+ // network-supplied. See loadLastMaintenanceTask.
246
+ const cached = loadLastMaintenanceTask();
247
+ for (const cmd of commands) {
248
+ if (cmd.kind !== 'run-last-maintenance')
249
+ continue;
250
+ if (!cached) {
251
+ console.log(chalk.yellow(' ⚠ "run now" received but no maintenance task has ever run on this device — run `infinicode maintain "..."` once first'));
252
+ connector?.request('infinicode.push.report', {
253
+ nodeId: identity.nodeId,
254
+ role: loadRole()?.role ?? identity.nodeId,
255
+ site: loadRole()?.site,
256
+ taskName: 'run-now (no task configured)',
257
+ startedAt: Date.now(),
258
+ finishedAt: Date.now(),
259
+ durationMs: 0,
260
+ ok: false,
261
+ summary: 'no maintenance task has run on this device yet — run `infinicode maintain "..."` once to establish one',
262
+ });
263
+ continue;
264
+ }
265
+ console.log(chalk.cyan(` ▸ re-running last maintenance task: ${cached.taskName ?? cached.taskPrompt.slice(0, 60)}`));
266
+ const report = await runMaintenanceTask(kernel, cached.taskPrompt, {
267
+ nodeId: identity.nodeId,
268
+ role: loadRole(),
269
+ taskName: cached.taskName,
270
+ });
271
+ connector?.request('infinicode.push.report', report);
272
+ console.log(report.ok ? chalk.green(' ✓ done') : chalk.red(' ✗ failed'));
273
+ }
274
+ })();
275
+ }, 10_000);
276
+ }
138
277
  const shutdown = async () => {
139
278
  console.log(chalk.dim('\n stopping mesh…'));
279
+ if (commandPoll)
280
+ clearInterval(commandPoll);
140
281
  connector?.stop();
141
282
  await federation.stop();
142
283
  kernel.destroy();
@@ -0,0 +1,41 @@
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
+ /** 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;
36
+ available(): boolean;
37
+ private extraArgs;
38
+ run(task: BackendTask, ctx: BackendRunContext): Promise<BackendRunResult>;
39
+ send(sessionId: string, message: string, ctx: BackendRunContext): Promise<BackendRunResult>;
40
+ private exec;
41
+ }
@@ -0,0 +1,140 @@
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, resolveSpawnTarget } 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
+ /** 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
+ }
36
+ available() {
37
+ return this.resolveBin() !== null;
38
+ }
39
+ extraArgs() {
40
+ const raw = this.spec.extraArgsEnv ? process.env[this.spec.extraArgsEnv] : undefined;
41
+ return raw ? raw.split(/\s+/).filter(Boolean) : [];
42
+ }
43
+ async run(task, ctx) {
44
+ const prompt = task.prompt && ctx.rolePreamble ? `${ctx.rolePreamble}\n\n${task.prompt}` : task.prompt;
45
+ const argv = task.sessionId && this.spec.resumeArgs
46
+ ? this.spec.resumeArgs(task.sessionId, prompt, task)
47
+ : this.spec.runArgs(prompt, task);
48
+ return this.exec([...argv, ...(task.args ?? []), ...this.extraArgs()], ctx);
49
+ }
50
+ async send(sessionId, message, ctx) {
51
+ const msg = ctx.rolePreamble ? `${ctx.rolePreamble}\n\n${message}` : message;
52
+ const task = { prompt: msg, sessionId };
53
+ const argv = this.spec.resumeArgs
54
+ ? this.spec.resumeArgs(sessionId, msg, task)
55
+ : this.spec.runArgs(msg, task);
56
+ return this.exec([...argv, ...this.extraArgs()], ctx);
57
+ }
58
+ exec(argv, ctx) {
59
+ return new Promise(resolve => {
60
+ const target = this.resolveSpawn();
61
+ if (!target) {
62
+ resolve({ status: 'failed', error: `${this.spec.bin} not found on PATH` });
63
+ return;
64
+ }
65
+ const child = spawn(target.command, [...target.prefixArgs, ...argv], {
66
+ cwd: ctx.cwd ?? process.cwd(),
67
+ env: { ...process.env, ...this.spec.env },
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ windowsHide: true,
70
+ });
71
+ const acc = {};
72
+ let stdoutBuf = '';
73
+ let rawAll = '';
74
+ let stderrTail = '';
75
+ let settled = false;
76
+ const onAbort = () => {
77
+ try {
78
+ child.kill();
79
+ }
80
+ catch { /* already gone */ }
81
+ };
82
+ ctx.signal?.addEventListener('abort', onAbort, { once: true });
83
+ const finish = (result) => {
84
+ if (settled)
85
+ return;
86
+ settled = true;
87
+ ctx.signal?.removeEventListener('abort', onAbort);
88
+ resolve(result);
89
+ };
90
+ child.stdout.setEncoding('utf8');
91
+ child.stdout.on('data', (chunk) => {
92
+ rawAll += chunk;
93
+ stdoutBuf += chunk;
94
+ let nl;
95
+ while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
96
+ const line = stdoutBuf.slice(0, nl).replace(/\r$/, '');
97
+ stdoutBuf = stdoutBuf.slice(nl + 1);
98
+ if (!line.trim())
99
+ continue;
100
+ try {
101
+ this.spec.parseLine(line, acc);
102
+ }
103
+ catch { /* tolerant parsing */ }
104
+ ctx.onFrame({ kind: 'log', text: line });
105
+ }
106
+ });
107
+ child.stderr.setEncoding('utf8');
108
+ child.stderr.on('data', (chunk) => {
109
+ stderrTail = (stderrTail + chunk).slice(-2000);
110
+ const trimmed = chunk.replace(/\s+$/, '');
111
+ if (trimmed)
112
+ ctx.onFrame({ kind: 'log', text: trimmed });
113
+ });
114
+ child.on('error', err => finish({ status: 'failed', error: err.message }));
115
+ child.on('close', code => {
116
+ // Flush a trailing partial line.
117
+ if (stdoutBuf.trim()) {
118
+ try {
119
+ this.spec.parseLine(stdoutBuf.trim(), acc);
120
+ }
121
+ catch { /* ignore */ }
122
+ }
123
+ // Fallback for CLIs that pretty-print one multi-line JSON object.
124
+ if (!acc.content && !acc.sessionId && rawAll.trim()) {
125
+ try {
126
+ this.spec.parseLine(rawAll.trim(), acc);
127
+ }
128
+ catch { /* ignore */ }
129
+ }
130
+ const ok = !acc.isError && (code === 0 || !!acc.content);
131
+ finish({
132
+ status: ok ? 'completed' : 'failed',
133
+ sessionId: acc.sessionId,
134
+ content: acc.content,
135
+ error: ok ? undefined : (stderrTail.trim() || acc.content || `${this.spec.bin} exited with code ${code}`),
136
+ });
137
+ });
138
+ });
139
+ }
140
+ }
@@ -0,0 +1,17 @@
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;
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;
@@ -0,0 +1,140 @@
1
+ /**
2
+ * OpenKernel — Executable detection
3
+ *
4
+ * Dependency-free `which`: scan PATH (honoring PATHEXT on Windows) so a backend
5
+ * can advertise availability without spawning `--version`. Results are cached —
6
+ * a device's installed CLIs don't change mid-process.
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join, delimiter, dirname } from 'node:path';
10
+ const cache = new Map();
11
+ /** Absolute path to `bin` on PATH, or null if not found. */
12
+ export function findExecutable(bin) {
13
+ const cached = cache.get(bin);
14
+ if (cached !== undefined)
15
+ return cached;
16
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
17
+ const exts = process.platform === 'win32'
18
+ ? ['', ...(process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)]
19
+ : [''];
20
+ for (const dir of dirs) {
21
+ for (const ext of exts) {
22
+ for (const cased of new Set([ext, ext.toLowerCase(), ext.toUpperCase()])) {
23
+ const full = join(dir, bin + cased);
24
+ if (existsSync(full)) {
25
+ cache.set(bin, full);
26
+ return full;
27
+ }
28
+ }
29
+ }
30
+ }
31
+ cache.set(bin, null);
32
+ return null;
33
+ }
34
+ export function hasExecutable(bin) {
35
+ return findExecutable(bin) !== null;
36
+ }
37
+ /** Test hook — forget cached lookups. */
38
+ export function clearExecutableCache() {
39
+ cache.clear();
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,11 @@
1
+ /**
2
+ * OpenKernel — Agent Backends public API
3
+ *
4
+ * Pluggable executors for dispatched mesh tasks: the native kernel plus
5
+ * third-party coding CLIs (Claude Code / Codex / OpenCode).
6
+ */
7
+ export type { AgentBackend, BackendId, BackendTask, BackendFrame, BackendRunContext, BackendRunResult, } from './types.js';
8
+ export { CliAgentBackend } from './cli-backend.js';
9
+ export type { CliBackendSpec, CliParsed } from './cli-backend.js';
10
+ export { AgentBackendRegistry, createDefaultBackendRegistry, CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC, } from './registry.js';
11
+ export { findExecutable, hasExecutable, clearExecutableCache } from './detect.js';
@@ -0,0 +1,3 @@
1
+ export { CliAgentBackend } from './cli-backend.js';
2
+ export { AgentBackendRegistry, createDefaultBackendRegistry, CLAUDE_SPEC, OPENCODE_SPEC, CODEX_SPEC, } from './registry.js';
3
+ export { findExecutable, hasExecutable, clearExecutableCache } from './detect.js';
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OpenKernel — Backend output parsing helpers
3
+ *
4
+ * Third-party CLIs emit different JSON shapes (and different versions change
5
+ * them), so parsing is deliberately defensive: try JSON, pull the common
6
+ * fields, and always fall back to raw text. Nothing here throws.
7
+ */
8
+ export declare function tryJson(line: string): Record<string, unknown> | null;
9
+ export declare function str(v: unknown): string | undefined;
10
+ /** Append a line to an accumulating text body. */
11
+ export declare function appendLine(acc: string | undefined, line: string): string;
12
+ /**
13
+ * Pull assistant text out of a wide range of JSON event shapes:
14
+ * Anthropic/Claude content arrays, plain `content` strings, and the usual
15
+ * `text`/`response`/`result`/`message` fields other CLIs use.
16
+ */
17
+ export declare function extractText(obj: Record<string, unknown>): string | undefined;
18
+ /** Common session-id keys across the CLIs, plus a bare-UUID fallback. */
19
+ export declare function extractSessionId(obj: Record<string, unknown>): string | undefined;
20
+ export declare function sessionIdFromText(line: string): string | undefined;