@tbrandenburg/node-red-agents 0.1.2 → 0.1.4

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.
@@ -1,7 +1,7 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const { spawn } = require('child_process');
4
- const { request } = require('./http');
3
+ const { spawn } = require("child_process");
4
+ const { request } = require("./http");
5
5
 
6
6
  const GRACE_PERIOD_MS = 2000;
7
7
  const DEFAULT_HEALTH_POLL_INTERVAL_MS = 200;
@@ -29,26 +29,26 @@ const MAX_BUFFERED_OUTPUT = 4000;
29
29
  //
30
30
  // config: { binary ('opencode'), hostname, port, srt: { enabled, binary, settingsPath } }
31
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 };
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
44
  }
45
45
 
46
46
  // Bounded ring-buffer append: keeps only the last MAX_BUFFERED_OUTPUT chars,
47
47
  // so a long-lived daemon's stdout/stderr can't leak memory over a session
48
48
  // that stays open for hours.
49
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;
50
+ const next = buffer + chunk;
51
+ return next.length > MAX_BUFFERED_OUTPUT ? next.slice(next.length - MAX_BUFFERED_OUTPUT) : next;
52
52
  }
53
53
 
54
54
  // Spawns the daemon process (detached into its own process group, same
@@ -60,90 +60,105 @@ function appendBounded(buffer, chunk) {
60
60
  // Does not wait for readiness -- call waitForHealthy() with the returned
61
61
  // baseUrl afterwards.
62
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 };
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
81
  }
82
82
 
83
83
  // Polls GET /global/health until it responds or timeoutMs elapses. Resolves
84
84
  // with the parsed health body; rejects with a clear error (including
85
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
- }
86
+ async function waitForHealthy(
87
+ baseUrl,
88
+ { timeoutMs, intervalMs, username, password, diagnostics } = {},
89
+ ) {
90
+ const deadline = Date.now() + (timeoutMs || 15000);
91
+ const interval = intervalMs || DEFAULT_HEALTH_POLL_INTERVAL_MS;
92
+ let lastError;
93
+
94
+ while (Date.now() < deadline) {
95
+ try {
96
+ return await request(`${baseUrl}/global/health`, {
97
+ timeoutMs: interval * 4,
98
+ username,
99
+ password,
100
+ });
101
+ } catch (err) {
102
+ lastError = err;
103
+ await new Promise((r) => setTimeout(r, interval));
98
104
  }
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
- );
105
+ }
106
+
107
+ const stderrTail =
108
+ diagnostics && diagnostics.stderr ? ` -- stderr: ${diagnostics.stderr.trim()}` : "";
109
+ throw new Error(
110
+ `daemon at ${baseUrl} did not become healthy within ${timeoutMs}ms` +
111
+ (lastError ? ` (last error: ${lastError.message})` : "") +
112
+ stderrTail,
113
+ );
106
114
  }
107
115
 
108
116
  // `-pid` reaches the whole detached process group (see spawnDaemon).
109
117
  function killProcessGroup(child, signal) {
110
- if (!child || child.exitCode !== null || child.signalCode !== null) return;
118
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
119
+ try {
120
+ process.kill(-child.pid, signal);
121
+ } catch (err) {
111
122
  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
- }
123
+ child.kill(signal);
124
+ } catch (_err) {
125
+ // Already exited between the check above and here.
119
126
  }
127
+ }
120
128
  }
121
129
 
122
130
  // SIGTERM, escalating to SIGKILL after GRACE_PERIOD_MS if it hasn't exited.
123
131
  // Resolves once the process has actually exited, or after a safety cap so
124
132
  // node close() can never hang forever on a daemon that refuses to die.
125
133
  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
- });
134
+ return new Promise((resolve) => {
135
+ if (!child || child.exitCode !== null || child.signalCode !== null) {
136
+ resolve();
137
+ return;
138
+ }
139
+
140
+ let settled = false;
141
+ const finish = () => {
142
+ if (settled) return;
143
+ settled = true;
144
+ clearTimeout(killTimer);
145
+ clearTimeout(safetyTimer);
146
+ resolve();
147
+ };
148
+
149
+ child.once("exit", finish);
150
+ killProcessGroup(child, "SIGTERM");
151
+
152
+ const killTimer = setTimeout(() => killProcessGroup(child, "SIGKILL"), GRACE_PERIOD_MS);
153
+ const safetyTimer = setTimeout(finish, GRACE_PERIOD_MS + 2000);
154
+ });
147
155
  }
148
156
 
149
- module.exports = { buildCommand, spawnDaemon, waitForHealthy, killDaemon, killProcessGroup, GRACE_PERIOD_MS };
157
+ module.exports = {
158
+ buildCommand,
159
+ spawnDaemon,
160
+ waitForHealthy,
161
+ killDaemon,
162
+ killProcessGroup,
163
+ GRACE_PERIOD_MS,
164
+ };
@@ -1,11 +1,11 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  // Thin fetch wrapper for talking to an opencode `serve` daemon: adds an
4
4
  // AbortController-based timeout (fetch has no built-in one) and optional
