@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,60 @@
1
+ 'use strict';
2
+
3
+ // Thin fetch wrapper for talking to an opencode `serve` daemon: adds an
4
+ // AbortController-based timeout (fetch has no built-in one) and optional
5
+ // HTTP basic auth. Deliberately not a generic HTTP client -- just enough to
6
+ // call the handful of endpoints this node needs.
7
+ function basicAuthHeader(username, password) {
8
+ return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
9
+ }
10
+
11
+ // opts: { method, body, timeoutMs, username, password }
12
+ // Returns the parsed JSON body, or undefined for a 204/empty response.
13
+ // Throws an Error (with .status set, if the server responded at all) on
14
+ // network failure, timeout, or a non-2xx response.
15
+ async function request(url, opts = {}) {
16
+ const controller = new AbortController();
17
+ const timeoutMs = opts.timeoutMs || 30000;
18
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
19
+
20
+ const headers = { 'Content-Type': 'application/json' };
21
+ if (opts.username || opts.password) {
22
+ headers.Authorization = basicAuthHeader(opts.username || '', opts.password || '');
23
+ }
24
+
25
+ let res;
26
+ try {
27
+ res = await fetch(url, {
28
+ method: opts.method || 'GET',
29
+ headers,
30
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
31
+ signal: controller.signal
32
+ });
33
+ } catch (err) {
34
+ if (err.name === 'AbortError') {
35
+ throw new Error(`request to ${url} timed out after ${timeoutMs}ms`);
36
+ }
37
+ throw new Error(`request to ${url} failed: ${err.message}`);
38
+ } finally {
39
+ clearTimeout(timer);
40
+ }
41
+
42
+ const text = await res.text();
43
+ let body;
44
+ try {
45
+ body = text ? JSON.parse(text) : undefined;
46
+ } catch (err) {
47
+ body = text;
48
+ }
49
+
50
+ if (!res.ok) {
51
+ const err = new Error(`${opts.method || 'GET'} ${url} -> ${res.status}${text ? ': ' + text : ''}`);
52
+ err.status = res.status;
53
+ err.body = body;
54
+ throw err;
55
+ }
56
+
57
+ return body;
58
+ }
59
+
60
+ module.exports = { request, basicAuthHeader };
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ // The `agent` node's --model flag accepts a plain "provider/model" string
4
+ // (opencode's CLI parses that itself). The HTTP API has no such shorthand --
5
+ // POST /session/:id/message's `model` field must be a
6
+ // `{ providerID, modelID }` object (verified empirically: passing a plain
7
+ // string 400s with "Expected object | null, got \"...\""). This is the
8
+ // translation between the two, kept pure/testable and separate from
9
+ // agent-server.js.
10
+ function parseModel(value) {
11
+ if (value === undefined || value === null || value === '') return undefined;
12
+ const str = String(value);
13
+ const slash = str.indexOf('/');
14
+ if (slash <= 0 || slash === str.length - 1) {
15
+ throw new Error(`invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-4.6")`);
16
+ }
17
+ return { providerID: str.slice(0, slash), modelID: str.slice(slash + 1) };
18
+ }
19
+
20
+ module.exports = { parseModel };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+
5
+ // Self-allocates a free TCP port instead of spawning `opencode serve --port
6
+ // 0` and scraping its stdout for the OS-assigned port: bind a throwaway
7
+ // server to port 0, read back whatever the OS gave it, close it immediately,
8
+ // then hand that concrete number to `--port`. Deterministic, doesn't depend
9
+ // on a log line's exact wording, and is trivially unit-testable without
10
+ // spawning any real agent binary.
11
+ //
12
+ // Unavoidable TOCTOU: the port could theoretically be grabbed by something
13
+ // else between close() here and the daemon binding it moments later. Left
14
+ // as a known, accepted race (same as almost every "find a free port" helper)
15
+ // -- the daemon's own health-poll failing/timing out is the backstop.
16
+ function findFreePort(hostname = '127.0.0.1') {
17
+ return new Promise((resolve, reject) => {
18
+ const server = net.createServer();
19
+ server.unref();
20
+ server.on('error', reject);
21
+ server.listen(0, hostname, () => {
22
+ const { port } = server.address();
23
+ server.close((err) => {
24
+ if (err) reject(err);
25
+ else resolve(port);
26
+ });
27
+ });
28
+ });
29
+ }
30
+
31
+ module.exports = { findFreePort };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // Framework-agnostic, in-memory tracking of every daemon (opencode serve
4
+ // process) this node instance has spawned, keyed by sessionID. Deliberately
5
+ // has no Node-RED dependency (same reasoning as the `agent` package's
6
+ // lib/execution/scheduler.js) so it's unit-testable in isolation.
7
+ //
8
+ // This is also the entire answer to "how many/which agents are running
9
+ // right now": everything summary() reports comes from local state already
10
+ // being maintained for routing purposes anyway -- no extra network calls,
11
+ // no separate bookkeeping subsystem.
12
+ class InstanceRegistry {
13
+ constructor() {
14
+ this.map = new Map();
15
+ }
16
+
17
+ // record: { child, host, port, baseUrl } -- busy/startedAt/lastUsed are
18
+ // owned by the registry itself, not the caller.
19
+ register(sessionID, record) {
20
+ this.map.set(
21
+ sessionID,
22
+ Object.assign({ busy: false, startedAt: Date.now(), lastUsed: Date.now() }, record)
23
+ );
24
+ }
25
+
26
+ get(sessionID) {
27
+ return this.map.get(sessionID);
28
+ }
29
+
30
+ has(sessionID) {
31
+ return this.map.has(sessionID);
32
+ }
33
+
34
+ delete(sessionID) {
35
+ return this.map.delete(sessionID);
36
+ }
37
+
38
+ list() {
39
+ return Array.from(this.map.keys());
40
+ }
41
+
42
+ size() {
43
+ return this.map.size;
44
+ }
45
+
46
+ // Marks a tracked session busy/idle and bumps lastUsed. No-op if the
47
+ // sessionID isn't tracked (e.g. already torn down) -- callers don't need
48
+ // to guard this themselves.
49
+ setBusy(sessionID, busy) {
50
+ const record = this.map.get(sessionID);
51
+ if (!record) return;
52
+ record.busy = busy;
53
+ record.lastUsed = Date.now();
54
+ }
55
+
56
+ // Pure, local (no network) snapshot -- this is what backs the 'status'
57
+ // operation when called without a sessionID, and the lifecycle-event
58
+ // envelope's live counts.
59
+ summary() {
60
+ const sessions = [];
61
+ let busy = 0;
62
+ for (const [sessionID, record] of this.map) {
63
+ if (record.busy) busy += 1;
64
+ sessions.push({
65
+ sessionID,
66
+ host: record.host,
67
+ port: record.port,
68
+ busy: !!record.busy,
69
+ startedAt: record.startedAt,
70
+ lastUsed: record.lastUsed
71
+ });
72
+ }
73
+ return { total: sessions.length, busy, idle: sessions.length - busy, sessions };
74
+ }
75
+ }
76
+
77
+ module.exports = { InstanceRegistry };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ // Pure function computing the node.status() shape from the registry's local
4
+ // summary (see lib/registry.js). Kept separate from agent-server.js so it's
5
+ // unit-testable without a Node-RED runtime -- same reasoning as the `agent`
6
+ // package's lib/execution/status.js.
7
+ function computeNodeStatus({ total, busy, idle }) {
8
+ if (total === 0) return {}; // no daemons tracked -- idle, nothing to show
9
+
10
+ let text = `${total} daemon${total === 1 ? '' : 's'}`;
11
+ text += busy > 0 ? ` \u00b7 ${busy} busy` : ' \u00b7 idle';
12
+ return { fill: busy > 0 ? 'blue' : 'green', shape: 'dot', text };
13
+ }
14
+
15
+ module.exports = { computeNodeStatus };
@@ -0,0 +1,75 @@
1
+ # node-red-contrib-gh
2
+
3
+ Runs the installed [GitHub CLI](https://cli.github.com/) (`gh`) from a
4
+ Node-RED flow. One `gh` invocation per incoming message, no shell involved.
5
+
6
+ ## Prerequisites
7
+
8
+ `gh` must already be installed and authenticated on the Node-RED host:
9
+
10
+ ```bash
11
+ gh --version
12
+ gh auth login # or GH_TOKEN / GH_ENTERPRISE_TOKEN in the environment
13
+ ```
14
+
15
+ This package does not bundle `gh` or implement its own login flow.
16
+
17
+ ## Node: `gh`
18
+
19
+ **Inputs:** 1 &nbsp; **Outputs:** 1
20
+
21
+ | Field | Type | Notes |
22
+ |---|---|---|
23
+ | Command | str/msg/flow/global | The `gh` subcommand, e.g. `pr`, `issue`, `workflow`, `api`. Not the full command line -- `gh` itself is always the executable and can never be overridden. |
24
+ | Arguments | str/msg/flow/global | Everything after the command, e.g. `list --state open --json number,title,url`. A string value is tokenized (quote-aware, no shell evaluation); an array value (e.g. from `msg.`) is used as-is. |
25
+ | Repository | str/msg/flow/global | Optional `owner/repo`. Sets `GH_REPO` for the invocation. |
26
+ | Host (Advanced) | str | Optional GitHub Enterprise hostname, e.g. `github.example.com`. Sets `GH_HOST`. |
27
+ | Timeout (Advanced) | number (ms) | Default 60000. The child process is killed (`SIGTERM`) if it runs longer than this. |
28
+
29
+ ### `msg.gh` overrides
30
+
31
+ ```js
32
+ msg.gh = {
33
+ command: 'issue',
34
+ args: ['list', '--state', 'open'], // array preferred; skips parsing entirely
35
+ repo: 'owner/repo',
36
+ host: 'github.example.com'
37
+ };
38
+ ```
39
+
40
+ Any property present on `msg.gh` takes precedence over the node's own
41
+ configuration for that message.
42
+
43
+ ### Output
44
+
45
+ - `msg.payload`: parsed JSON if stdout was valid JSON, otherwise the raw
46
+ stdout string.
47
+ - `msg.gh`: `{ command, args, repo, host, exitCode, stderr }` execution
48
+ metadata. Never contains credentials.
49
+
50
+ A non-zero exit code, a timeout, or a missing `gh` executable all go through
51
+ `done(error)` (catchable with a Catch node) instead of producing an output
52
+ message.
53
+
54
+ ## Example
55
+
56
+ See `examples/list-pull-requests.json` and `examples/run-workflow.json` for
57
+ importable flow snippets.
58
+
59
+ ## Tests
60
+
61
+ ```bash
62
+ npm test
63
+ ```
64
+
65
+ Uses Node's built-in test runner (`node --test`) against a fake `gh`
66
+ executable in `test/fixtures/` -- no real GitHub CLI or network access
67
+ required.
68
+
69
+ ## Security
70
+
71
+ - The executable is always the literal string `gh`; nothing in `msg` or
72
+ config can change it.
73
+ - No shell is used (`shell: false`); arguments are passed as an array.
74
+ - Credentials are never logged, included in `msg`, or included in errors.
75
+ - The full process environment is never dumped anywhere.
@@ -0,0 +1,48 @@
1
+ [
2
+ {
3
+ "id": "gh-example-inject",
4
+ "type": "inject",
5
+ "name": "",
6
+ "props": [{ "p": "payload" }],
7
+ "repeat": "",
8
+ "crontab": "",
9
+ "once": false,
10
+ "onceDelay": 0.1,
11
+ "topic": "",
12
+ "payload": "",
13
+ "payloadType": "date",
14
+ "x": 150,
15
+ "y": 120,
16
+ "wires": [["gh-example-node"]]
17
+ },
18
+ {
19
+ "id": "gh-example-node",
20
+ "type": "gh",
21
+ "name": "Open PRs",
22
+ "command": "pr",
23
+ "commandType": "str",
24
+ "args": "list --state open --json number,title,author,url",
25
+ "argsType": "str",
26
+ "repo": "owner/repository",
27
+ "repoType": "str",
28
+ "host": "",
29
+ "timeoutMs": 60000,
30
+ "x": 350,
31
+ "y": 120,
32
+ "wires": [["gh-example-debug"]]
33
+ },
34
+ {
35
+ "id": "gh-example-debug",
36
+ "type": "debug",
37
+ "name": "PRs",
38
+ "active": true,
39
+ "tosidebar": true,
40
+ "console": false,
41
+ "tostatus": false,
42
+ "complete": "payload",
43
+ "targetType": "msg",
44
+ "x": 550,
45
+ "y": 120,
46
+ "wires": []
47
+ }
48
+ ]
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "id": "gh-wf-example-function",
4
+ "type": "function",
5
+ "name": "Build gh args",
6
+ "func": "msg.gh = {\n command: 'workflow',\n args: ['run', 'deploy.yml', '--ref', 'main', '-f', 'environment=staging'],\n repo: 'owner/repository'\n};\nreturn msg;",
7
+ "outputs": 1,
8
+ "x": 200,
9
+ "y": 220,
10
+ "wires": [["gh-wf-example-node"]]
11
+ },
12
+ {
13
+ "id": "gh-wf-example-node",
14
+ "type": "gh",
15
+ "name": "Trigger workflow",
16
+ "command": "",
17
+ "commandType": "str",
18
+ "args": "",
19
+ "argsType": "str",
20
+ "repo": "",
21
+ "repoType": "str",
22
+ "host": "",
23
+ "timeoutMs": 60000,
24
+ "x": 420,
25
+ "y": 220,
26
+ "wires": [["gh-wf-example-debug"]]
27
+ },
28
+ {
29
+ "id": "gh-wf-example-debug",
30
+ "type": "debug",
31
+ "name": "Result",
32
+ "active": true,
33
+ "tosidebar": true,
34
+ "console": false,
35
+ "tostatus": false,
36
+ "complete": "true",
37
+ "targetType": "full",
38
+ "x": 620,
39
+ "y": 220,
40
+ "wires": []
41
+ }
42
+ ]
@@ -0,0 +1,146 @@
1
+ <script type="text/javascript">
2
+ (function () {
3
+ 'use strict';
4
+ RED.nodes.registerType('gh', {
5
+ category: 'Collaboration',
6
+ color: '#6e5494',
7
+ defaults: {
8
+ name: { value: "" },
9
+ command: { value: "", required: true },
10
+ commandType: { value: "str" },
11
+ args: { value: "" },
12
+ argsType: { value: "str" },
13
+ repo: { value: "" },
14
+ repoType: { value: "str" },
15
+ host: { value: "" },
16
+ timeoutMs: { value: 60000, validate: RED.validators.number() }
17
+ },
18
+ inputs: 1,
19
+ outputs: 1,
20
+ icon: "gh.svg",
21
+ label: function () {
22
+ return this.name || (this.command ? "gh " + this.command : "gh");
23
+ },
24
+ oneditprepare: function () {
25
+ $('#node-input-command').typedInput({
26
+ typeField: '#node-input-commandType',
27
+ types: ['str', 'msg', 'flow', 'global']
28
+ });
29
+ $('#node-input-args').typedInput({
30
+ typeField: '#node-input-argsType',
31
+ types: ['str', 'msg', 'flow', 'global']
32
+ });
33
+ $('#node-input-repo').typedInput({
34
+ typeField: '#node-input-repoType',
35
+ types: ['str', 'msg', 'flow', 'global']
36
+ });
37
+ },
38
+ oneditsave: function () {
39
+ const cmd = ($('#node-input-command').val() || '').trim();
40
+ if (this.commandType === 'str' || !this.commandType) {
41
+ if (cmd === 'gh' || /\s/.test(cmd)) {
42
+ // Editor-side warning only; the runtime re-validates
43
+ // regardless (flow JSON can be edited outside the UI).
44
+ RED.notify(
45
+ 'Command should be a single gh subcommand (e.g. "pr"), not "gh" itself or a full command line.',
46
+ 'warning'
47
+ );
48
+ }
49
+ }
50
+ }
51
+ });
52
+ }());
53
+ </script>
54
+
55
+ <script type="text/html" data-template-name="gh">
56
+ <div class="form-row">
57
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
58
+ <input type="text" id="node-input-name" placeholder="Name">
59
+ </div>
60
+ <div class="form-row">
61
+ <label for="node-input-command"><i class="fa fa-terminal"></i> Command</label>
62
+ <input type="text" id="node-input-command" placeholder="pr">
63
+ <input type="hidden" id="node-input-commandType">
64
+ </div>
65
+ <div class="form-row">
66
+ <label for="node-input-args"><i class="fa fa-list"></i> Arguments</label>
67
+ <input type="text" id="node-input-args" placeholder="list --state open --json number,title,url">
68
+ <input type="hidden" id="node-input-argsType">
69
+ </div>
70
+ <div class="form-row">
71
+ <label for="node-input-repo"><i class="fa fa-book"></i> Repository</label>
72
+ <input type="text" id="node-input-repo" placeholder="owner/repo (optional)">
73
+ <input type="hidden" id="node-input-repoType">
74
+ </div>
75
+
76
+ <div class="form-row">
77
+ <a href="#" class="editor-tray-help-tray-hide" id="gh-advanced-toggle" style="font-weight:bold;">
78
+ <i class="fa fa-angle-right" id="gh-advanced-icon"></i> Advanced
79
+ </a>
80
+ </div>
81
+ <div id="gh-advanced-fields" style="display:none;">
82
+ <div class="form-row">
83
+ <label for="node-input-host"><i class="fa fa-globe"></i> Host</label>
84
+ <input type="text" id="node-input-host" placeholder="github.com (leave blank for gh's own default)">
85
+ </div>
86
+ <div class="form-row">
87
+ <label for="node-input-timeoutMs"><i class="fa fa-clock-o"></i> Timeout (ms)</label>
88
+ <input type="text" id="node-input-timeoutMs" style="width:100px">
89
+ </div>
90
+ </div>
91
+ <script type="text/javascript">
92
+ (function () {
93
+ $('#gh-advanced-toggle').on('click', function (e) {
94
+ e.preventDefault();
95
+ $('#gh-advanced-fields').slideToggle();
96
+ $('#gh-advanced-icon').toggleClass('fa-angle-right fa-angle-down');
97
+ });
98
+ }());
99
+ </script>
100
+ </script>
101
+
102
+ <script type="text/html" data-help-name="gh">
103
+ <p>Runs one GitHub CLI (<code>gh</code>) invocation per incoming message
104
+ and returns its result. Requires <code>gh</code> to already be
105
+ installed and authenticated (<code>gh auth login</code>) on the
106
+ Node-RED host -- this node does not implement its own login flow.</p>
107
+
108
+ <h3>Editor fields</h3>
109
+ <dl class="message-properties">
110
+ <dt>Command</dt>
111
+ <dd>The gh top-level subcommand, e.g. <code>pr</code>, <code>issue</code>,
112
+ <code>workflow</code>, <code>api</code>. Not the full command
113
+ line -- <code>gh</code> itself is always the executable.</dd>
114
+ <dt>Arguments</dt>
115
+ <dd>Everything after the command, e.g.
116
+ <code>list --state open --json number,title,url</code>. Quoted
117
+ spans stay together as one argument; nothing is passed through
118
+ a shell.</dd>
119
+ <dt>Repository</dt>
120
+ <dd>Optional <code>owner/repo</code>. Sets <code>GH_REPO</code> for
121
+ the invocation so it works without a checked-out git repo.</dd>
122
+ </dl>
123
+
124
+ <h3>Inputs</h3>
125
+ <dl class="message-properties">
126
+ <dt class="optional">gh <span class="property-type">object</span></dt>
127
+ <dd>Overrides the node's configuration for this message:
128
+ <code>msg.gh.command</code>, <code>msg.gh.args</code> (string or
129
+ array -- array is preferred, it skips argument parsing entirely),
130
+ <code>msg.gh.repo</code>, <code>msg.gh.host</code>.</dd>
131
+ </dl>
132
+
133
+ <h3>Outputs</h3>
134
+ <dl class="message-properties">
135
+ <dt>payload</dt>
136
+ <dd>Parsed JSON if stdout was valid JSON, otherwise the raw stdout
137
+ string.</dd>
138
+ <dt>gh <span class="property-type">object</span></dt>
139
+ <dd>Execution metadata: <code>{ command, args, repo, host, exitCode,
140
+ stderr }</code>. Never contains credentials.</dd>
141
+ </dl>
142
+
143
+ <p>A non-zero exit code, a timeout, or a missing <code>gh</code>
144
+ executable all trigger the node's error path (catchable with a Catch
145
+ node) instead of sending an output message.</p>
146
+ </script>