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.
- package/.opencode/plugins/home-logo-animation.tsx +5 -1
- package/.opencode/plugins/mesh-commands.tsx +4 -3
- package/.opencode/plugins/routing-mode-display.tsx +6 -2
- package/.opencode/tui.json +2 -1
- package/bin/robopark.js +2 -0
- package/dist/cli.js +46 -1
- package/dist/commands/maintain.d.ts +29 -0
- package/dist/commands/maintain.js +186 -0
- package/dist/commands/mcp.js +1 -1
- package/dist/commands/mesh-install.d.ts +10 -0
- package/dist/commands/mesh-install.js +160 -0
- package/dist/commands/robopark.d.ts +6 -0
- package/dist/commands/robopark.js +450 -0
- package/dist/commands/run.js +82 -22
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +152 -11
- package/dist/kernel/agents/backends/cli-backend.d.ts +41 -0
- package/dist/kernel/agents/backends/cli-backend.js +140 -0
- package/dist/kernel/agents/backends/detect.d.ts +17 -0
- package/dist/kernel/agents/backends/detect.js +140 -0
- package/dist/kernel/agents/backends/index.d.ts +11 -0
- package/dist/kernel/agents/backends/index.js +3 -0
- package/dist/kernel/agents/backends/parse-utils.d.ts +20 -0
- package/dist/kernel/agents/backends/parse-utils.js +72 -0
- package/dist/kernel/agents/backends/profiles.d.ts +67 -0
- package/dist/kernel/agents/backends/profiles.js +48 -0
- package/dist/kernel/agents/backends/registry.d.ts +31 -0
- package/dist/kernel/agents/backends/registry.js +174 -0
- package/dist/kernel/agents/backends/rotation.d.ts +51 -0
- package/dist/kernel/agents/backends/rotation.js +107 -0
- package/dist/kernel/agents/backends/types.d.ts +67 -0
- package/dist/kernel/agents/backends/types.js +1 -0
- package/dist/kernel/agents/index.d.ts +8 -0
- package/dist/kernel/agents/index.js +8 -0
- package/dist/kernel/federation/dashboard-html.d.ts +13 -0
- package/dist/kernel/federation/dashboard-html.js +371 -0
- package/dist/kernel/federation/federation.d.ts +112 -2
- package/dist/kernel/federation/federation.js +270 -10
- package/dist/kernel/federation/moltfed-adapter.js +1 -0
- package/dist/kernel/federation/telemetry.d.ts +1 -1
- package/dist/kernel/federation/telemetry.js +2 -1
- package/dist/kernel/federation/transport-http.d.ts +17 -1
- package/dist/kernel/federation/transport-http.js +65 -2
- package/dist/kernel/federation/types.d.ts +46 -1
- package/dist/kernel/free-providers.js +83 -0
- package/dist/kernel/gateway/openai-gateway.d.ts +55 -3
- package/dist/kernel/gateway/openai-gateway.js +428 -49
- package/dist/kernel/index.d.ts +2 -1
- package/dist/kernel/index.js +3 -1
- package/dist/kernel/logger.d.ts +16 -3
- package/dist/kernel/logger.js +38 -0
- package/dist/kernel/mcp/mcp-server.js +65 -10
- package/dist/kernel/orchestrator.d.ts +4 -0
- package/dist/kernel/orchestrator.js +34 -0
- package/dist/kernel/provider-manager.d.ts +13 -0
- package/dist/kernel/provider-manager.js +58 -5
- package/dist/kernel/providers/openai-compatible-provider.d.ts +6 -0
- package/dist/kernel/providers/openai-compatible-provider.js +22 -3
- package/dist/kernel/recovery-manager.js +55 -15
- package/dist/kernel/router.js +15 -2
- package/dist/kernel/types.d.ts +12 -2
- package/dist/robopark-cli.d.ts +2 -0
- package/dist/robopark-cli.js +23 -0
- package/package.json +6 -3
package/dist/commands/serve.js
CHANGED
|
@@ -11,10 +11,25 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import chalk from 'chalk';
|
|
13
13
|
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { dirname, join } from 'node:path';
|
|
16
17
|
import { buildKernelFromConfig, registerCloudProviders } from '../kernel/setup.js';
|
|
17
|
-
import { Federation, MoltfedConnector, loadOrCreateIdentity } from '../kernel/index.js';
|
|
18
|
+
import { Federation, MoltfedConnector, loadOrCreateIdentity, loadRole } from '../kernel/index.js';
|
|
19
|
+
import { runMaintenanceTask, loadLastMaintenanceTask } from './maintain.js';
|
|
20
|
+
/** ws(s):// gateway URL -> http(s):// base, for the plain-HTTP command poll
|
|
21
|
+
* (the WS RPC channel is fire-and-forget, no request/response tracking —
|
|
22
|
+
* simpler to poll a raw route than extend that channel to support it). */
|
|
23
|
+
function httpBaseFromWsUrl(wsUrl) {
|
|
24
|
+
try {
|
|
25
|
+
const u = new URL(wsUrl);
|
|
26
|
+
u.protocol = u.protocol === 'wss:' ? 'https:' : 'http:';
|
|
27
|
+
return u.origin;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
18
33
|
function pkgVersion() {
|
|
19
34
|
try {
|
|
20
35
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,12 +39,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
|
|
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
|
-
|
|
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
|
-
//
|
|
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,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;
|