@tbrandenburg/node-red-agents 0.1.1
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/LICENSE +21 -0
- package/README.md +34 -0
- package/nodes/agent/agent.html +550 -0
- package/nodes/agent/agent.js +396 -0
- package/nodes/agent/icons/agent.svg +27 -0
- package/nodes/agent/lib/agents/base.js +42 -0
- package/nodes/agent/lib/agents/opencode.js +141 -0
- package/nodes/agent/lib/agents/pi.js +220 -0
- package/nodes/agent/lib/execution/lifecycle.js +69 -0
- package/nodes/agent/lib/execution/scheduler.js +97 -0
- package/nodes/agent/lib/execution/status.js +24 -0
- package/nodes/agent/lib/mcp/normalize.js +31 -0
- package/nodes/agent/lib/runtimes/base.js +21 -0
- package/nodes/agent/lib/runtimes/direct.js +28 -0
- package/nodes/agent/lib/runtimes/process-exec.js +105 -0
- package/nodes/agent/lib/runtimes/srt.js +63 -0
- package/nodes/agent-server/agent-server.html +365 -0
- package/nodes/agent-server/agent-server.js +481 -0
- package/nodes/agent-server/icons/agent.svg +27 -0
- package/nodes/agent-server/lib/daemon.js +149 -0
- package/nodes/agent-server/lib/http.js +60 -0
- package/nodes/agent-server/lib/model.js +20 -0
- package/nodes/agent-server/lib/port.js +31 -0
- package/nodes/agent-server/lib/registry.js +77 -0
- package/nodes/agent-server/lib/status.js +15 -0
- package/nodes/gh/README.md +75 -0
- package/nodes/gh/examples/list-pull-requests.json +48 -0
- package/nodes/gh/examples/run-workflow.json +42 -0
- package/nodes/gh/gh.html +146 -0
- package/nodes/gh/gh.js +237 -0
- package/nodes/gh/icons/gh.svg +15 -0
- package/nodes/gh/lib/parse-args.js +67 -0
- package/package.json +60 -0
- package/shared/srt-settings.js +71 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { AgentAdapter } = require('./base');
|
|
6
|
+
|
|
7
|
+
// Maps pi's real `--mode json` event stream (verified empirically against
|
|
8
|
+
// pi 0.84.1 -- its own docs don't spell this out) onto the Agent node's
|
|
9
|
+
// generic event vocabulary. Deliberately coarser than pi's own granularity:
|
|
10
|
+
// text/thinking *_delta and *_start events are skipped to avoid flooding
|
|
11
|
+
// output 2 with one message per streamed token; only the completed chunk
|
|
12
|
+
// (*_end) is surfaced.
|
|
13
|
+
const TYPE_MAP = {
|
|
14
|
+
agent_start: 'started',
|
|
15
|
+
tool_execution_start: 'tool',
|
|
16
|
+
tool_execution_update: 'tool',
|
|
17
|
+
tool_execution_end: 'tool',
|
|
18
|
+
turn_end: 'progress',
|
|
19
|
+
agent_end: 'completed'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// pi's project-level skill/prompt-template directories, per this
|
|
23
|
+
// installation's ~/.pi/agent/settings.json ("skills": [".github/skills"],
|
|
24
|
+
// "prompts": [".github/prompts"]). Used only as a *resolution convention*
|
|
25
|
+
// for turning the generic Agent node's bare invocationName into a path --
|
|
26
|
+
// pi itself doesn't auto-discover these for a single non-interactive run
|
|
27
|
+
// (verified: without an explicit --skill/--prompt-template flag, the model
|
|
28
|
+
// has no idea the file exists and will try to go hunting for it with its
|
|
29
|
+
// own tools instead).
|
|
30
|
+
const RESOURCE_DIRS = {
|
|
31
|
+
skill: '.github/skills',
|
|
32
|
+
command: '.github/prompts'
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function resolveResourcePath(name, kind, cwd) {
|
|
36
|
+
const base = cwd || process.cwd();
|
|
37
|
+
const candidates = [];
|
|
38
|
+
|
|
39
|
+
if (path.isAbsolute(name)) {
|
|
40
|
+
candidates.push(name);
|
|
41
|
+
} else if (name.includes('/') || name.endsWith('.md')) {
|
|
42
|
+
candidates.push(path.join(base, name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const dir = RESOURCE_DIRS[kind];
|
|
46
|
+
candidates.push(path.join(base, dir, name, 'SKILL.md'));
|
|
47
|
+
candidates.push(path.join(base, dir, `${name}.md`));
|
|
48
|
+
|
|
49
|
+
for (const candidate of candidates) {
|
|
50
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`could not find a ${kind} named "${name}" (looked for: ${candidates.join(', ')})`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class PiAdapter extends AgentAdapter {
|
|
56
|
+
validate(resolved) {
|
|
57
|
+
if (resolved.cwd) {
|
|
58
|
+
let stat;
|
|
59
|
+
try {
|
|
60
|
+
stat = fs.statSync(resolved.cwd);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
throw new Error(`cwd does not exist: ${resolved.cwd}`);
|
|
63
|
+
}
|
|
64
|
+
if (!stat.isDirectory()) {
|
|
65
|
+
throw new Error(`cwd is not a directory: ${resolved.cwd}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (resolved.invocation === 'prompt') {
|
|
70
|
+
if (!resolved.prompt || !String(resolved.prompt).trim()) {
|
|
71
|
+
throw new Error('prompt invocation requires a non-empty prompt (msg.payload or the Prompt field)');
|
|
72
|
+
}
|
|
73
|
+
} else if (resolved.invocation === 'skill' || resolved.invocation === 'command') {
|
|
74
|
+
if (!resolved.invocationName || !String(resolved.invocationName).trim()) {
|
|
75
|
+
throw new Error(`${resolved.invocation} invocation requires a non-empty name`);
|
|
76
|
+
}
|
|
77
|
+
// Throws its own clear error if nothing matches -- fail before
|
|
78
|
+
// spawning anything, per the adapter contract.
|
|
79
|
+
resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
|
|
80
|
+
} else {
|
|
81
|
+
throw new Error(`unknown invocation mode: ${resolved.invocation}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'the pi adapter does not support MCP servers (no MCP CLI/config surface was found in `pi --help`); ' +
|
|
87
|
+
'remove the configured MCP servers or switch Agent to OpenCode'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (resolved.sessionID) {
|
|
92
|
+
// Every pi invocation is built with --no-session (see
|
|
93
|
+
// buildExecution) -- deliberately ephemeral, matching the
|
|
94
|
+
// "one CLI process = one execution" model this adapter was
|
|
95
|
+
// verified against. Silently accepting a sessionID here would
|
|
96
|
+
// imply continuation that never actually happens.
|
|
97
|
+
throw new Error(
|
|
98
|
+
'the pi adapter does not support session continuation through this node (every pi run uses ' +
|
|
99
|
+
'--no-session); leave Session ID blank or switch Agent to OpenCode'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
buildExecution(resolved) {
|
|
105
|
+
const args = ['--no-session', '--mode', 'json'];
|
|
106
|
+
|
|
107
|
+
if (resolved.model) args.push('--model', String(resolved.model));
|
|
108
|
+
|
|
109
|
+
// pi has no permission-prompt system to bypass in non-interactive
|
|
110
|
+
// mode -- read/bash/edit/write tools just run immediately with no
|
|
111
|
+
// confirmation of any kind (verified: an unrestricted run had the
|
|
112
|
+
// model shell out to `find /` on its own with zero gating). The
|
|
113
|
+
// closest analogue to opencode's --auto is restricting which
|
|
114
|
+
// tools are even available: "not auto" -> read-only tool set,
|
|
115
|
+
// "auto" -> everything. This is an approximation, not a true
|
|
116
|
+
// permission bypass -- documented in the node's help text.
|
|
117
|
+
if (!resolved.auto) {
|
|
118
|
+
args.push('--tools', 'read,grep,find,ls');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let message;
|
|
122
|
+
if (resolved.invocation === 'prompt') {
|
|
123
|
+
message = String(resolved.prompt);
|
|
124
|
+
} else {
|
|
125
|
+
const resourcePath = resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
|
|
126
|
+
const flag = resolved.invocation === 'skill' ? '--skill' : '--prompt-template';
|
|
127
|
+
args.push(flag, resourcePath);
|
|
128
|
+
|
|
129
|
+
// pi has no deterministic slash-command dispatch like
|
|
130
|
+
// opencode's --command (verified: without this explicit
|
|
131
|
+
// instruction the model never used the loaded skill/template
|
|
132
|
+
// on its own). Spelling out the path and arguments in plain
|
|
133
|
+
// language is what actually worked in testing.
|
|
134
|
+
const kind = resolved.invocation === 'skill' ? 'skill' : 'prompt template';
|
|
135
|
+
const argsText = resolved.args !== undefined && resolved.args !== null ? String(resolved.args) : '';
|
|
136
|
+
message = `Use the "${resolved.invocationName}" ${kind} at ${resourcePath}. ${argsText}`.trim();
|
|
137
|
+
}
|
|
138
|
+
args.push(message);
|
|
139
|
+
|
|
140
|
+
return { command: 'pi', args, env: {} };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
parseEvent(line) {
|
|
144
|
+
const trimmed = line.trim();
|
|
145
|
+
if (!trimmed) return null;
|
|
146
|
+
|
|
147
|
+
let raw;
|
|
148
|
+
try {
|
|
149
|
+
raw = JSON.parse(trimmed);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (raw.type === 'session') {
|
|
155
|
+
return { type: 'started', sessionID: raw.id, data: raw };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (raw.type === 'message_update' && raw.assistantMessageEvent) {
|
|
159
|
+
const inner = raw.assistantMessageEvent;
|
|
160
|
+
if (inner.type === 'text_end' || inner.type === 'thinking_end') {
|
|
161
|
+
return { type: 'agent', data: raw };
|
|
162
|
+
}
|
|
163
|
+
return null; // skip granular *_start/*_delta streaming noise
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const type = TYPE_MAP[raw.type];
|
|
167
|
+
if (!type) return null;
|
|
168
|
+
return { type, data: raw };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
parseResult(events, exitCode, signal, stderr) {
|
|
172
|
+
const raw = events.map((e) => e.data);
|
|
173
|
+
const sessionEvent = raw.find((e) => e.type === 'session');
|
|
174
|
+
const sessionID = sessionEvent ? sessionEvent.id : undefined;
|
|
175
|
+
const agentEnd = [...raw].reverse().find((e) => e.type === 'agent_end');
|
|
176
|
+
|
|
177
|
+
let payload = '';
|
|
178
|
+
let errorMessage;
|
|
179
|
+
|
|
180
|
+
if (agentEnd && Array.isArray(agentEnd.messages)) {
|
|
181
|
+
const lastAssistant = [...agentEnd.messages].reverse().find((m) => m.role === 'assistant');
|
|
182
|
+
if (lastAssistant) {
|
|
183
|
+
if (lastAssistant.stopReason === 'error') {
|
|
184
|
+
// pi does NOT set a non-zero exit code for a model/API
|
|
185
|
+
// error (verified: exit 0 with stopReason:"error" deep
|
|
186
|
+
// in the event stream) -- this is the only reliable
|
|
187
|
+
// signal, unlike opencode's {"type":"error"} + exit code.
|
|
188
|
+
errorMessage = lastAssistant.errorMessage || 'pi reported an error';
|
|
189
|
+
} else if (Array.isArray(lastAssistant.content)) {
|
|
190
|
+
payload = lastAssistant.content
|
|
191
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
192
|
+
.map((c) => c.text)
|
|
193
|
+
.join('\n')
|
|
194
|
+
.trim();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (errorMessage) {
|
|
200
|
+
return { payload, sessionID, status: 'failed', errorMessage };
|
|
201
|
+
}
|
|
202
|
+
if (signal) {
|
|
203
|
+
return { payload, sessionID, status: 'failed', errorMessage: `process killed by signal ${signal}` };
|
|
204
|
+
}
|
|
205
|
+
if (exitCode !== 0) {
|
|
206
|
+
return {
|
|
207
|
+
payload,
|
|
208
|
+
sessionID,
|
|
209
|
+
status: 'failed',
|
|
210
|
+
errorMessage: `exited with code ${exitCode}${stderr ? ': ' + String(stderr).trim() : ''}`
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (!agentEnd) {
|
|
214
|
+
return { payload, sessionID, status: 'failed', errorMessage: 'pi produced no agent_end event' };
|
|
215
|
+
}
|
|
216
|
+
return { payload, sessionID, status: 'completed' };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { PiAdapter, resolveResourcePath };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Framework-agnostic orchestrator: given an adapter, a runtime, and an
|
|
4
|
+
// already-resolved (typed-input-evaluated) input object, runs one agent
|
|
5
|
+
// execution end to end. Deliberately has no Node-RED dependency so it can
|
|
6
|
+
// be unit-tested with fake adapters/runtimes (see test/execution/).
|
|
7
|
+
//
|
|
8
|
+
// `resolved` shape (built by agent.js from node config + msg):
|
|
9
|
+
// {
|
|
10
|
+
// invocation: 'prompt' | 'skill' | 'command',
|
|
11
|
+
// prompt, name, args, // per invocation mode
|
|
12
|
+
// cwd, model, auto,
|
|
13
|
+
// timeoutMs,
|
|
14
|
+
// mcpServers: []
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// `callbacks`:
|
|
18
|
+
// onEvent(event) -- called for every parsed stdout event (output 2)
|
|
19
|
+
// onStatus(status) -- 'running' | 'completed' | 'failed' | 'timeout'
|
|
20
|
+
async function runAgent({ adapter, runtime, resolved, executionId, onEvent, onStatus }) {
|
|
21
|
+
adapter.validate(resolved); // throws synchronously on bad config
|
|
22
|
+
|
|
23
|
+
const built = adapter.buildExecution(resolved);
|
|
24
|
+
const executionRequest = {
|
|
25
|
+
id: executionId,
|
|
26
|
+
command: built.command,
|
|
27
|
+
args: built.args,
|
|
28
|
+
cwd: resolved.cwd || undefined,
|
|
29
|
+
env: Object.assign({}, process.env, built.env || {}),
|
|
30
|
+
timeoutMs: resolved.timeoutMs || undefined
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const events = [];
|
|
34
|
+
const startedAt = Date.now();
|
|
35
|
+
if (onStatus) onStatus('running');
|
|
36
|
+
|
|
37
|
+
const outcome = await runtime.execute(executionRequest, {
|
|
38
|
+
onLine: (line) => {
|
|
39
|
+
let event;
|
|
40
|
+
try {
|
|
41
|
+
event = adapter.parseEvent(line);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// parseEvent must never crash the node; treat as ignorable.
|
|
44
|
+
event = null;
|
|
45
|
+
}
|
|
46
|
+
if (event) {
|
|
47
|
+
events.push(event);
|
|
48
|
+
if (onEvent) onEvent(event);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const durationMs = Date.now() - startedAt;
|
|
54
|
+
const result = adapter.parseResult(events, outcome.exitCode, outcome.signal, outcome.stderr);
|
|
55
|
+
const status = outcome.timedOut ? 'timeout' : result.status;
|
|
56
|
+
|
|
57
|
+
if (onStatus) onStatus(status);
|
|
58
|
+
|
|
59
|
+
return Object.assign({}, result, {
|
|
60
|
+
status,
|
|
61
|
+
exitCode: outcome.exitCode,
|
|
62
|
+
signal: outcome.signal,
|
|
63
|
+
timedOut: outcome.timedOut,
|
|
64
|
+
durationMs,
|
|
65
|
+
events
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { runAgent };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Framework-agnostic bounded-concurrency FIFO scheduler. Deliberately has no
|
|
4
|
+
// Node-RED dependency (same reasoning as lib/execution/lifecycle.js) so it's
|
|
5
|
+
// unit-testable in isolation.
|
|
6
|
+
//
|
|
7
|
+
// This is the entire "concurrency" feature: a plain array + Map, no worker
|
|
8
|
+
// threads, no external queue library. Node-RED's own message-per-invocation
|
|
9
|
+
// model already gives independent executions for free (see AGENTS fan-out
|
|
10
|
+
// spec); this class only adds the bound + the waiting line on top of that.
|
|
11
|
+
class ExecutionScheduler {
|
|
12
|
+
constructor({ concurrency, onStart, onQueued, onSettled } = {}) {
|
|
13
|
+
this.concurrency = Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 1;
|
|
14
|
+
this.onStart = onStart; // (item) => Promise -- required
|
|
15
|
+
this.onQueued = onQueued; // (item) => void -- optional
|
|
16
|
+
// Called after this item is removed from `active` AND after any
|
|
17
|
+
// queued item that became eligible to start has already been
|
|
18
|
+
// started -- i.e. the scheduler's own bookkeeping is fully
|
|
19
|
+
// settled, so a status render triggered from here is never stale.
|
|
20
|
+
this.onSettled = onSettled; // (item) => void -- optional
|
|
21
|
+
this.queue = [];
|
|
22
|
+
this.active = new Map();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get activeCount() {
|
|
26
|
+
return this.active.size;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get queuedCount() {
|
|
30
|
+
return this.queue.length;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
activeIds() {
|
|
34
|
+
return Array.from(this.active.keys());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// item must have a unique `executionId` property; anything else on it
|
|
38
|
+
// is opaque to the scheduler (agent.js stores msg/send/done/resolved).
|
|
39
|
+
submit(item) {
|
|
40
|
+
if (this.active.size < this.concurrency) {
|
|
41
|
+
this._start(item);
|
|
42
|
+
} else {
|
|
43
|
+
this.queue.push(item);
|
|
44
|
+
if (this.onQueued) this.onQueued(item);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_start(item) {
|
|
49
|
+
this.active.set(item.executionId, item);
|
|
50
|
+
Promise.resolve(this.onStart(item)).finally(() => {
|
|
51
|
+
this.active.delete(item.executionId);
|
|
52
|
+
this._advance();
|
|
53
|
+
if (this.onSettled) this.onSettled(item);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
_advance() {
|
|
58
|
+
while (this.active.size < this.concurrency && this.queue.length > 0) {
|
|
59
|
+
this._start(this.queue.shift());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Removes every still-queued item (FIFO order) without ever starting
|
|
64
|
+
// them, calling onCancel for each. Used on node close/redeploy so
|
|
65
|
+
// queued-but-not-yet-started messages get a clean done() instead of
|
|
66
|
+
// hanging forever. Does not touch active executions -- that's the
|
|
67
|
+
// caller's responsibility (terminate() belongs to the runtime layer).
|
|
68
|
+
drainQueue(onCancel) {
|
|
69
|
+
const remaining = this.queue.splice(0, this.queue.length);
|
|
70
|
+
if (onCancel) remaining.forEach((item) => onCancel(item));
|
|
71
|
+
return remaining;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// On-demand single-item cancellation (the `terminate` operation), as
|
|
75
|
+
// opposed to drainQueue's "everything, node is closing" semantics.
|
|
76
|
+
// Returns:
|
|
77
|
+
// { status: 'active', item } -- still running; caller must kill the
|
|
78
|
+
// actual process/runtime itself, this
|
|
79
|
+
// scheduler has no handle on that.
|
|
80
|
+
// { status: 'queued', item } -- removed from the queue before it ever
|
|
81
|
+
// started; caller must still settle the
|
|
82
|
+
// item's own done()/send() itself, same
|
|
83
|
+
// as drainQueue's onCancel.
|
|
84
|
+
// null -- unknown executionId (already finished,
|
|
85
|
+
// or never existed).
|
|
86
|
+
cancel(executionId) {
|
|
87
|
+
if (this.active.has(executionId)) {
|
|
88
|
+
return { status: 'active', item: this.active.get(executionId) };
|
|
89
|
+
}
|
|
90
|
+
const idx = this.queue.findIndex((item) => item.executionId === executionId);
|
|
91
|
+
if (idx === -1) return null;
|
|
92
|
+
const [item] = this.queue.splice(idx, 1);
|
|
93
|
+
return { status: 'queued', item };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { ExecutionScheduler };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure function computing the node.status() shape from current scheduler
|
|
4
|
+
// counts plus the last terminal outcome. Kept separate from agent.js so the
|
|
5
|
+
// "running/queued counts take precedence" rule (spec section 4) is
|
|
6
|
+
// unit-testable without a Node-RED runtime.
|
|
7
|
+
//
|
|
8
|
+
// lastTerminal: undefined (never run) | 'completed' | 'failed' | 'timeout'
|
|
9
|
+
// lastText: optional override for the failed-state text (e.g. 'bad config')
|
|
10
|
+
function computeNodeStatus({ active, queued, lastTerminal, lastText }) {
|
|
11
|
+
if (active > 0 || queued > 0) {
|
|
12
|
+
let text = `${active} running`;
|
|
13
|
+
if (queued > 0) text += ` \u00b7 ${queued} queued`;
|
|
14
|
+
return { fill: 'blue', shape: 'dot', text };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (lastTerminal === 'completed') return { fill: 'green', shape: 'dot', text: 'completed' };
|
|
18
|
+
if (lastTerminal === 'failed') return { fill: 'red', shape: 'ring', text: lastText || 'failed' };
|
|
19
|
+
if (lastTerminal === 'timeout') return { fill: 'yellow', shape: 'ring', text: 'timeout' };
|
|
20
|
+
|
|
21
|
+
return {}; // idle, never run -- no status
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { computeNodeStatus };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Generic mcpServers[] (see AGENTS node schema) -> OpenCode's keyed `mcp`
|
|
4
|
+
// config object, as verified against opencode's real config schema:
|
|
5
|
+
// { "<name>": { "type": "remote", "url": "...", "enabled": true } }
|
|
6
|
+
// { "<name>": { "type": "local", "command": ["npx", "-y", "pkg"], "enabled": true } }
|
|
7
|
+
//
|
|
8
|
+
// Each agent adapter owns its own translation; this module is OpenCode's.
|
|
9
|
+
function toOpenCodeMcp(mcpServers) {
|
|
10
|
+
const out = {};
|
|
11
|
+
if (!Array.isArray(mcpServers)) return out;
|
|
12
|
+
|
|
13
|
+
for (const server of mcpServers) {
|
|
14
|
+
if (!server || typeof server.name !== 'string' || !server.name.trim()) continue;
|
|
15
|
+
|
|
16
|
+
if (server.type === 'remote') {
|
|
17
|
+
if (typeof server.url !== 'string' || !server.url.trim()) continue;
|
|
18
|
+
out[server.name] = { type: 'remote', url: server.url, enabled: true };
|
|
19
|
+
} else if (server.type === 'local') {
|
|
20
|
+
if (typeof server.command !== 'string' || !server.command.trim()) continue;
|
|
21
|
+
const args = Array.isArray(server.args) ? server.args : [];
|
|
22
|
+
out[server.name] = { type: 'local', command: [server.command, ...args], enabled: true };
|
|
23
|
+
}
|
|
24
|
+
// Unknown types are silently skipped -- validate() at the adapter
|
|
25
|
+
// level is responsible for surfacing a clear error before execution.
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { toOpenCodeMcp };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Generic interface every runtime (Direct, SRT, and later Daytona/OpenShell)
|
|
4
|
+
// must implement. Agent adapters never depend on a runtime directly, and
|
|
5
|
+
// runtimes never depend on an agent adapter -- they only see the normalized
|
|
6
|
+
// executionRequest built by lib/execution/lifecycle.js.
|
|
7
|
+
class RuntimeProvider {
|
|
8
|
+
// executionRequest: { id, command, args, cwd, env, timeoutMs }
|
|
9
|
+
// handlers: { onLine(line: string), onExit(...) } -- onExit is not
|
|
10
|
+
// called directly by implementations; execute() resolves instead with
|
|
11
|
+
// { exitCode, signal, stderr, timedOut }.
|
|
12
|
+
async execute(_executionRequest, _handlers) {
|
|
13
|
+
throw new Error('RuntimeProvider.execute() not implemented');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async terminate(_executionId) {}
|
|
17
|
+
|
|
18
|
+
async cleanup(_executionId) {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { RuntimeProvider };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { RuntimeProvider } = require('./base');
|
|
4
|
+
const { runProcess, terminate } = require('./process-exec');
|
|
5
|
+
|
|
6
|
+
// The portability baseline: runs the agent CLI directly as a child process
|
|
7
|
+
// wherever it's on PATH (laptop, GitHub runner, Kubernetes container, ...).
|
|
8
|
+
class DirectRuntime extends RuntimeProvider {
|
|
9
|
+
async execute(executionRequest, handlers) {
|
|
10
|
+
return runProcess(
|
|
11
|
+
{
|
|
12
|
+
id: executionRequest.id,
|
|
13
|
+
cmd: executionRequest.command,
|
|
14
|
+
args: executionRequest.args,
|
|
15
|
+
cwd: executionRequest.cwd,
|
|
16
|
+
env: executionRequest.env,
|
|
17
|
+
timeoutMs: executionRequest.timeoutMs
|
|
18
|
+
},
|
|
19
|
+
handlers
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async terminate(executionId) {
|
|
24
|
+
terminate(executionId);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { DirectRuntime };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared spawn/timeout/kill logic used by both the Direct and SRT runtimes.
|
|
4
|
+
// Direct and SRT differ only in *which* command+args get spawned (see
|
|
5
|
+
// direct.js / srt.js) -- everything about process lifecycle, JSONL line
|
|
6
|
+
// buffering, and timeout handling lives here exactly once.
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const GRACE_PERIOD_MS = 2000;
|
|
10
|
+
|
|
11
|
+
// executionId -> { child, timeoutTimer, killTimer, timedOut }
|
|
12
|
+
const registry = new Map();
|
|
13
|
+
|
|
14
|
+
function killProcessGroup(child, signal) {
|
|
15
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
16
|
+
try {
|
|
17
|
+
// `detached: true` (see runProcess) gives the child its own process
|
|
18
|
+
// group with pgid === child.pid, so `-pid` reaches the whole tree
|
|
19
|
+
// (e.g. srt's bwrap wrapper + the agent CLI it spawns), leaving no
|
|
20
|
+
// orphans -- verified manually against a real `srt`-wrapped process.
|
|
21
|
+
process.kill(-child.pid, signal);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
try {
|
|
24
|
+
child.kill(signal);
|
|
25
|
+
} catch (_err) {
|
|
26
|
+
// Process likely already exited between the check above and here.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runProcess(executionRequest, handlers = {}) {
|
|
32
|
+
const { id, cmd, args, cwd, env, timeoutMs } = executionRequest;
|
|
33
|
+
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
let child;
|
|
36
|
+
try {
|
|
37
|
+
child = spawn(cmd, args, {
|
|
38
|
+
cwd: cwd || undefined,
|
|
39
|
+
env: env || process.env,
|
|
40
|
+
detached: true,
|
|
41
|
+
// Agent CLIs (opencode, srt-wrapped or not) wait on stdin if
|
|
42
|
+
// it's left open as a pipe; close it so they behave like a
|
|
43
|
+
// normal non-interactive invocation (see AGENTS.md).
|
|
44
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
reject(err);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const state = { child, timeoutTimer: null, killTimer: null, timedOut: false };
|
|
52
|
+
registry.set(id, state);
|
|
53
|
+
|
|
54
|
+
let lineBuffer = '';
|
|
55
|
+
let stderr = '';
|
|
56
|
+
|
|
57
|
+
child.stdout.on('data', (chunk) => {
|
|
58
|
+
lineBuffer += chunk.toString();
|
|
59
|
+
let idx;
|
|
60
|
+
while ((idx = lineBuffer.indexOf('\n')) >= 0) {
|
|
61
|
+
const line = lineBuffer.slice(0, idx);
|
|
62
|
+
lineBuffer = lineBuffer.slice(idx + 1);
|
|
63
|
+
if (handlers.onLine) handlers.onLine(line);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
child.stderr.on('data', (chunk) => {
|
|
67
|
+
stderr += chunk.toString();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
71
|
+
state.timeoutTimer = setTimeout(() => {
|
|
72
|
+
state.timedOut = true;
|
|
73
|
+
killProcessGroup(child, 'SIGTERM');
|
|
74
|
+
state.killTimer = setTimeout(() => killProcessGroup(child, 'SIGKILL'), GRACE_PERIOD_MS);
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
child.on('error', (err) => {
|
|
79
|
+
cleanupTimers(state);
|
|
80
|
+
registry.delete(id);
|
|
81
|
+
reject(err);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
child.on('close', (code, signal) => {
|
|
85
|
+
if (lineBuffer.length && handlers.onLine) handlers.onLine(lineBuffer);
|
|
86
|
+
cleanupTimers(state);
|
|
87
|
+
registry.delete(id);
|
|
88
|
+
resolve({ exitCode: code, signal, stderr, timedOut: state.timedOut, pid: child.pid });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cleanupTimers(state) {
|
|
94
|
+
if (state.timeoutTimer) clearTimeout(state.timeoutTimer);
|
|
95
|
+
if (state.killTimer) clearTimeout(state.killTimer);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function terminate(id) {
|
|
99
|
+
const state = registry.get(id);
|
|
100
|
+
if (!state) return;
|
|
101
|
+
killProcessGroup(state.child, 'SIGTERM');
|
|
102
|
+
state.killTimer = setTimeout(() => killProcessGroup(state.child, 'SIGKILL'), GRACE_PERIOD_MS);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { runProcess, terminate, killProcessGroup, GRACE_PERIOD_MS };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { RuntimeProvider } = require('./base');
|
|
4
|
+
const { runProcess, terminate } = require('./process-exec');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_BINARY = 'srt';
|
|
7
|
+
|
|
8
|
+
// Wraps execution in Anthropic's `srt` (sandbox-runtime) CLI, which is
|
|
9
|
+
// already installed on this host as a plain binary. Verified empirically
|
|
10
|
+
// (2026-08-13) that `srt`'s default invocation mode --
|
|
11
|
+
// srt [-s <settings>] <command> [args...]
|
|
12
|
+
// -- passes each argv element through to the sandboxed child literally,
|
|
13
|
+
// with NO shell re-interpretation (confirmed with payloads containing
|
|
14
|
+
// `;`, `&&`, quotes, backticks). Only `srt -c "<string>"` behaves like
|
|
15
|
+
// `sh -c` and would be unsafe with untrusted args -- that mode is
|
|
16
|
+
// deliberately never used here.
|
|
17
|
+
//
|
|
18
|
+
// Because of this, SRT is implemented as a thin argv-prefix transform on
|
|
19
|
+
// top of the exact same process-exec.js used by Direct: no new npm
|
|
20
|
+
// dependency, no shell-quoting step, no separate process lifecycle code.
|
|
21
|
+
class SrtRuntime extends RuntimeProvider {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
super();
|
|
24
|
+
this.binary = options.binary || DEFAULT_BINARY;
|
|
25
|
+
// Left undefined by default so `srt` falls back to its own default
|
|
26
|
+
// (~/.srt-settings.json) -- SRT policy is deliberately kept out of
|
|
27
|
+
// the Agent node's core schema (spec: "SRT-specific configuration
|
|
28
|
+
// should be hidden... implemented by the runtime adapter").
|
|
29
|
+
this.settingsPath = options.settingsPath || undefined;
|
|
30
|
+
this.extraArgs = Array.isArray(options.extraArgs) ? options.extraArgs : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
buildCommand(executionRequest) {
|
|
34
|
+
const flags = [];
|
|
35
|
+
if (this.settingsPath) flags.push('-s', this.settingsPath);
|
|
36
|
+
flags.push(...this.extraArgs);
|
|
37
|
+
return {
|
|
38
|
+
cmd: this.binary,
|
|
39
|
+
args: [...flags, executionRequest.command, ...executionRequest.args]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async execute(executionRequest, handlers) {
|
|
44
|
+
const { cmd, args } = this.buildCommand(executionRequest);
|
|
45
|
+
return runProcess(
|
|
46
|
+
{
|
|
47
|
+
id: executionRequest.id,
|
|
48
|
+
cmd,
|
|
49
|
+
args,
|
|
50
|
+
cwd: executionRequest.cwd,
|
|
51
|
+
env: executionRequest.env,
|
|
52
|
+
timeoutMs: executionRequest.timeoutMs
|
|
53
|
+
},
|
|
54
|
+
handlers
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async terminate(executionId) {
|
|
59
|
+
terminate(executionId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { SrtRuntime };
|