@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,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 };