@tbrandenburg/node-red-agents 0.3.6 → 0.3.8
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.
- package/README.md +3 -1
- package/nodes/agent/agent.html +55 -3
- package/nodes/agent/agent.js +12 -1
- package/nodes/agent/lib/agents/opencode.js +37 -4
- package/nodes/agent/lib/agents/pi.js +13 -2
- package/nodes/agent/lib/execution/lifecycle.js +7 -1
- package/nodes/agent-server/agent-server.html +52 -3
- package/nodes/agent-server/lib/model.js +3 -5
- package/nodes/gh/README.md +17 -1
- package/nodes/gh/gh.js +32 -3
- package/package.json +2 -2
- package/shared/model-format.js +34 -0
package/README.md
CHANGED
|
@@ -19,7 +19,9 @@ and example flows (`nodes/gh/examples/`).
|
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
22
|
-
- Node-RED >= 4.0.0, Node.js >=
|
|
22
|
+
- Node-RED >= 4.0.0, Node.js >= 20
|
|
23
|
+
(Node.js 20 compatibility is verified for these nodes; the monorepo's own
|
|
24
|
+
dev/CI tooling still targets Node.js 22, see the repo root `.nvmrc`)
|
|
23
25
|
- The [`opencode`](https://opencode.ai) CLI on `PATH` (for `agent`/`agent-server`)
|
|
24
26
|
- [`srt`](https://github.com/anthropics/sandbox-runtime) on `PATH`, only if using the SRT runtime option
|
|
25
27
|
- The [`gh`](https://cli.github.com) CLI on `PATH`, authenticated (for `gh`)
|
package/nodes/agent/agent.html
CHANGED
|
@@ -120,7 +120,18 @@
|
|
|
120
120
|
srtAllowedDomains: { value: [] },
|
|
121
121
|
srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
|
|
122
122
|
srtStrictAllowlist: { value: true },
|
|
123
|
-
srtAdvancedJson: {
|
|
123
|
+
srtAdvancedJson: {
|
|
124
|
+
value: '',
|
|
125
|
+
validate: function (v) {
|
|
126
|
+
if (!v || !v.trim()) return true;
|
|
127
|
+
try {
|
|
128
|
+
JSON.parse(v);
|
|
129
|
+
return true;
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
124
135
|
},
|
|
125
136
|
inputs: 1,
|
|
126
137
|
outputs: 2,
|
|
@@ -312,8 +323,49 @@
|
|
|
312
323
|
});
|
|
313
324
|
return values;
|
|
314
325
|
}
|
|
315
|
-
|
|
316
|
-
|
|
326
|
+
|
|
327
|
+
// Only persist the SRT inline-settings lists/fields while
|
|
328
|
+
// they're actually the active runtime+mode -- otherwise
|
|
329
|
+
// reset them to their defaults. Without this, switching
|
|
330
|
+
// Runtime back to Direct (or SRT settings back to File
|
|
331
|
+
// path) silently keeps whatever domains/directories/JSON
|
|
332
|
+
// were entered earlier, still saved into the flow even
|
|
333
|
+
// though the now-hidden fields are irrelevant to the
|
|
334
|
+
// deployed config -- confusing at best, and a footgun if
|
|
335
|
+
// the runtime is ever switched back to SRT without
|
|
336
|
+
// re-checking these stale values.
|
|
337
|
+
//
|
|
338
|
+
// Note: Node-RED's own core copies each defaults-bound
|
|
339
|
+
// plain input's *current DOM value* into `this[key]`
|
|
340
|
+
// right after oneditsave returns, so setting e.g.
|
|
341
|
+
// `this.srtSettingsMode` here alone is not enough -- a
|
|
342
|
+
// hidden <select>/<input>/<textarea> still holds its old
|
|
343
|
+
// value and would silently overwrite it straight back.
|
|
344
|
+
// The reset has to touch the DOM element itself for any
|
|
345
|
+
// field with a plain `#node-input-<name>` binding;
|
|
346
|
+
// srtAllowedDomains/srtAllowedWriteDirs are the exception
|
|
347
|
+
// (no such element exists -- only the "-list" editableList
|
|
348
|
+
// container), so those are fine set directly on `this`.
|
|
349
|
+
const runtime = $('#node-input-runtime').val();
|
|
350
|
+
const srtSettingsMode = $('#node-input-srtSettingsMode').val();
|
|
351
|
+
|
|
352
|
+
if (runtime !== 'srt') {
|
|
353
|
+
$('#node-input-srtSettingsMode').val('file');
|
|
354
|
+
$('#node-input-srtSettingsPath').val('');
|
|
355
|
+
$('#node-input-srtBinary').val('');
|
|
356
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
357
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
358
|
+
this.srtAllowedDomains = [];
|
|
359
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
360
|
+
} else if (srtSettingsMode !== 'inline') {
|
|
361
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
362
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
363
|
+
this.srtAllowedDomains = [];
|
|
364
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
365
|
+
} else {
|
|
366
|
+
this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
|
|
367
|
+
this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
|
|
368
|
+
}
|
|
317
369
|
}
|
|
318
370
|
});
|
|
319
371
|
}());
|
package/nodes/agent/agent.js
CHANGED
|
@@ -208,7 +208,10 @@ module.exports = function (RED) {
|
|
|
208
208
|
resolved,
|
|
209
209
|
executionId,
|
|
210
210
|
onEvent: (event) => {
|
|
211
|
-
send([
|
|
211
|
+
send([
|
|
212
|
+
null,
|
|
213
|
+
lifecycleEnvelope(msg, executionId, event, resolved.agentName, resolved.cwd),
|
|
214
|
+
]);
|
|
212
215
|
},
|
|
213
216
|
onStatus: (status) => {
|
|
214
217
|
if (status === "running") {
|
|
@@ -250,6 +253,14 @@ module.exports = function (RED) {
|
|
|
250
253
|
timedOut: result.timedOut,
|
|
251
254
|
durationMs: result.durationMs,
|
|
252
255
|
sessionID: result.sessionID,
|
|
256
|
+
// Raw error object from the adapter (e.g. opencode's full
|
|
257
|
+
// {"type":"error"} payload, or pi's failing assistant
|
|
258
|
+
// message) when the run failed -- the `done(err)` string
|
|
259
|
+
// below only carries a single summarized message/name, so
|
|
260
|
+
// anything needing the fuller detail (extra fields the
|
|
261
|
+
// adapter didn't fold into errorMessage) should wire a
|
|
262
|
+
// Debug node to output 1 and inspect this field.
|
|
263
|
+
errorDetail: result.errorDetail,
|
|
253
264
|
},
|
|
254
265
|
});
|
|
255
266
|
send([resultMsg, null]);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const { AgentAdapter } = require("./base");
|
|
5
5
|
const { toOpenCodeMcp } = require("../mcp/normalize");
|
|
6
|
+
const { assertModelFormat } = require("../../../../shared/model-format");
|
|
6
7
|
|
|
7
8
|
// Maps opencode's real `--format json` event stream types (verified against
|
|
8
9
|
// packages/opencode/src/cli/cmd/run.ts) onto the Agent node's generic event
|
|
@@ -18,6 +19,8 @@ const TYPE_MAP = {
|
|
|
18
19
|
|
|
19
20
|
class OpenCodeAdapter extends AgentAdapter {
|
|
20
21
|
validate(resolved) {
|
|
22
|
+
assertModelFormat(resolved.model);
|
|
23
|
+
|
|
21
24
|
if (resolved.cwd) {
|
|
22
25
|
let stat;
|
|
23
26
|
try {
|
|
@@ -107,7 +110,7 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
107
110
|
return { type, sessionID: raw.sessionID, data: raw };
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
parseResult(events, exitCode, signal, stderr) {
|
|
113
|
+
parseResult(events, exitCode, signal, stderr, resolved) {
|
|
111
114
|
const raw = events.map((e) => e.data);
|
|
112
115
|
const errorEvent = raw.find((e) => e.type === "error");
|
|
113
116
|
const sessionID = raw.length ? raw[raw.length - 1].sessionID : undefined;
|
|
@@ -119,11 +122,41 @@ class OpenCodeAdapter extends AgentAdapter {
|
|
|
119
122
|
.trim();
|
|
120
123
|
|
|
121
124
|
if (errorEvent) {
|
|
125
|
+
const errDetail = errorEvent.error || {};
|
|
122
126
|
const message =
|
|
123
|
-
(
|
|
124
|
-
|
|
127
|
+
(errDetail.data && errDetail.data.message) ||
|
|
128
|
+
errDetail.name ||
|
|
125
129
|
"opencode reported an error";
|
|
126
|
-
|
|
130
|
+
// The JSON error event only carries opencode's own top-level
|
|
131
|
+
// message/name -- append name (if distinct), its diagnostic ref (if
|
|
132
|
+
// any -- note this does NOT reliably show up in opencode's own log
|
|
133
|
+
// file, verified empirically, so it's a weak clue at best), and any
|
|
134
|
+
// stderr output opencode wrote alongside it, since all three would
|
|
135
|
+
// otherwise be silently dropped here (unlike the exitCode!==0
|
|
136
|
+
// branch below, which already surfaces stderr).
|
|
137
|
+
const extras = [];
|
|
138
|
+
if (errDetail.name && errDetail.name !== message) extras.push(errDetail.name);
|
|
139
|
+
if (errDetail.data && errDetail.data.ref) extras.push(`ref=${errDetail.data.ref}`);
|
|
140
|
+
// "UnknownError" is opencode's catch-all for a request the provider/
|
|
141
|
+
// server rejected before generating any content -- in practice the
|
|
142
|
+
// single most common trigger we've seen is a `--model` value that
|
|
143
|
+
// doesn't exist (wrong provider, typo, or a model that isn't
|
|
144
|
+
// actually available to this account). It's not the only possible
|
|
145
|
+
// cause, so this is phrased as a hint, not a diagnosis.
|
|
146
|
+
if (errDetail.name === "UnknownError" && resolved && resolved.model) {
|
|
147
|
+
extras.push(
|
|
148
|
+
`possible cause: model "${resolved.model}" may not exist or isn't available -- run "opencode models" to check`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (stderr && String(stderr).trim()) extras.push(String(stderr).trim());
|
|
152
|
+
const errorMessage = extras.length ? `${message} (${extras.join("; ")})` : message;
|
|
153
|
+
return {
|
|
154
|
+
payload,
|
|
155
|
+
sessionID,
|
|
156
|
+
status: "failed",
|
|
157
|
+
errorMessage,
|
|
158
|
+
errorDetail: errDetail,
|
|
159
|
+
};
|
|
127
160
|
}
|
|
128
161
|
if (signal) {
|
|
129
162
|
return {
|
|
@@ -186,9 +186,10 @@ class PiAdapter extends AgentAdapter {
|
|
|
186
186
|
|
|
187
187
|
let payload = "";
|
|
188
188
|
let errorMessage;
|
|
189
|
+
let lastAssistant;
|
|
189
190
|
|
|
190
191
|
if (agentEnd && Array.isArray(agentEnd.messages)) {
|
|
191
|
-
|
|
192
|
+
lastAssistant = [...agentEnd.messages].reverse().find((m) => m.role === "assistant");
|
|
192
193
|
if (lastAssistant) {
|
|
193
194
|
if (lastAssistant.stopReason === "error") {
|
|
194
195
|
// pi does NOT set a non-zero exit code for a model/API
|
|
@@ -207,7 +208,17 @@ class PiAdapter extends AgentAdapter {
|
|
|
207
208
|
}
|
|
208
209
|
|
|
209
210
|
if (errorMessage) {
|
|
210
|
-
|
|
211
|
+
// Same rationale as the opencode adapter: stderr is otherwise
|
|
212
|
+
// silently dropped for this branch (it's only appended in the
|
|
213
|
+
// exitCode!==0 branch below).
|
|
214
|
+
const extra = stderr && String(stderr).trim() ? ` (${String(stderr).trim()})` : "";
|
|
215
|
+
return {
|
|
216
|
+
payload,
|
|
217
|
+
sessionID,
|
|
218
|
+
status: "failed",
|
|
219
|
+
errorMessage: `${errorMessage}${extra}`,
|
|
220
|
+
errorDetail: lastAssistant,
|
|
221
|
+
};
|
|
211
222
|
}
|
|
212
223
|
if (signal) {
|
|
213
224
|
return {
|
|
@@ -51,7 +51,13 @@ async function runAgent({ adapter, runtime, resolved, executionId, onEvent, onSt
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
const durationMs = Date.now() - startedAt;
|
|
54
|
-
const result = adapter.parseResult(
|
|
54
|
+
const result = adapter.parseResult(
|
|
55
|
+
events,
|
|
56
|
+
outcome.exitCode,
|
|
57
|
+
outcome.signal,
|
|
58
|
+
outcome.stderr,
|
|
59
|
+
resolved,
|
|
60
|
+
);
|
|
55
61
|
const status = outcome.timedOut ? "timeout" : result.status;
|
|
56
62
|
|
|
57
63
|
if (onStatus) onStatus(status);
|
|
@@ -49,7 +49,18 @@
|
|
|
49
49
|
srtAllowedDomains: { value: [] },
|
|
50
50
|
srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
|
|
51
51
|
srtStrictAllowlist: { value: true },
|
|
52
|
-
srtAdvancedJson: {
|
|
52
|
+
srtAdvancedJson: {
|
|
53
|
+
value: '',
|
|
54
|
+
validate: function (v) {
|
|
55
|
+
if (!v || !v.trim()) return true;
|
|
56
|
+
try {
|
|
57
|
+
JSON.parse(v);
|
|
58
|
+
return true;
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
53
64
|
},
|
|
54
65
|
inputs: 1,
|
|
55
66
|
outputs: 2,
|
|
@@ -135,8 +146,46 @@
|
|
|
135
146
|
});
|
|
136
147
|
return values;
|
|
137
148
|
}
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
|
|
150
|
+
// Same rationale as the agent node's oneditsave: only
|
|
151
|
+
// persist the SRT inline-settings lists/fields while
|
|
152
|
+
// they're actually the active runtime+mode, otherwise
|
|
153
|
+
// reset them to their defaults so switching back to
|
|
154
|
+
// Direct (or File path mode) doesn't silently leave a
|
|
155
|
+
// stale SRT config sitting in the deployed flow.
|
|
156
|
+
//
|
|
157
|
+
// Note: Node-RED's own core copies each defaults-bound
|
|
158
|
+
// plain input's *current DOM value* into `this[key]`
|
|
159
|
+
// right after oneditsave returns, so the reset has to
|
|
160
|
+
// touch the DOM element itself for any field with a
|
|
161
|
+
// plain `#node-input-<name>` binding (srtSettingsMode,
|
|
162
|
+
// srtSettingsPath, srtBinary, srtStrictAllowlist,
|
|
163
|
+
// srtAdvancedJson) -- setting `this.<name>` alone would
|
|
164
|
+
// get silently overwritten straight back by that hidden
|
|
165
|
+
// element's stale value. srtAllowedDomains/
|
|
166
|
+
// srtAllowedWriteDirs are the exception (no such element
|
|
167
|
+
// exists -- only the "-list" editableList container), so
|
|
168
|
+
// those are fine set directly on `this`.
|
|
169
|
+
const runtime = $('#node-input-runtime').val();
|
|
170
|
+
const srtSettingsMode = $('#node-input-srtSettingsMode').val();
|
|
171
|
+
|
|
172
|
+
if (runtime !== 'srt') {
|
|
173
|
+
$('#node-input-srtSettingsMode').val('file');
|
|
174
|
+
$('#node-input-srtSettingsPath').val('');
|
|
175
|
+
$('#node-input-srtBinary').val('');
|
|
176
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
177
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
178
|
+
this.srtAllowedDomains = [];
|
|
179
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
180
|
+
} else if (srtSettingsMode !== 'inline') {
|
|
181
|
+
$('#node-input-srtStrictAllowlist').prop('checked', true);
|
|
182
|
+
$('#node-input-srtAdvancedJson').val('');
|
|
183
|
+
this.srtAllowedDomains = [];
|
|
184
|
+
this.srtAllowedWriteDirs = ['.', '/tmp', '~/.local/share/opencode'];
|
|
185
|
+
} else {
|
|
186
|
+
this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
|
|
187
|
+
this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
|
|
188
|
+
}
|
|
140
189
|
}
|
|
141
190
|
});
|
|
142
191
|
}());
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const { assertModelFormat } = require("../../../shared/model-format");
|
|
4
|
+
|
|
3
5
|
// The `agent` node's --model flag accepts a plain "provider/model" string
|
|
4
6
|
// (opencode's CLI parses that itself). The HTTP API has no such shorthand --
|
|
5
7
|
// POST /session/:id/message's `model` field must be a
|
|
@@ -10,12 +12,8 @@
|
|
|
10
12
|
function parseModel(value) {
|
|
11
13
|
if (value === undefined || value === null || value === "") return undefined;
|
|
12
14
|
const str = String(value);
|
|
15
|
+
assertModelFormat(str); // throws with a shared, consistent message (see shared/model-format.js)
|
|
13
16
|
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
17
|
return { providerID: str.slice(0, slash), modelID: str.slice(slash + 1) };
|
|
20
18
|
}
|
|
21
19
|
|
package/nodes/gh/README.md
CHANGED
|
@@ -49,7 +49,23 @@ configuration for that message.
|
|
|
49
49
|
|
|
50
50
|
A non-zero exit code, a timeout, or a missing `gh` executable all go through
|
|
51
51
|
`done(error)` (catchable with a Catch node) instead of producing an output
|
|
52
|
-
message.
|
|
52
|
+
message. Every such error carries an `err.errorType` string so flows can
|
|
53
|
+
branch on it without regex-matching gh's (locale/version dependent) human
|
|
54
|
+
-readable stderr text themselves:
|
|
55
|
+
|
|
56
|
+
| `errorType` | When |
|
|
57
|
+
| ------------------- | ------------------------------------------------------------------------------ |
|
|
58
|
+
| `auth` | Not logged in / bad credentials (stderr mentions `gh auth login`, etc.) |
|
|
59
|
+
| `rate-limit` | GitHub API (primary or secondary) rate limit exceeded |
|
|
60
|
+
| `feature-disabled` | Repo has issues/PRs/projects disabled (e.g. this ticket's original report) |
|
|
61
|
+
| `not-found` | Repository/resource doesn't exist or can't be resolved |
|
|
62
|
+
| `permission` | 403 / insufficient permissions |
|
|
63
|
+
| `network` | Network failure or timeout talking to GitHub |
|
|
64
|
+
| `not-installed` | `gh` executable not found on `PATH` (spawn `ENOENT`) |
|
|
65
|
+
| `spawn-failed` | The child process could not be started for another reason |
|
|
66
|
+
| `timeout` | The command exceeded the configured Timeout and was killed |
|
|
67
|
+
| `killed` | The command was killed by a signal other than the node's own timeout |
|
|
68
|
+
| `unknown` | Non-zero exit whose stderr didn't match any known pattern |
|
|
53
69
|
|
|
54
70
|
## Example
|
|
55
71
|
|
package/nodes/gh/gh.js
CHANGED
|
@@ -38,6 +38,30 @@ function validateCommand(command) {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Classifies a gh stderr message into a stable, machine-checkable category so
|
|
42
|
+
// downstream flows can branch on `err.errorType` instead of regex-matching
|
|
43
|
+
// the (locale/version dependent) human-readable message themselves. Order
|
|
44
|
+
// matters: more specific patterns are checked before generic ones.
|
|
45
|
+
const ERROR_TYPE_PATTERNS = [
|
|
46
|
+
[
|
|
47
|
+
/not logged into|to authenticate|gh auth login|authentication required|bad credentials/i,
|
|
48
|
+
"auth",
|
|
49
|
+
],
|
|
50
|
+
[/api rate limit exceeded|secondary rate limit/i, "rate-limit"],
|
|
51
|
+
[/has disabled issues|has disabled pull requests|has disabled projects/i, "feature-disabled"],
|
|
52
|
+
[/could not resolve to a repository|repository not found|404/i, "not-found"],
|
|
53
|
+
[/403|resource not accessible|must have admin rights|permission/i, "permission"],
|
|
54
|
+
[/network|timed out|econnreset|enotfound/i, "network"],
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
function classifyGhError(stderr) {
|
|
58
|
+
if (!stderr) return "unknown";
|
|
59
|
+
for (const [pattern, type] of ERROR_TYPE_PATTERNS) {
|
|
60
|
+
if (pattern.test(stderr)) return type;
|
|
61
|
+
}
|
|
62
|
+
return "unknown";
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
// Resolves an "args" value (from config or msg.gh.args) into a string[].
|
|
42
66
|
// Strings are tokenized (quote-aware, no shell evaluation); arrays are used
|
|
43
67
|
// as-is (each element coerced to a string); anything else is rejected.
|
|
@@ -172,6 +196,7 @@ module.exports = function (RED) {
|
|
|
172
196
|
const wrapped = new Error("gh: " + message);
|
|
173
197
|
wrapped.command = command;
|
|
174
198
|
wrapped.args = args;
|
|
199
|
+
wrapped.errorType = err && err.code === "ENOENT" ? "not-installed" : "spawn-failed";
|
|
175
200
|
done(wrapped);
|
|
176
201
|
});
|
|
177
202
|
|
|
@@ -188,19 +213,23 @@ module.exports = function (RED) {
|
|
|
188
213
|
err.args = args;
|
|
189
214
|
err.signal = signal;
|
|
190
215
|
err.timedOut = timedOut;
|
|
216
|
+
err.errorType = timedOut ? "timeout" : "killed";
|
|
191
217
|
done(err);
|
|
192
218
|
return;
|
|
193
219
|
}
|
|
194
220
|
|
|
195
221
|
if (code !== 0) {
|
|
196
|
-
|
|
222
|
+
const trimmedStderr = stderr.trim();
|
|
223
|
+
const errorType = classifyGhError(trimmedStderr);
|
|
224
|
+
node.status({ fill: "red", shape: "dot", text: errorType + " (exit " + code + ")" });
|
|
197
225
|
const err = new Error(
|
|
198
|
-
"gh: command failed (exit " + code + ")" + (
|
|
226
|
+
"gh: command failed (exit " + code + ")" + (trimmedStderr ? ": " + trimmedStderr : ""),
|
|
199
227
|
);
|
|
200
228
|
err.command = command;
|
|
201
229
|
err.args = args;
|
|
202
230
|
err.exitCode = code;
|
|
203
|
-
err.stderr =
|
|
231
|
+
err.stderr = trimmedStderr;
|
|
232
|
+
err.errorType = errorType;
|
|
204
233
|
done(err);
|
|
205
234
|
return;
|
|
206
235
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tbrandenburg/node-red-agents",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Node-RED nodes for running coding agents (opencode, pi) and GitHub CLI operations from flows.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node-red",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"!**/fixtures/**"
|
|
42
42
|
],
|
|
43
43
|
"engines": {
|
|
44
|
-
"node": ">=
|
|
44
|
+
"node": ">=20"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"test": "node --test --experimental-test-coverage=false 'nodes/**/test/**/*.spec.js' 'shared/**/test/**/*.spec.js'"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// opencode's model identifiers are always "provider/model" (e.g.
|
|
4
|
+
// "github-copilot/claude-sonnet-5"). Both the `agent` node (which passes
|
|
5
|
+
// the string straight through to `opencode run --model`) and the
|
|
6
|
+
// `agent-server` node (which splits it into { providerID, modelID } for the
|
|
7
|
+
// HTTP API, see agent-server/lib/model.js) need the same shape check.
|
|
8
|
+
//
|
|
9
|
+
// This exists because a malformed or non-existent model string currently
|
|
10
|
+
// only surfaces as opencode's own generic, unhelpful failure -- e.g.
|
|
11
|
+
// `opencode run --model bogus "hi"` prints
|
|
12
|
+
// {"type":"error","error":{"name":"UnknownError","data":{"message":
|
|
13
|
+
// "Unexpected server error. Check server logs for details.","ref":"err_..."}}}
|
|
14
|
+
// with exit code 0, and that "err_..." ref does *not* actually appear in
|
|
15
|
+
// opencode's own log file (verified empirically) -- so "check server logs"
|
|
16
|
+
// is a dead end. Catching an obviously-malformed string (no slash, or an
|
|
17
|
+
// empty provider/model half) before ever spawning opencode turns that into
|
|
18
|
+
// an immediate, actionable Node-RED error instead. It cannot catch a
|
|
19
|
+
// syntactically valid but non-existent "provider/model" pair -- that still
|
|
20
|
+
// requires either `opencode models` (see opencode's own --help) or opencode
|
|
21
|
+
// fixing its own error reporting.
|
|
22
|
+
function assertModelFormat(value) {
|
|
23
|
+
if (value === undefined || value === null || value === "") return;
|
|
24
|
+
const str = String(value);
|
|
25
|
+
const slash = str.indexOf("/");
|
|
26
|
+
if (slash <= 0 || slash === str.length - 1) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`invalid model "${str}" -- expected "provider/model" (e.g. "github-copilot/claude-sonnet-5"); ` +
|
|
29
|
+
`run "opencode models" to list valid values`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { assertModelFormat };
|