5
5
  // HTTP basic auth. Deliberately not a generic HTTP client -- just enough to
6
6
  // call the handful of endpoints this node needs.
7
7
  function basicAuthHeader(username, password) {
8
- return 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
8
+ return "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
9
9
  }
10
10
 
11
11
  // opts: { method, body, timeoutMs, username, password }
@@ -13,48 +13,50 @@ function basicAuthHeader(username, password) {
13
13
  // Throws an Error (with .status set, if the server responded at all) on
14
14
  // network failure, timeout, or a non-2xx response.
15
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;
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`);
55
36
  }
56
-
57
- return body;
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(
52
+ `${opts.method || "GET"} ${url} -> ${res.status}${text ? ": " + text : ""}`,
53
+ );
54
+ err.status = res.status;
55
+ err.body = body;
56
+ throw err;
57
+ }
58
+
59
+ return body;
58
60
  }
59
61
 
60
62
  module.exports = { request, basicAuthHeader };
@@ -1,4 +1,4 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  // The `agent` node's --model flag accepts a plain "provider/model" string
4
4
  // (opencode's CLI parses that itself). The HTTP API has no such shorthand --
@@ -8,13 +8,15 @@
8
8
  // translation between the two, kept pure/testable and separate from
9
9
  // agent-server.js.
10
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) };
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(
16
+ `invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-4.6")`,
17
+ );
18
+ }
19
+ return { providerID: str.slice(0, slash), modelID: str.slice(slash + 1) };
18
20
  }
19
21
 
20
22
  module.exports = { parseModel };
@@ -1,6 +1,6 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const net = require('net');
3
+ const net = require("net");
4
4
 
5
5
  // Self-allocates a free TCP port instead of spawning `opencode serve --port
6
6
  // 0` and scraping its stdout for the OS-assigned port: bind a throwaway
@@ -13,19 +13,19 @@ const net = require('net');
13
13
  // else between close() here and the daemon binding it moments later. Left
14
14
  // as a known, accepted race (same as almost every "find a free port" helper)
15
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
- });
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
+ });
28
27
  });
28
+ });
29
29
  }
30
30
 
31
31
  module.exports = { findFreePort };
@@ -1,4 +1,4 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  // Framework-agnostic, in-memory tracking of every daemon (opencode serve
4
4
  // process) this node instance has spawned, keyed by sessionID. Deliberately
@@ -10,68 +10,68 @@
10
10
  // being maintained for routing purposes anyway -- no extra network calls,
11
11
  // no separate bookkeeping subsystem.
12
12
  class InstanceRegistry {
13
- constructor() {
14
- this.map = new Map();
15
- }
13
+ constructor() {
14
+ this.map = new Map();
15
+ }
16
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
- }
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
25
 
26
- get(sessionID) {
27
- return this.map.get(sessionID);
28
- }
26
+ get(sessionID) {
27
+ return this.map.get(sessionID);
28
+ }
29
29
 
30
- has(sessionID) {
31
- return this.map.has(sessionID);
32
- }
30
+ has(sessionID) {
31
+ return this.map.has(sessionID);
32
+ }
33
33
 
34
- delete(sessionID) {
35
- return this.map.delete(sessionID);
36
- }
34
+ delete(sessionID) {
35
+ return this.map.delete(sessionID);
36
+ }
37
37
 
38
- list() {
39
- return Array.from(this.map.keys());
40
- }
38
+ list() {
39
+ return Array.from(this.map.keys());
40
+ }
41
41
 
42
- size() {
43
- return this.map.size;
44
- }
42
+ size() {
43
+ return this.map.size;
44
+ }
45
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
- }
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
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 };
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
+ });
74
72
  }
73
+ return { total: sessions.length, busy, idle: sessions.length - busy, sessions };
74
+ }
75
75
  }
76
76
 
77
77
  module.exports = { InstanceRegistry };
@@ -1,15 +1,15 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
3
  // Pure function computing the node.status() shape from the registry's local
4
4
  // summary (see lib/registry.js). Kept separate from agent-server.js so it's
5
5
  // unit-testable without a Node-RED runtime -- same reasoning as the `agent`
6
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
7
+ function computeNodeStatus({ total, busy }) {
8
+ if (total === 0) return {}; // no daemons tracked -- idle, nothing to show
9
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 };
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
13
  }
14
14
 
15
15
  module.exports = { computeNodeStatus };
@@ -18,22 +18,22 @@ This package does not bundle `gh` or implement its own login flow.
18
18
 
19
19
  **Inputs:** 1 &nbsp; **Outputs:** 1
20
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. |
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
28
 
29
29
  ### `msg.gh` overrides
30
30
 
31
31
  ```js
32
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'
33
+ command: "issue",
34
+ args: ["list", "--state", "open"], // array preferred; skips parsing entirely
35
+ repo: "owner/repo",
36
+ host: "github.example.com",
37
37
  };
38
38
  ```
39
39