@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.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/nodes/agent/agent.html +550 -0
  4. package/nodes/agent/agent.js +396 -0
  5. package/nodes/agent/icons/agent.svg +27 -0
  6. package/nodes/agent/lib/agents/base.js +42 -0
  7. package/nodes/agent/lib/agents/opencode.js +141 -0
  8. package/nodes/agent/lib/agents/pi.js +220 -0
  9. package/nodes/agent/lib/execution/lifecycle.js +69 -0
  10. package/nodes/agent/lib/execution/scheduler.js +97 -0
  11. package/nodes/agent/lib/execution/status.js +24 -0
  12. package/nodes/agent/lib/mcp/normalize.js +31 -0
  13. package/nodes/agent/lib/runtimes/base.js +21 -0
  14. package/nodes/agent/lib/runtimes/direct.js +28 -0
  15. package/nodes/agent/lib/runtimes/process-exec.js +105 -0
  16. package/nodes/agent/lib/runtimes/srt.js +63 -0
  17. package/nodes/agent-server/agent-server.html +365 -0
  18. package/nodes/agent-server/agent-server.js +481 -0
  19. package/nodes/agent-server/icons/agent.svg +27 -0
  20. package/nodes/agent-server/lib/daemon.js +149 -0
  21. package/nodes/agent-server/lib/http.js +60 -0
  22. package/nodes/agent-server/lib/model.js +20 -0
  23. package/nodes/agent-server/lib/port.js +31 -0
  24. package/nodes/agent-server/lib/registry.js +77 -0
  25. package/nodes/agent-server/lib/status.js +15 -0
  26. package/nodes/gh/README.md +75 -0
  27. package/nodes/gh/examples/list-pull-requests.json +48 -0
  28. package/nodes/gh/examples/run-workflow.json +42 -0
  29. package/nodes/gh/gh.html +146 -0
  30. package/nodes/gh/gh.js +237 -0
  31. package/nodes/gh/icons/gh.svg +15 -0
  32. package/nodes/gh/lib/parse-args.js +67 -0
  33. package/package.json +60 -0
  34. 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 };