@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
package/nodes/gh/gh.js ADDED
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('child_process');
4
+ const { parseArgs } = require('./lib/parse-args');
5
+
6
+ const GH_BINARY = 'gh';
7
+ const DEFAULT_TIMEOUT_MS = 60000;
8
+
9
+ // Resolves a typed-input style config field (str/msg/flow/global), honoring
10
+ // the Node-RED typedInput convention used elsewhere in this repo (see
11
+ // opencode-run.js's prompt/cwd handling). An empty 'str' path is treated as
12
+ // "not configured" rather than an evaluation error.
13
+ function evalField(RED, node, msg, value, type) {
14
+ if (type === 'str' && (value === undefined || value === null || value === '')) {
15
+ return undefined;
16
+ }
17
+ return RED.util.evaluateNodeProperty(value, type, node, msg);
18
+ }
19
+
20
+ // A valid gh top-level command is a single token (e.g. "pr", "issue",
21
+ // "api") -- never the literal "gh" itself and never something containing
22
+ // whitespace (which would indicate a whole command line was pasted in).
23
+ function validateCommand(command) {
24
+ if (typeof command !== 'string' || command.trim() === '') {
25
+ return 'no command configured (set the Command field or msg.gh.command)';
26
+ }
27
+ const trimmed = command.trim();
28
+ if (/\s/.test(trimmed)) {
29
+ return 'Command must be a single gh subcommand (e.g. "pr"), not a full command line: "' + trimmed + '"';
30
+ }
31
+ if (trimmed.toLowerCase() === 'gh') {
32
+ return '"gh" is the executable itself, not a command -- use e.g. "pr" with gh already implied';
33
+ }
34
+ return null;
35
+ }
36
+
37
+ // Resolves an "args" value (from config or msg.gh.args) into a string[].
38
+ // Strings are tokenized (quote-aware, no shell evaluation); arrays are used
39
+ // as-is (each element coerced to a string); anything else is rejected.
40
+ function resolveArgs(value) {
41
+ if (value === undefined || value === null) return [];
42
+ if (Array.isArray(value)) return value.map(String);
43
+ if (typeof value === 'string') return parseArgs(value);
44
+ throw new Error('args must be a string or an array of strings, got ' + typeof value);
45
+ }
46
+
47
+ module.exports = function (RED) {
48
+ function GhNode(config) {
49
+ RED.nodes.createNode(this, config);
50
+ const node = this;
51
+
52
+ node.command = config.command || '';
53
+ node.commandType = config.commandType || 'str';
54
+ node.args = config.args || '';
55
+ node.argsType = config.argsType || 'str';
56
+ node.repo = config.repo || '';
57
+ node.repoType = config.repoType || 'str';
58
+ node.host = config.host || '';
59
+ node.timeoutMs = Number(config.timeoutMs) || DEFAULT_TIMEOUT_MS;
60
+
61
+ node.on('input', function (msg, send, done) {
62
+ const ghOverride = (msg.gh && typeof msg.gh === 'object') ? msg.gh : {};
63
+
64
+ // --- command ---
65
+ let command;
66
+ try {
67
+ command = Object.prototype.hasOwnProperty.call(ghOverride, 'command')
68
+ ? ghOverride.command
69
+ : evalField(RED, node, msg, node.command, node.commandType);
70
+ } catch (err) {
71
+ node.status({ fill: 'red', shape: 'ring', text: 'bad command config' });
72
+ done(err);
73
+ return;
74
+ }
75
+
76
+ const commandError = validateCommand(command);
77
+ if (commandError) {
78
+ node.status({ fill: 'red', shape: 'ring', text: 'bad command' });
79
+ done(new Error('gh: ' + commandError));
80
+ return;
81
+ }
82
+ command = command.trim();
83
+
84
+ // --- args ---
85
+ let rawArgs;
86
+ try {
87
+ rawArgs = Object.prototype.hasOwnProperty.call(ghOverride, 'args')
88
+ ? ghOverride.args
89
+ : evalField(RED, node, msg, node.args, node.argsType);
90
+ } catch (err) {
91
+ node.status({ fill: 'red', shape: 'ring', text: 'bad args config' });
92
+ done(err);
93
+ return;
94
+ }
95
+
96
+ let args;
97
+ try {
98
+ args = resolveArgs(rawArgs);
99
+ } catch (err) {
100
+ node.status({ fill: 'red', shape: 'ring', text: 'bad args' });
101
+ done(new Error('gh: ' + err.message));
102
+ return;
103
+ }
104
+
105
+ // --- repo ---
106
+ let repo;
107
+ try {
108
+ repo = Object.prototype.hasOwnProperty.call(ghOverride, 'repo')
109
+ ? ghOverride.repo
110
+ : evalField(RED, node, msg, node.repo, node.repoType);
111
+ } catch (err) {
112
+ node.status({ fill: 'red', shape: 'ring', text: 'bad repo config' });
113
+ done(err);
114
+ return;
115
+ }
116
+ repo = (repo === undefined || repo === null || repo === '') ? undefined : String(repo);
117
+
118
+ // --- host (Advanced; simple str field, no typed input) ---
119
+ const host = ghOverride.host || node.host || undefined;
120
+
121
+ node.status({ fill: 'blue', shape: 'dot', text: 'running ' + command });
122
+
123
+ const env = Object.assign({}, process.env);
124
+ if (repo) env.GH_REPO = repo;
125
+ if (host) env.GH_HOST = host;
126
+
127
+ let stdout = '';
128
+ let stderr = '';
129
+ let child;
130
+ try {
131
+ child = spawn(GH_BINARY, [command, ...args], {
132
+ env,
133
+ stdio: ['ignore', 'pipe', 'pipe'],
134
+ shell: false
135
+ });
136
+ } catch (err) {
137
+ node.status({ fill: 'red', shape: 'ring', text: 'spawn failed' });
138
+ done(err);
139
+ return;
140
+ }
141
+
142
+ // Own explicit timeout instead of spawn()'s built-in timeout
143
+ // option: it's simpler to reason about (we control exactly when
144
+ // the timer is armed/cleared) and sidesteps that option's timer
145
+ // not always being cleared promptly when the child never
146
+ // actually starts (e.g. ENOENT).
147
+ let timedOut = false;
148
+ const timer = setTimeout(() => {
149
+ timedOut = true;
150
+ child.kill('SIGTERM');
151
+ }, node.timeoutMs);
152
+ if (typeof timer.unref === 'function') timer.unref();
153
+
154
+ child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
155
+ child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
156
+
157
+ child.on('error', (err) => {
158
+ clearTimeout(timer);
159
+ node.status({ fill: 'red', shape: 'ring', text: 'error' });
160
+ const message = (err && err.code === 'ENOENT')
161
+ ? 'gh executable not found on PATH'
162
+ : 'failed to run gh: ' + err.message;
163
+ const wrapped = new Error('gh: ' + message);
164
+ wrapped.command = command;
165
+ wrapped.args = args;
166
+ done(wrapped);
167
+ });
168
+
169
+ child.on('close', (code, signal) => {
170
+ clearTimeout(timer);
171
+ if (signal) {
172
+ node.status({ fill: 'red', shape: 'ring', text: timedOut ? 'timeout' : 'killed' });
173
+ const err = new Error(
174
+ timedOut
175
+ ? 'gh: timed out after ' + node.timeoutMs + 'ms and was killed'
176
+ : 'gh: process killed by signal ' + signal
177
+ );
178
+ err.command = command;
179
+ err.args = args;
180
+ err.signal = signal;
181
+ err.timedOut = timedOut;
182
+ done(err);
183
+ return;
184
+ }
185
+
186
+ if (code !== 0) {
187
+ node.status({ fill: 'red', shape: 'dot', text: 'exit ' + code });
188
+ const err = new Error(
189
+ 'gh: command failed (exit ' + code + ')' + (stderr.trim() ? ': ' + stderr.trim() : '')
190
+ );
191
+ err.command = command;
192
+ err.args = args;
193
+ err.exitCode = code;
194
+ err.stderr = stderr.trim();
195
+ done(err);
196
+ return;
197
+ }
198
+
199
+ const text = stdout.replace(/\r?\n$/, '');
200
+ let payload;
201
+ try {
202
+ payload = JSON.parse(text.trim());
203
+ } catch (e) {
204
+ payload = text;
205
+ }
206
+
207
+ msg.payload = payload;
208
+ msg.gh = {
209
+ command,
210
+ args,
211
+ repo: repo || '',
212
+ host: host || '',
213
+ exitCode: 0,
214
+ stderr: stderr.trim()
215
+ };
216
+
217
+ node.status({ fill: 'green', shape: 'dot', text: 'done' });
218
+ const clearStatus = setTimeout(() => node.status({}), 2000);
219
+ if (typeof clearStatus.unref === 'function') clearStatus.unref();
220
+ send(msg);
221
+ done();
222
+ });
223
+
224
+ node._child = child;
225
+ });
226
+
227
+ node.on('close', function (done) {
228
+ if (node._child && !node._child.killed) {
229
+ node._child.kill();
230
+ }
231
+ node.status({});
232
+ done();
233
+ });
234
+ }
235
+
236
+ RED.nodes.registerType('gh', GhNode);
237
+ };
@@ -0,0 +1,15 @@
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 (same
5
+ convention as nodes/agent/icons/agent.svg).
6
+
7
+ Artwork is the GitHub "octocat" mark from the Simple Icons project
8
+ (https://simpleicons.org/, CC0-1.0 license, public domain, free to
9
+ redistribute), recolored white and centered in the 40x60 canvas.
10
+ Original 24x24 path source: https://cdn.simpleicons.org/github/ffffff
11
+ -->
12
+ <g fill="#ffffff" transform="translate(4,14) scale(1.3333)">
13
+ <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
14
+ </g>
15
+ </svg>
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // Splits a string of gh CLI arguments into an argv array without any shell
4
+ // evaluation. Single- and double-quoted spans are kept as one argument
5
+ // (quotes themselves are stripped); everything else -- including shell
6
+ // metacharacters like &&, |, ;, `, $() -- is passed through as literal
7
+ // characters, never interpreted.
8
+ //
9
+ // Examples:
10
+ // 'list --state open' -> ['list', '--state', 'open']
11
+ // 'list --label "needs review"' -> ['list', '--label', 'needs review']
12
+ // 'pr list && rm -rf /' -> ['pr', 'list', '&&', 'rm', '-rf', '/']
13
+ function parseArgs(str) {
14
+ if (str === undefined || str === null) return [];
15
+ if (typeof str !== 'string') {
16
+ throw new Error('parseArgs: expected a string, got ' + typeof str);
17
+ }
18
+
19
+ const args = [];
20
+ let current = '';
21
+ let inArg = false;
22
+ let quote = null; // '"' or "'" while inside a quoted span
23
+
24
+ for (let i = 0; i < str.length; i += 1) {
25
+ const ch = str[i];
26
+
27
+ if (quote) {
28
+ if (ch === quote) {
29
+ quote = null;
30
+ } else {
31
+ current += ch;
32
+ }
33
+ continue;
34
+ }
35
+
36
+ if (ch === '"' || ch === "'") {
37
+ quote = ch;
38
+ inArg = true;
39
+ continue;
40
+ }
41
+
42
+ if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
43
+ if (inArg) {
44
+ args.push(current);
45
+ current = '';
46
+ inArg = false;
47
+ }
48
+ continue;
49
+ }
50
+
51
+ current += ch;
52
+ inArg = true;
53
+ }
54
+
55
+ if (quote) {
56
+ // Unterminated quote: fail safe by treating the rest of the
57
+ // string as literal content rather than throwing, since this is
58
+ // config text a user can fix, not something to crash a flow over.
59
+ args.push(current);
60
+ } else if (inArg) {
61
+ args.push(current);
62
+ }
63
+
64
+ return args;
65
+ }
66
+
67
+ module.exports = { parseArgs };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@tbrandenburg/node-red-agents",
3
+ "version": "0.1.1",
4
+ "description": "Node-RED nodes for running coding agents (opencode, pi) and GitHub CLI operations from flows.",
5
+ "keywords": [
6
+ "node-red",
7
+ "agent",
8
+ "ai-agent",
9
+ "opencode",
10
+ "pi",
11
+ "coding-agent",
12
+ "sandbox",
13
+ "srt",
14
+ "ai",
15
+ "llm",
16
+ "github",
17
+ "gh",
18
+ "github-cli"
19
+ ],
20
+ "homepage": "https://github.com/tbrandenburg/node-red-agents#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/tbrandenburg/node-red-agents/issues"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/tbrandenburg/node-red-agents.git",
27
+ "directory": "packages/node-red-agents"
28
+ },
29
+ "license": "MIT",
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "files": [
34
+ "nodes/**/*.js",
35
+ "nodes/**/*.html",
36
+ "nodes/**/icons/**",
37
+ "nodes/**/examples/**",
38
+ "nodes/gh/README.md",
39
+ "shared/**/*.js",
40
+ "!**/test/**",
41
+ "!**/fixtures/**"
42
+ ],
43
+ "engines": {
44
+ "node": ">=22"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test --experimental-test-coverage=false 'nodes/**/test/**/*.spec.js' 'shared/**/test/**/*.spec.js'"
48
+ },
49
+ "node-red": {
50
+ "version": ">=4.0.0",
51
+ "nodes": {
52
+ "agent": "nodes/agent/agent.js",
53
+ "agent-server": "nodes/agent-server/agent-server.js",
54
+ "gh": "nodes/gh/gh.js"
55
+ }
56
+ },
57
+ "devDependencies": {
58
+ "node-red-node-test-helper": "^0.3.6"
59
+ }
60
+ }
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ // Translates the simple "Inline" SRT settings UI (two string lists + one
8
+ // checkbox), used by both the `agent` and `agent-server` nodes, into the
9
+ // JSON shape `srt` itself requires, and writes it to a temp file (srt only
10
+ // accepts a file path via -s, not inline JSON). Kept separate from
11
+ // lib/runtimes/srt.js (agent) and lib/daemon.js (agent-server), which stay
12
+ // completely runtime-source-agnostic -- they only ever see a settingsPath
13
+ // string and don't care whether it came from this module or a hand-authored
14
+ // file (spec: "SRT-specific configuration... implemented by the runtime
15
+ // adapter rather than embedded in the Agent node core").
16
+ //
17
+ // Shared here (not duplicated) because both consumers now live in the same
18
+ // publishable package (packages/node-red-agents) -- see docs/260817_Refactoring.md
19
+ // step 8. Previously this was a verbatim copy in each node's lib/, kept
20
+ // separate only because each node used to be its own standalone npm package.
21
+ //
22
+ // srt requires network.allowedDomains, network.deniedDomains,
23
+ // filesystem.denyRead, and filesystem.denyWrite to all be *present* (even
24
+ // as empty arrays) -- verified empirically: srt refuses to run and prints
25
+ // "<field>: Required" for each one missing. This function always includes
26
+ // all four.
27
+ function buildSettingsJson({ allowedDomains, allowedWriteDirs, strictAllowlist } = {}) {
28
+ return {
29
+ network: {
30
+ allowedDomains: Array.isArray(allowedDomains) ? allowedDomains : [],
31
+ deniedDomains: [],
32
+ strictAllowlist: strictAllowlist !== false
33
+ },
34
+ filesystem: {
35
+ allowWrite: Array.isArray(allowedWriteDirs) ? allowedWriteDirs : [],
36
+ denyRead: [],
37
+ denyWrite: []
38
+ }
39
+ };
40
+ }
41
+
42
+ // config: { allowedDomains, allowedWriteDirs, strictAllowlist, advancedJson }
43
+ // Returns the JSON string that will be written (and validated as parseable
44
+ // JSON) -- does not touch the filesystem. Kept separate from
45
+ // writeInlineSettingsFile so tests can check the generated content without
46
+ // needing a real filesystem.
47
+ function resolveSettingsJsonString(config = {}) {
48
+ const raw = config.advancedJson && config.advancedJson.trim() ? config.advancedJson : JSON.stringify(buildSettingsJson(config));
49
+ // Throws a clear SyntaxError if the (usually hand-edited advanced) JSON
50
+ // is invalid -- deliberately no deeper validation than "is this valid
51
+ // JSON"; srt's own runtime error for a structurally-wrong-but-valid-JSON
52
+ // settings file is already clear and surfaces through the normal
53
+ // execution/spawn-failure path.
54
+ JSON.parse(raw);
55
+ return raw;
56
+ }
57
+
58
+ // nodeId is used only to make the temp filename recognizable/stable per
59
+ // node instance; process.pid is appended so a fast redeploy sequence can
60
+ // never have two live Node-RED processes racing on the same file.
61
+ // filePrefix distinguishes callers (e.g. 'agent' vs 'agent-server') so two
62
+ // different node types configuring the same nodeId-shaped id can never
63
+ // collide on the same temp file.
64
+ function writeInlineSettingsFile(nodeId, config, filePrefix = 'srt-settings') {
65
+ const json = resolveSettingsJsonString(config);
66
+ const filePath = path.join(os.tmpdir(), `${filePrefix}-${nodeId}-${process.pid}.json`);
67
+ fs.writeFileSync(filePath, json);
68
+ return filePath;
69
+ }
70
+
71
+ module.exports = { buildSettingsJson, resolveSettingsJsonString, writeInlineSettingsFile };