@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,481 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { findFreePort } = require('./lib/port');
|
|
5
|
+
const { InstanceRegistry } = require('./lib/registry');
|
|
6
|
+
const { spawnDaemon, waitForHealthy, killDaemon } = require('./lib/daemon');
|
|
7
|
+
const { computeNodeStatus } = require('./lib/status');
|
|
8
|
+
const { writeInlineSettingsFile } = require('../../shared/srt-settings');
|
|
9
|
+
const { request } = require('./lib/http');
|
|
10
|
+
const { parseModel } = require('./lib/model');
|
|
11
|
+
|
|
12
|
+
module.exports = function (RED) {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
function AgentServerNode(config) {
|
|
16
|
+
RED.nodes.createNode(this, config);
|
|
17
|
+
const node = this;
|
|
18
|
+
|
|
19
|
+
node.hostname = config.hostname || '127.0.0.1';
|
|
20
|
+
node.opencodeBinary = config.opencodeBinary || '';
|
|
21
|
+
|
|
22
|
+
node.operation = config.operation || 'message';
|
|
23
|
+
|
|
24
|
+
node.sessionIdProp = config.sessionIdProp !== undefined ? config.sessionIdProp : 'sessionID';
|
|
25
|
+
node.sessionIdPropType = config.sessionIdPropType || 'msg';
|
|
26
|
+
|
|
27
|
+
node.promptProp = config.promptProp !== undefined ? config.promptProp : 'payload';
|
|
28
|
+
node.promptPropType = config.promptPropType || 'msg';
|
|
29
|
+
|
|
30
|
+
node.model = config.model || '';
|
|
31
|
+
node.modelType = config.modelType || 'str';
|
|
32
|
+
|
|
33
|
+
node.startupTimeoutMs = Number(config.startupTimeoutMs) || 15000;
|
|
34
|
+
node.requestTimeoutMs = Number(config.requestTimeoutMs) || 120000;
|
|
35
|
+
|
|
36
|
+
const maxInstancesNum = parseInt(config.maxInstances, 10);
|
|
37
|
+
// 0 (or invalid/blank) means unlimited -- not everyone needs a cap,
|
|
38
|
+
// and 0 reads more naturally as "no limit" than as "allow zero".
|
|
39
|
+
node.maxInstances = Number.isFinite(maxInstancesNum) && maxInstancesNum > 0 ? maxInstancesNum : 0;
|
|
40
|
+
|
|
41
|
+
node.authUsername = config.authUsername || '';
|
|
42
|
+
node.authPassword = config.authPassword || '';
|
|
43
|
+
|
|
44
|
+
node.runtime = config.runtime || 'direct';
|
|
45
|
+
node.srtBinary = config.srtBinary || '';
|
|
46
|
+
node.srtSettingsMode = config.srtSettingsMode || 'file';
|
|
47
|
+
node.srtSettingsPath = config.srtSettingsPath || '';
|
|
48
|
+
node.srtAllowedDomains = Array.isArray(config.srtAllowedDomains) ? config.srtAllowedDomains : [];
|
|
49
|
+
node.srtAllowedWriteDirs = Array.isArray(config.srtAllowedWriteDirs) ? config.srtAllowedWriteDirs : [];
|
|
50
|
+
node.srtStrictAllowlist = config.srtStrictAllowlist !== false;
|
|
51
|
+
node.srtAdvancedJson = config.srtAdvancedJson || '';
|
|
52
|
+
|
|
53
|
+
// Resolved once at construction time, same pattern as the `agent`
|
|
54
|
+
// node -- these settings don't change without a redeploy.
|
|
55
|
+
node.resolvedSrtSettingsPath = undefined;
|
|
56
|
+
node.srtTempSettingsFile = undefined;
|
|
57
|
+
node.srtSettingsError = undefined;
|
|
58
|
+
|
|
59
|
+
if (node.runtime === 'srt') {
|
|
60
|
+
if (node.srtSettingsMode === 'inline') {
|
|
61
|
+
try {
|
|
62
|
+
node.resolvedSrtSettingsPath = writeInlineSettingsFile(node.id, {
|
|
63
|
+
allowedDomains: node.srtAllowedDomains,
|
|
64
|
+
allowedWriteDirs: node.srtAllowedWriteDirs,
|
|
65
|
+
strictAllowlist: node.srtStrictAllowlist,
|
|
66
|
+
advancedJson: node.srtAdvancedJson
|
|
67
|
+
}, 'agent-server-srt-settings');
|
|
68
|
+
node.srtTempSettingsFile = node.resolvedSrtSettingsPath;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
node.srtSettingsError = `invalid inline SRT settings JSON: ${err.message}`;
|
|
71
|
+
node.error(`agent-server: ${node.srtSettingsError}`);
|
|
72
|
+
node.status({ fill: 'red', shape: 'ring', text: 'bad srt settings' });
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
node.resolvedSrtSettingsPath = node.srtSettingsPath || undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
node.registry = new InstanceRegistry();
|
|
80
|
+
|
|
81
|
+
function updateStatus() {
|
|
82
|
+
node.status(computeNodeStatus(node.registry.summary()));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Envelope shared by every message on output 2 -- same shape/intent
|
|
86
|
+
// as the `agent` node's lifecycle envelope (topic correlation + live
|
|
87
|
+
// counts + timestamp), just counting daemons/sessions instead of
|
|
88
|
+
// executions. This is what a downstream ui-table/context-aggregator
|
|
89
|
+
// uses to build a "what has been running" history.
|
|
90
|
+
function emitEvent(sessionID, type, msg) {
|
|
91
|
+
const summary = node.registry.summary();
|
|
92
|
+
node.send([
|
|
93
|
+
null,
|
|
94
|
+
{
|
|
95
|
+
_msgid: msg && msg._msgid,
|
|
96
|
+
topic: msg && msg.topic,
|
|
97
|
+
payload: { type },
|
|
98
|
+
sessionID,
|
|
99
|
+
active: summary.busy,
|
|
100
|
+
tracked: summary.total,
|
|
101
|
+
timestamp: Date.now()
|
|
102
|
+
}
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolveTyped(prop, type, msg, fallback) {
|
|
107
|
+
if (prop === '') return fallback;
|
|
108
|
+
try {
|
|
109
|
+
const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
|
|
110
|
+
return value === undefined || value === null || value === '' ? fallback : value;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function authOptions() {
|
|
117
|
+
return node.authUsername || node.authPassword
|
|
118
|
+
? { username: node.authUsername, password: node.authPassword }
|
|
119
|
+
: {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extracts the assistant's text reply the same way the `agent`
|
|
123
|
+
// node's OpenCodeAdapter does: join every text-type part.
|
|
124
|
+
function extractText(messageResponse) {
|
|
125
|
+
const parts = (messageResponse && messageResponse.parts) || [];
|
|
126
|
+
return parts
|
|
127
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
128
|
+
.map((p) => p.text)
|
|
129
|
+
.join('\n')
|
|
130
|
+
.trim();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Spawns a brand-new daemon + session, registers it, and resolves
|
|
134
|
+
// with { sessionID, baseUrl }. Cleans up (kills the half-started
|
|
135
|
+
// process) and rejects on any failure along the way -- callers
|
|
136
|
+
// never have to clean up a partially-started daemon themselves.
|
|
137
|
+
async function spawnNewInstance(msg) {
|
|
138
|
+
if (node.maxInstances > 0 && node.registry.size() >= node.maxInstances) {
|
|
139
|
+
throw new Error(`agent-server: max instances (${node.maxInstances}) reached, cannot spawn a new daemon`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const port = await findFreePort(node.hostname);
|
|
143
|
+
const baseUrl = `http://${node.hostname}:${port}`;
|
|
144
|
+
const auth = authOptions();
|
|
145
|
+
|
|
146
|
+
const env = Object.assign({}, process.env);
|
|
147
|
+
if (node.authUsername || node.authPassword) {
|
|
148
|
+
env.OPENCODE_SERVER_PASSWORD = node.authPassword;
|
|
149
|
+
if (node.authUsername) env.OPENCODE_SERVER_USERNAME = node.authUsername;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { child, diagnostics } = spawnDaemon({
|
|
153
|
+
binary: node.opencodeBinary || undefined,
|
|
154
|
+
hostname: node.hostname,
|
|
155
|
+
port,
|
|
156
|
+
env,
|
|
157
|
+
srt: {
|
|
158
|
+
enabled: node.runtime === 'srt',
|
|
159
|
+
binary: node.srtBinary || undefined,
|
|
160
|
+
settingsPath: node.resolvedSrtSettingsPath
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
await waitForHealthy(baseUrl, {
|
|
166
|
+
timeoutMs: node.startupTimeoutMs,
|
|
167
|
+
diagnostics,
|
|
168
|
+
...auth
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
await killDaemon(child);
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let session;
|
|
176
|
+
try {
|
|
177
|
+
session = await request(`${baseUrl}/session`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
body: { title: (msg && msg.topic) || 'agent-server' },
|
|
180
|
+
timeoutMs: node.requestTimeoutMs,
|
|
181
|
+
...auth
|
|
182
|
+
});
|
|
183
|
+
} catch (err) {
|
|
184
|
+
await killDaemon(child);
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const sessionID = session.id;
|
|
189
|
+
node.registry.register(sessionID, { child, host: node.hostname, port, baseUrl });
|
|
190
|
+
emitEvent(sessionID, 'spawned', msg);
|
|
191
|
+
updateStatus();
|
|
192
|
+
return { sessionID, baseUrl };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Shared by both the "spawn new" and "reuse existing" paths.
|
|
196
|
+
async function sendMessage(sessionID, baseUrl, prompt, model, msg, send, done) {
|
|
197
|
+
node.registry.setBusy(sessionID, true);
|
|
198
|
+
emitEvent(sessionID, 'running', msg);
|
|
199
|
+
updateStatus();
|
|
200
|
+
|
|
201
|
+
const auth = authOptions();
|
|
202
|
+
// process.hrtime.bigint() rather than Date.now() for the
|
|
203
|
+
// duration measurement specifically: it's monotonic, so it
|
|
204
|
+
// can't ever go negative from a wall-clock adjustment mid-call
|
|
205
|
+
// (observed once in this sandbox's WSL2 VM). timestamp fields
|
|
206
|
+
// elsewhere deliberately stay Date.now() -- those are for
|
|
207
|
+
// human-readable/correlatable wall-clock history, not duration.
|
|
208
|
+
const startedAtNs = process.hrtime.bigint();
|
|
209
|
+
try {
|
|
210
|
+
const body = {
|
|
211
|
+
agent: msg.agent || 'build',
|
|
212
|
+
parts: [{ type: 'text', text: String(prompt) }]
|
|
213
|
+
};
|
|
214
|
+
if (model) body.model = model;
|
|
215
|
+
const response = await request(`${baseUrl}/session/${sessionID}/message`, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
body,
|
|
218
|
+
timeoutMs: node.requestTimeoutMs,
|
|
219
|
+
...auth
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
node.registry.setBusy(sessionID, false);
|
|
223
|
+
const record = node.registry.get(sessionID);
|
|
224
|
+
const durationMs = Number((process.hrtime.bigint() - startedAtNs) / 1000000n);
|
|
225
|
+
const resultMsg = Object.assign({}, msg, {
|
|
226
|
+
payload: extractText(response),
|
|
227
|
+
sessionID,
|
|
228
|
+
agentServer: {
|
|
229
|
+
sessionID,
|
|
230
|
+
host: record && record.host,
|
|
231
|
+
port: record && record.port,
|
|
232
|
+
durationMs
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
send([resultMsg, null]);
|
|
236
|
+
emitEvent(sessionID, 'completed', msg);
|
|
237
|
+
updateStatus();
|
|
238
|
+
done();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
node.registry.setBusy(sessionID, false);
|
|
241
|
+
emitEvent(sessionID, 'failed', msg);
|
|
242
|
+
updateStatus();
|
|
243
|
+
done(new Error(`agent-server: message to session "${sessionID}" failed: ${err.message}`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function handleMessageOperation(msg, send, done) {
|
|
248
|
+
let sessionID;
|
|
249
|
+
try {
|
|
250
|
+
sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
done(err);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let prompt;
|
|
257
|
+
try {
|
|
258
|
+
prompt = resolveTyped(node.promptProp, node.promptPropType, msg, undefined);
|
|
259
|
+
} catch (err) {
|
|
260
|
+
done(err);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (prompt === undefined || prompt === null || String(prompt).trim() === '') {
|
|
264
|
+
done(new Error('agent-server: no prompt (msg.payload or the configured Prompt field is empty)'));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let model;
|
|
269
|
+
try {
|
|
270
|
+
model = parseModel(resolveTyped(node.model, node.modelType, msg, ''));
|
|
271
|
+
} catch (err) {
|
|
272
|
+
done(new Error(`agent-server: ${err.message}`));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (sessionID) {
|
|
277
|
+
// Foreign sessionID -- not tracked by this node instance --
|
|
278
|
+
// is always an error, never a fallback spawn/resume.
|
|
279
|
+
if (!node.registry.has(sessionID)) {
|
|
280
|
+
done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const record = node.registry.get(sessionID);
|
|
284
|
+
if (record.busy) {
|
|
285
|
+
done(new Error(`agent-server: session "${sessionID}" is already processing a message`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
sendMessage(sessionID, record.baseUrl, prompt, model, msg, send, done);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
spawnNewInstance(msg)
|
|
293
|
+
.then(({ sessionID: newSessionID, baseUrl }) => sendMessage(newSessionID, baseUrl, prompt, model, msg, send, done))
|
|
294
|
+
.catch((err) => {
|
|
295
|
+
updateStatus();
|
|
296
|
+
done(err);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function handleStatusOperation(msg, send, done) {
|
|
301
|
+
let sessionID;
|
|
302
|
+
try {
|
|
303
|
+
sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
done(err);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!sessionID) {
|
|
310
|
+
// Aggregate across every daemon this node instance is
|
|
311
|
+
// tracking -- purely local, no network calls.
|
|
312
|
+
send([Object.assign({}, msg, { payload: node.registry.summary() }), null]);
|
|
313
|
+
done();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (!node.registry.has(sessionID)) {
|
|
318
|
+
done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const record = node.registry.get(sessionID);
|
|
323
|
+
send([
|
|
324
|
+
Object.assign({}, msg, {
|
|
325
|
+
payload: {
|
|
326
|
+
sessionID,
|
|
327
|
+
busy: record.busy,
|
|
328
|
+
host: record.host,
|
|
329
|
+
port: record.port,
|
|
330
|
+
startedAt: record.startedAt,
|
|
331
|
+
lastUsed: record.lastUsed
|
|
332
|
+
}
|
|
333
|
+
}),
|
|
334
|
+
null
|
|
335
|
+
]);
|
|
336
|
+
done();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function handleAbortOperation(msg, send, done) {
|
|
340
|
+
let sessionID;
|
|
341
|
+
try {
|
|
342
|
+
sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
|
|
343
|
+
} catch (err) {
|
|
344
|
+
done(err);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!sessionID || !node.registry.has(sessionID)) {
|
|
348
|
+
done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const record = node.registry.get(sessionID);
|
|
353
|
+
request(`${record.baseUrl}/session/${sessionID}/abort`, {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
timeoutMs: node.requestTimeoutMs,
|
|
356
|
+
...authOptions()
|
|
357
|
+
})
|
|
358
|
+
.then(() => {
|
|
359
|
+
node.registry.setBusy(sessionID, false);
|
|
360
|
+
updateStatus();
|
|
361
|
+
send([Object.assign({}, msg, { payload: true, sessionID }), null]);
|
|
362
|
+
done();
|
|
363
|
+
})
|
|
364
|
+
.catch((err) => done(new Error(`agent-server: abort failed: ${err.message}`)));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleHistoryOperation(msg, send, done) {
|
|
368
|
+
let sessionID;
|
|
369
|
+
try {
|
|
370
|
+
sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
done(err);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (!sessionID || !node.registry.has(sessionID)) {
|
|
376
|
+
done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const record = node.registry.get(sessionID);
|
|
381
|
+
request(`${record.baseUrl}/session/${sessionID}/message`, {
|
|
382
|
+
timeoutMs: node.requestTimeoutMs,
|
|
383
|
+
...authOptions()
|
|
384
|
+
})
|
|
385
|
+
.then((history) => {
|
|
386
|
+
send([Object.assign({}, msg, { payload: history, sessionID }), null]);
|
|
387
|
+
done();
|
|
388
|
+
})
|
|
389
|
+
.catch((err) => done(new Error(`agent-server: history fetch failed: ${err.message}`)));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Kills the daemon process itself, unlike `abort` (which only
|
|
393
|
+
// HTTP-cancels the in-flight message and leaves the daemon running,
|
|
394
|
+
// reusable for a later message). After this, the sessionID is no
|
|
395
|
+
// longer tracked -- a later message with this sessionID is a foreign/
|
|
396
|
+
// unknown id, same as if it had never been spawned.
|
|
397
|
+
function handleTerminateOperation(msg, send, done) {
|
|
398
|
+
let sessionID;
|
|
399
|
+
try {
|
|
400
|
+
sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
|
|
401
|
+
} catch (err) {
|
|
402
|
+
done(err);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (!sessionID || !node.registry.has(sessionID)) {
|
|
406
|
+
done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const record = node.registry.get(sessionID);
|
|
411
|
+
node.registry.delete(sessionID);
|
|
412
|
+
killDaemon(record.child)
|
|
413
|
+
.then(() => {
|
|
414
|
+
emitEvent(sessionID, 'terminated', msg);
|
|
415
|
+
updateStatus();
|
|
416
|
+
send([Object.assign({}, msg, { payload: true, sessionID }), null]);
|
|
417
|
+
done();
|
|
418
|
+
})
|
|
419
|
+
.catch((err) => done(new Error(`agent-server: terminate failed: ${err.message}`)));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const VALID_OPERATIONS = ['message', 'status', 'abort', 'history', 'terminate'];
|
|
423
|
+
|
|
424
|
+
node.on('input', function (msg, send, done) {
|
|
425
|
+
if (node.srtSettingsError) {
|
|
426
|
+
done(new Error(`agent-server: ${node.srtSettingsError}`));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// msg.operation can override the configured default for this one
|
|
431
|
+
// trigger. This matters because each node instance's registry is
|
|
432
|
+
// private (per-node-instance scoping, same as the `agent` node's
|
|
433
|
+
// active/queued counts) -- a *separate* node configured with
|
|
434
|
+
// operation 'status' would only ever see its own (always empty)
|
|
435
|
+
// registry, never another node's spawned daemons. Overriding lets
|
|
436
|
+
// the same node instance that does the spawning also be queried
|
|
437
|
+
// for its own status/history/abort on demand.
|
|
438
|
+
const operation = VALID_OPERATIONS.includes(msg.operation) ? msg.operation : node.operation;
|
|
439
|
+
|
|
440
|
+
switch (operation) {
|
|
441
|
+
case 'status':
|
|
442
|
+
handleStatusOperation(msg, send, done);
|
|
443
|
+
return;
|
|
444
|
+
case 'abort':
|
|
445
|
+
handleAbortOperation(msg, send, done);
|
|
446
|
+
return;
|
|
447
|
+
case 'history':
|
|
448
|
+
handleHistoryOperation(msg, send, done);
|
|
449
|
+
return;
|
|
450
|
+
case 'terminate':
|
|
451
|
+
handleTerminateOperation(msg, send, done);
|
|
452
|
+
return;
|
|
453
|
+
case 'message':
|
|
454
|
+
default:
|
|
455
|
+
handleMessageOperation(msg, send, done);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
node.on('close', function (done) {
|
|
461
|
+
const entries = Array.from(node.registry.list());
|
|
462
|
+
Promise.all(
|
|
463
|
+
entries.map((sessionID) => {
|
|
464
|
+
const record = node.registry.get(sessionID);
|
|
465
|
+
node.registry.delete(sessionID);
|
|
466
|
+
return killDaemon(record.child).then(() => {
|
|
467
|
+
emitEvent(sessionID, 'closed', {});
|
|
468
|
+
});
|
|
469
|
+
})
|
|
470
|
+
).then(() => {
|
|
471
|
+
if (node.srtTempSettingsFile) {
|
|
472
|
+
fs.unlink(node.srtTempSettingsFile, () => {});
|
|
473
|
+
}
|
|
474
|
+
node.status({});
|
|
475
|
+
done();
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
RED.nodes.registerType('agent-server', AgentServerNode);
|
|
481
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 60">
|
|
2
|
+
<!--
|
|
3
|
+
Node-RED custom node icon requirements (docs/creating-nodes/appearance.md):
|
|
4
|
+
white on transparent background, 2:3 aspect ratio, >= 40x60px.
|
|
5
|
+
This viewBox is exactly 40x60 (2:3). Eyes/mouth are cut as transparent
|
|
6
|
+
holes out of the solid white silhouette via a mask, so the shape reads
|
|
7
|
+
correctly against any node background color.
|
|
8
|
+
-->
|
|
9
|
+
<defs>
|
|
10
|
+
<mask id="agent-face-mask">
|
|
11
|
+
<rect x="0" y="0" width="40" height="60" fill="#ffffff"/>
|
|
12
|
+
<circle cx="14" cy="30" r="3.2" fill="#000000"/>
|
|
13
|
+
<circle cx="26" cy="30" r="3.2" fill="#000000"/>
|
|
14
|
+
<rect x="13" y="38" width="14" height="4" rx="2" fill="#000000"/>
|
|
15
|
+
</mask>
|
|
16
|
+
</defs>
|
|
17
|
+
<g fill="#ffffff" mask="url(#agent-face-mask)">
|
|
18
|
+
<!-- antenna -->
|
|
19
|
+
<circle cx="20" cy="6" r="3"/>
|
|
20
|
+
<rect x="18.5" y="9" width="3" height="6" rx="1.5"/>
|
|
21
|
+
<!-- head -->
|
|
22
|
+
<rect x="4" y="15" width="32" height="34" rx="8"/>
|
|
23
|
+
<!-- side ears -->
|
|
24
|
+
<rect x="0" y="26" width="4" height="10" rx="2"/>
|
|
25
|
+
<rect x="36" y="26" width="4" height="10" rx="2"/>
|
|
26
|
+
</g>
|
|
27
|
+
</svg>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const { request } = require('./http');
|
|
5
|
+
|
|
6
|
+
const GRACE_PERIOD_MS = 2000;
|
|
7
|
+
const DEFAULT_HEALTH_POLL_INTERVAL_MS = 200;
|
|
8
|
+
const MAX_BUFFERED_OUTPUT = 4000;
|
|
9
|
+
|
|
10
|
+
// Same argv-prefix technique as nodes/agent/lib/runtimes/srt.js:
|
|
11
|
+
// `srt [-s <settingsPath>] <command> [args...]`, no shell involved -- just
|
|
12
|
+
// wrapping `opencode serve` instead of `opencode run`. Kept as a pure
|
|
13
|
+
// function so the exact argv this node would spawn is unit-testable without
|
|
14
|
+
// touching child_process at all.
|
|
15
|
+
//
|
|
16
|
+
// IMPORTANT (verified empirically 2026-08-13): srt only sandboxes *outbound*
|
|
17
|
+
// network egress (network.allowedDomains/deniedDomains -- see `srt --help`,
|
|
18
|
+
// there is no inbound/listen-port concept at all). It isolates the
|
|
19
|
+
// sandboxed child into its own network namespace, so a process that itself
|
|
20
|
+
// needs to *accept* inbound connections -- like `opencode serve`'s HTTP
|
|
21
|
+
// server -- prints "listening on http://host:port" and genuinely never
|
|
22
|
+
// becomes reachable from outside the sandbox (confirmed with a trivial
|
|
23
|
+
// plain Node http server too, not opencode-specific). This is fine for the
|
|
24
|
+
// `agent` node (srt wraps `opencode run`, which is outbound-only), but SRT
|
|
25
|
+
// runtime here will always fail readiness (waitForHealthy times out) --
|
|
26
|
+
// there is currently no way to make a srt-sandboxed daemon reachable.
|
|
27
|
+
// Kept as an option (in case a future srt version adds inbound support)
|
|
28
|
+
// rather than removed, but treat it as non-functional today.
|
|
29
|
+
//
|
|
30
|
+
// config: { binary ('opencode'), hostname, port, srt: { enabled, binary, settingsPath } }
|
|
31
|
+
function buildCommand(config) {
|
|
32
|
+
const serveArgs = ['serve', '--port', String(config.port), '--hostname', config.hostname];
|
|
33
|
+
|
|
34
|
+
if (config.srt && config.srt.enabled) {
|
|
35
|
+
const flags = [];
|
|
36
|
+
if (config.srt.settingsPath) flags.push('-s', config.srt.settingsPath);
|
|
37
|
+
return {
|
|
38
|
+
cmd: config.srt.binary || 'srt',
|
|
39
|
+
args: [...flags, config.binary || 'opencode', ...serveArgs]
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { cmd: config.binary || 'opencode', args: serveArgs };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Bounded ring-buffer append: keeps only the last MAX_BUFFERED_OUTPUT chars,
|
|
47
|
+
// so a long-lived daemon's stdout/stderr can't leak memory over a session
|
|
48
|
+
// that stays open for hours.
|
|
49
|
+
function appendBounded(buffer, chunk) {
|
|
50
|
+
const next = buffer + chunk;
|
|
51
|
+
return next.length > MAX_BUFFERED_OUTPUT ? next.slice(next.length - MAX_BUFFERED_OUTPUT) : next;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Spawns the daemon process (detached into its own process group, same
|
|
55
|
+
// reasoning as nodes/agent/lib/runtimes/process-exec.js: a single
|
|
56
|
+
// `-pid` kill later reaches the whole tree, e.g. srt's sandbox wrapper +
|
|
57
|
+
// the opencode process it spawns). stdin is closed immediately -- opencode
|
|
58
|
+
// (like most CLIs) would otherwise wait on it forever (see AGENTS.md).
|
|
59
|
+
//
|
|
60
|
+
// Does not wait for readiness -- call waitForHealthy() with the returned
|
|
61
|
+
// baseUrl afterwards.
|
|
62
|
+
function spawnDaemon(config) {
|
|
63
|
+
const { cmd, args } = buildCommand(config);
|
|
64
|
+
const state = { stdout: '', stderr: '' };
|
|
65
|
+
|
|
66
|
+
const child = spawn(cmd, args, {
|
|
67
|
+
cwd: config.cwd || undefined,
|
|
68
|
+
env: config.env || process.env,
|
|
69
|
+
detached: true,
|
|
70
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
child.stdout.on('data', (chunk) => {
|
|
74
|
+
state.stdout = appendBounded(state.stdout, chunk.toString());
|
|
75
|
+
});
|
|
76
|
+
child.stderr.on('data', (chunk) => {
|
|
77
|
+
state.stderr = appendBounded(state.stderr, chunk.toString());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return { child, cmd, args, diagnostics: state };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Polls GET /global/health until it responds or timeoutMs elapses. Resolves
|
|
84
|
+
// with the parsed health body; rejects with a clear error (including
|
|
85
|
+
// whatever stderr the daemon produced, if any) on timeout.
|
|
86
|
+
async function waitForHealthy(baseUrl, { timeoutMs, intervalMs, username, password, diagnostics } = {}) {
|
|
87
|
+
const deadline = Date.now() + (timeoutMs || 15000);
|
|
88
|
+
const interval = intervalMs || DEFAULT_HEALTH_POLL_INTERVAL_MS;
|
|
89
|
+
let lastError;
|
|
90
|
+
|
|
91
|
+
while (Date.now() < deadline) {
|
|
92
|
+
try {
|
|
93
|
+
return await request(`${baseUrl}/global/health`, { timeoutMs: interval * 4, username, password });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
lastError = err;
|
|
96
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stderrTail = diagnostics && diagnostics.stderr ? ` -- stderr: ${diagnostics.stderr.trim()}` : '';
|
|
101
|
+
throw new Error(
|
|
102
|
+
`daemon at ${baseUrl} did not become healthy within ${timeoutMs}ms` +
|
|
103
|
+
(lastError ? ` (last error: ${lastError.message})` : '') +
|
|
104
|
+
stderrTail
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// `-pid` reaches the whole detached process group (see spawnDaemon).
|
|
109
|
+
function killProcessGroup(child, signal) {
|
|
110
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
111
|
+
try {
|
|
112
|
+
process.kill(-child.pid, signal);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
try {
|
|
115
|
+
child.kill(signal);
|
|
116
|
+
} catch (_err) {
|
|
117
|
+
// Already exited between the check above and here.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// SIGTERM, escalating to SIGKILL after GRACE_PERIOD_MS if it hasn't exited.
|
|
123
|
+
// Resolves once the process has actually exited, or after a safety cap so
|
|
124
|
+
// node close() can never hang forever on a daemon that refuses to die.
|
|
125
|
+
function killDaemon(child) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
|
128
|
+
resolve();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let settled = false;
|
|
133
|
+
const finish = () => {
|
|
134
|
+
if (settled) return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(killTimer);
|
|
137
|
+
clearTimeout(safetyTimer);
|
|
138
|
+
resolve();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
child.once('exit', finish);
|
|
142
|
+
killProcessGroup(child, 'SIGTERM');
|
|
143
|
+
|
|
144
|
+
const killTimer = setTimeout(() => killProcessGroup(child, 'SIGKILL'), GRACE_PERIOD_MS);
|
|
145
|
+
const safetyTimer = setTimeout(finish, GRACE_PERIOD_MS + 2000);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { buildCommand, spawnDaemon, waitForHealthy, killDaemon, killProcessGroup, GRACE_PERIOD_MS };
|