@tbrandenburg/node-red-agents 0.4.1 → 0.4.2

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 CHANGED
@@ -7,11 +7,21 @@ flow.
7
7
 
8
8
  - **agent** — runs a coding-agent CLI (OpenCode first, `pi` also
9
9
  supported) either directly or sandboxed via SRT (Anthropic's
10
- sandbox-runtime), one execution per input message.
10
+ sandbox-runtime), one execution per input message. Also supports
11
+ session resume, `$INPUTS.<name>` templating, retry with session
12
+ reuse, a FIFO concurrency scheduler, on-demand termination, tool
13
+ allow/deny lists, MCP server configuration, structured (JSON-Schema)
14
+ output validation, and cost/token usage reporting where supported.
11
15
  - **agent-server** — manages a long-lived `opencode serve` daemon
12
- (session-based, SRT-sandboxable), for flows that need repeated
13
- low-latency calls instead of `agent`'s one-shot execution model.
14
- - **gh** — runs GitHub CLI (`gh`) commands and returns parsed output.
16
+ (session-based), for flows that need repeated low-latency calls
17
+ instead of `agent`'s one-shot execution model. Supports
18
+ `message`/`status`/`abort`/`history`/`terminate` operations, an
19
+ instance cap (`maxInstances`), and optional basic auth. SRT
20
+ sandboxing is **not functional** for this node (see its built-in
21
+ help) since it only sandboxes outbound egress, not the inbound calls
22
+ this node needs.
23
+ - **gh** — runs GitHub CLI (`gh`) commands and returns parsed output,
24
+ with structured error classification and per-message overrides.
15
25
 
16
26
  See each node's built-in help (Node-RED editor info panel) for
17
27
  configuration details, or `nodes/gh/README.md` for `gh`-specific usage
@@ -15,6 +15,8 @@
15
15
  "github-copilot/gpt-5.4",
16
16
  "openai/gpt-5.4",
17
17
  "openai/gpt-4.1",
18
+ "claude-sonnet-5",
19
+ "gpt-5.4",
18
20
  ];
19
21
 
20
22
  function mcpServerRow(container, data) {
@@ -156,7 +158,7 @@
156
158
  arguments: { value: "payload" },
157
159
  argumentsType: { value: "msg" },
158
160
 
159
- inputs: { value: [] },
161
+ promptArgs: { value: [] },
160
162
 
161
163
  cwd: { value: "cwd" },
162
164
  cwdType: { value: "msg" },
@@ -312,6 +314,10 @@
312
314
  skill: "code-review (looked up under .github/skills/)",
313
315
  command: "review (looked up under .github/prompts/)",
314
316
  },
317
+ copilot: {
318
+ skill: "code-review (looked up under .github/skills/)",
319
+ command: "review (looked up under .github/prompts/)",
320
+ },
315
321
  };
316
322
 
317
323
  function updateInvocationRows() {
@@ -342,6 +348,7 @@
342
348
  const AUTO_HINTS = {
343
349
  opencode: "Maps to --auto: skips permission prompts for actions not explicitly denied.",
344
350
  pi: "Pi has no permission prompts to bypass. Off = read-only tools (read/grep/find/ls). On = full tool access (bash/edit/write too).",
351
+ copilot: "Maps to --allow-all-tools: skips permission prompts for all tool calls.",
345
352
  };
346
353
 
347
354
  // Grey out (rather than hide) fields an agent doesn't
@@ -392,7 +399,7 @@
392
399
  removable: true,
393
400
  sortable: true,
394
401
  });
395
- (node.inputs || []).forEach((entry) => inputsList.editableList("addItem", entry));
402
+ (node.promptArgs || []).forEach((entry) => inputsList.editableList("addItem", entry));
396
403
 
397
404
 
398
405
  const deniedToolsList = $("#node-input-deniedTools-list");
@@ -444,7 +451,7 @@
444
451
  const sessionSupported = agent !== "pi";
445
452
  setSectionSupported($(".agent-row-session"), sessionSupported);
446
453
  $(".agent-row-session-hint").toggle(!sessionSupported);
447
- const effortSupported = agent === "opencode";
454
+ const effortSupported = agent === "opencode" || agent === "copilot";
448
455
  setSectionSupported($(".agent-row-effort"), effortSupported);
449
456
  $(".agent-row-effort-hint").toggle(!effortSupported);
450
457
  updateInvocationRows();
@@ -475,14 +482,14 @@
475
482
  this.allowedTools = collectStrings($("#node-input-allowedTools-list"));
476
483
  this.deniedTools = collectStrings($("#node-input-deniedTools-list"));
477
484
 
478
- const inputsOut = [];
485
+ const promptArgsOut = [];
479
486
  $("#node-input-inputs-list")
480
487
  .editableList("items")
481
488
  .each(function () {
482
489
  const v = $(this).data("inputsGetValue")();
483
- if (v && v.name) inputsOut.push(v);
490
+ if (v && v.name) promptArgsOut.push(v);
484
491
  });
485
- this.inputs = inputsOut;
492
+ this.promptArgs = promptArgsOut;
486
493
 
487
494
 
488
495
  // Only persist the SRT inline-settings lists/fields while
@@ -561,6 +568,7 @@
561
568
  <select id="node-input-agent" style="width:70%">
562
569
  <option value="opencode">OpenCode</option>
563
570
  <option value="pi">Pi</option>
571
+ <option value="copilot">Copilot</option>
564
572
  </select>
565
573
  </div>
566
574
 
@@ -880,8 +888,8 @@
880
888
 
881
889
  <script type="text/html" data-help-name="agent">
882
890
  <p>
883
- Runs a coding agent (<b>OpenCode</b> or <b>Pi</b>) non-interactively, either directly on this
884
- host or sandboxed through <code>srt</code>.
891
+ Runs a coding agent (<b>OpenCode</b>, <b>Pi</b>, or <b>Copilot</b>) non-interactively, either
892
+ directly on this host or sandboxed through <code>srt</code>.
885
893
  </p>
886
894
  <h3>Inputs</h3>
887
895
  <dl class="message-properties">
@@ -898,7 +906,7 @@
898
906
  <dt class="optional">sessionID <span class="property-type">string</span></dt>
899
907
  <dd>
900
908
  Used to resume a previous session if the Session ID field is left at its default
901
- (<code>msg.sessionID</code>). Empty/absent starts a new session. OpenCode only.
909
+ (<code>msg.sessionID</code>). Empty/absent starts a new session. OpenCode and Copilot only.
902
910
  </dd>
903
911
  <dt class="optional">operation <span class="property-type">string</span></dt>
904
912
  <dd>
@@ -994,21 +1002,27 @@
994
1002
  still completes). Pi has no equivalent permission-prompt system to bypass in non-interactive
995
1003
  mode -- for Pi this instead restricts the available tools to read-only
996
1004
  (<code>read/grep/find/ls</code>) when off, and allows everything (including
997
- <code>bash/edit/write</code>) when on.
1005
+ <code>bash/edit/write</code>) when on. For Copilot it maps to <code>--allow-all-tools</code>: off
1006
+ (default) leaves Copilot's own permission prompts in effect (which auto-reject in
1007
+ non-interactive mode), on skips them entirely.
998
1008
  </p>
999
1009
  <p>
1000
1010
  <b>Skill</b>/<b>Command</b> invocation: for OpenCode this is the name of a discovered skill or a
1001
1011
  <code>.opencode/command(s)/&lt;name&gt;.md</code> file, dispatched deterministically via
1002
- <code>--command</code>. Pi has no equivalent dispatch mechanism -- the name is resolved to a
1003
- file under <code>.github/skills/</code> (skill) or <code>.github/prompts/</code> (command),
1004
- passed via <code>--skill</code>/<code>--prompt-template</code>, and the model is explicitly told
1005
- in the prompt to use it. Pi also does not support configuring MCP servers through this node.
1012
+ <code>--command</code>. Pi and Copilot have no equivalent dispatch mechanism -- the name is
1013
+ resolved to a file under <code>.github/skills/</code> (skill) or <code>.github/prompts/</code>
1014
+ (command), passed via <code>--skill</code>/<code>--prompt-template</code> for Pi (Copilot has the
1015
+ resolved file path spelled out in the synthesized prompt instruction instead, since it has no
1016
+ equivalent CLI flag), and the model is explicitly told in the prompt to use it. Pi does not
1017
+ support configuring MCP servers through this node; Copilot does.
1006
1018
  </p>
1007
1019
  <p>
1008
1020
  <b>Session ID</b>: leave blank to always start a fresh session (the default). Set it (typically
1009
1021
  from the previous result's <code>msg.sessionID</code>) to continue that conversation instead --
1010
- maps to OpenCode's <code>opencode run --session &lt;id&gt; ...</code>. Only the OpenCode agent
1011
- supports this; Pi rejects a non-empty Session ID (every Pi run uses <code>--no-session</code>).
1022
+ maps to OpenCode's <code>opencode run --session &lt;id&gt; ...</code> or Copilot's
1023
+ <code>copilot --resume &lt;id&gt; ...</code>. Pi rejects a non-empty Session ID (every Pi run
1024
+ uses <code>--no-session</code>). Copilot only ever resumes via <code>--resume</code> -- it has
1025
+ no equivalent of OpenCode's explicit "start a new session with this id" flag.
1012
1026
  </p>
1013
1027
  <p>
1014
1028
  <b>SRT</b> runtime shells out to the <code>srt</code> sandbox-runtime CLI already installed on
@@ -1019,13 +1033,24 @@
1019
1033
  <b>System prompt</b>, <b>Effort</b>, and <b>Allowed/Denied tools</b> are forwarded to the
1020
1034
  underlying CLI only where that adapter actually supports it -- a field set for an adapter that
1021
1035
  doesn't will be silently dropped with a Node-RED warning, never a hard error.
1022
- <b>System prompt</b> has no verified CLI flag for either adapter yet and is always dropped.
1036
+ <b>System prompt</b> has no verified CLI flag for any adapter yet and is always dropped.
1023
1037
  <b>Effort</b> (reasoning depth, e.g. <code>low</code>/<code>medium</code>/<code>high</code>)
1024
- maps to OpenCode's <code>--variant</code>; Pi has no equivalent.
1025
- <b>Allowed/ Denied tools</b> are supported by both: for OpenCode they're materialized into a
1026
- temporary, per-run agent config (delivered the same way as MCP servers, via
1038
+ maps to OpenCode's <code>--variant</code> or Copilot's own effort flag; Pi has no equivalent.
1039
+ <b>Allowed/ Denied tools</b> are supported by all three: for OpenCode they're materialized into
1040
+ a temporary, per-run agent config (delivered the same way as MCP servers, via
1027
1041
  <code>OPENCODE_CONFIG_CONTENT</code>) selected with <code>--agent</code>; for Pi, a non-empty
1028
1042
  Allowed tools list is passed as <code>--tools</code> (Pi has no deny-list mechanism, so Denied
1029
- tools has no effect for Pi).
1043
+ tools has no effect for Pi); for Copilot both lists are passed as
1044
+ <code>--allow-tool</code>/<code>--deny-tool</code> flags.
1045
+ </p>
1046
+ <p>
1047
+ <b>Copilot</b> specifics: <b>Model</b> is a bare model id (e.g. <code>claude-sonnet-5</code>,
1048
+ <code>gpt-5.4</code>) rather than OpenCode's <code>provider/model</code> form. <b>Session ID</b>
1049
+ is always resumed with <code>--resume &lt;id&gt;</code> -- there is no equivalent of OpenCode's
1050
+ "start a new session with this specific id" flag. MCP servers configured through this node are
1051
+ passed via <code>--additional-mcp-config</code>, but the Copilot CLI always additionally loads
1052
+ the user's global <code>~/.copilot/mcp-config.json</code> alongside them -- a known CLI
1053
+ limitation, not a bug in this node, so servers defined there are always active too regardless of
1054
+ what's configured here.
1030
1055
  </p>
1031
1056
  </script>
@@ -1,6 +1,8 @@
1
1
  const fs = require("fs");
2
- const { OpenCodeAdapter } = require("./lib/agents/opencode");
3
- const { PiAdapter } = require("./lib/agents/pi");
2
+ require("./lib/agents/opencode"); // registers "opencode" as a side effect
3
+ require("./lib/agents/pi"); // registers "pi" as a side effect
4
+ require("./lib/agents/copilot"); // registers "copilot" as a side effect
5
+ const { isRegisteredAgent, getAgentAdapter } = require("./lib/agents/registry");
4
6
  const { DirectRuntime } = require("./lib/runtimes/direct");
5
7
  const { SrtRuntime } = require("./lib/runtimes/srt");
6
8
  const { writeInlineSettingsFile } = require("../../shared/srt-settings");
@@ -10,6 +12,7 @@ const { computeNodeStatus } = require("./lib/execution/status");
10
12
  const { getCapabilities } = require("./lib/agents/capabilities");
11
13
  const { shouldRetry } = require("./lib/execution/retry");
12
14
  const { substituteInputs } = require("./lib/execution/inputs");
15
+ const { resumeOutcome } = require("./lib/execution/resume-outcome");
13
16
  const {
14
17
  STRUCTURED_OUTPUT_MAX_REASKS,
15
18
  compileOutputFormat,
@@ -18,15 +21,12 @@ const {
18
21
  buildReaskPrompt,
19
22
  } = require("./lib/execution/structured-output");
20
23
 
21
- // Registries. Adding a future adapter/runtime is just one more entry here --
24
+ // Registries. Adding a future adapter/runtime is just one more self-
25
+ // registration in the adapter's own module (see lib/agents/registry.js) --
22
26
  // nothing else in this file (or in lib/execution/lifecycle.js) needs to
23
27
  // change, per the spec's adapter-independence requirement. Concurrency
24
28
  // (lib/execution/scheduler.js) is likewise fully independent of both: it
25
29
  // only ever sees opaque { executionId, ... } items.
26
- const AGENTS = {
27
- opencode: () => new OpenCodeAdapter(),
28
- pi: () => new PiAdapter(),
29
- };
30
30
 
31
31
  function buildRuntime(node) {
32
32
  if (node.runtime === "srt") {
@@ -89,7 +89,7 @@ module.exports = function (RED) {
89
89
  // resolved arguments string before it's handed to either adapter.
90
90
  // Pure text templating, zero adapter-specific code -- see
91
91
  // lib/execution/inputs.js. Default empty list = zero behavior change.
92
- node.inputs = Array.isArray(config.inputs) ? config.inputs : [];
92
+ node.promptArgs = Array.isArray(config.promptArgs) ? config.promptArgs : [];
93
93
 
94
94
  node.cwd = config.cwd !== undefined ? config.cwd : "cwd";
95
95
  node.cwdType = config.cwdType || "msg";
@@ -158,8 +158,8 @@ module.exports = function (RED) {
158
158
  node.outputFormatSchema = schema;
159
159
  }
160
160
  }
161
- if (!node.outputFormatError && AGENTS[node.agent]) {
162
- const capabilities = getCapabilities(AGENTS[node.agent]());
161
+ if (!node.outputFormatError && isRegisteredAgent(node.agent)) {
162
+ const capabilities = getCapabilities(getAgentAdapter(node.agent));
163
163
  if (capabilities.structuredOutput === false) {
164
164
  node.outputFormatError = `output_format not supported by ${node.agent}`;
165
165
  }
@@ -241,7 +241,7 @@ module.exports = function (RED) {
241
241
  const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
242
242
  return value === undefined || value === null ? fallback : value;
243
243
  } catch (err) {
244
- throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
244
+ throw new Error(`invalid ${type} property "${prop}": ${err.message}`, { cause: err });
245
245
  }
246
246
  }
247
247
 
@@ -293,7 +293,7 @@ module.exports = function (RED) {
293
293
  // directly from the input handler.
294
294
  function startExecution(item) {
295
295
  const { executionId, msg, send, done, resolved } = item;
296
- const adapter = AGENTS[node.agent]();
296
+ const adapter = getAgentAdapter(node.agent);
297
297
  const runtime = buildRuntime(node);
298
298
  const capabilities = getCapabilities(adapter);
299
299
 
@@ -494,6 +494,19 @@ module.exports = function (RED) {
494
494
  // Debug node to output 1 and inspect this field.
495
495
  errorDetail: result.errorDetail,
496
496
  };
497
+ // Explicit resume-outcome signal (issue #41): compares the
498
+ // ORIGINALLY-requested sessionID (resolved.sessionID, before any
499
+ // internal reask/retry continuity mutation) against the FINAL
500
+ // result.sessionID once the whole execution has settled. Omitted
501
+ // entirely (never emitted as undefined) when no resume was ever
502
+ // requested or this adapter doesn't support it -- same
503
+ // omit-rather-than-emit convention as costUsd/tokens below.
504
+ const resumed = resumeOutcome(
505
+ resolved.sessionID,
506
+ result.sessionID,
507
+ capabilities.sessionResume,
508
+ );
509
+ if (resumed !== undefined) agentExecution.resumed = resumed;
497
510
  if (structuredEnabled && result.structuredOutput !== undefined) {
498
511
  agentExecution.structuredOutput = result.structuredOutput;
499
512
  agentExecution.declaredFields = Object.keys(
@@ -705,9 +718,9 @@ module.exports = function (RED) {
705
718
  node.invocation !== "prompt"
706
719
  ? (() => {
707
720
  const raw = resolveTyped(node.arguments_, node.argumentsType, msg, msg.payload);
708
- if (typeof raw !== "string" || node.inputs.length === 0) return raw;
721
+ if (typeof raw !== "string" || node.promptArgs.length === 0) return raw;
709
722
  const inputsMap = {};
710
- node.inputs.forEach((entry) => {
723
+ node.promptArgs.forEach((entry) => {
711
724
  inputsMap[entry.name] = resolveTyped(
712
725
  entry.value,
713
726
  entry.valueType || "msg",
@@ -772,7 +785,7 @@ module.exports = function (RED) {
772
785
  return;
773
786
  }
774
787
 
775
- if (!AGENTS[node.agent]) {
788
+ if (!isRegisteredAgent(node.agent)) {
776
789
  node.lastTerminal = "failed";
777
790
  node.lastText = "unknown agent";
778
791
  updateStatus();
@@ -0,0 +1,318 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { AgentAdapter } = require("./base");
6
+ const { toCopilotMcp } = require("../mcp/normalize");
7
+
8
+ // Maps the real Copilot CLI `--output-format json` event stream (verified
9
+ // empirically against `copilot` v1.0.86) onto the Agent node's generic
10
+ // event vocabulary. Deliberately coarser than the CLI's own granularity:
11
+ // `assistant.message_delta` (streamed fragments) is skipped in favor of
12
+ // the single completed `assistant.message` event, same rationale as
13
+ // pi.js's *_delta skipping.
14
+ const TYPE_MAP = {
15
+ "session.mcp_server_status_changed": "progress",
16
+ "session.mcp_servers_loaded": "progress",
17
+ "session.tools_updated": "progress",
18
+ "user.message": "progress",
19
+ "assistant.turn_start": "started",
20
+ "model.call_start": "progress",
21
+ "assistant.message_start": "progress",
22
+ "assistant.message_delta": "progress",
23
+ "tool.execution_start": "tool",
24
+ "tool.execution_complete": "tool",
25
+ "model.call_finished": "progress",
26
+ "assistant.message": "agent",
27
+ "assistant.turn_end": "progress",
28
+ "session.usage_checkpoint": "progress",
29
+ "assistant.idle": "progress",
30
+ // Not directly observed in a successful transcript (issue #46/Task 1
31
+ // did not surface a `session.error` event in the empirically verified
32
+ // failure paths -- bad model/resume instead exit non-zero with zero or
33
+ // partial JSON stdout, handled in parseResult()'s exitCode branch), but
34
+ // kept as a defensive mapping in case the CLI ever does emit one.
35
+ "session.error": "failed",
36
+ };
37
+
38
+ // Copilot has no `--skill`/`--command` CLI flag (verified against
39
+ // `copilot --help`) -- mirrors pi.js's directory-convention resolution
40
+ // for turning a bare invocationName into a real file path so it can be
41
+ // spelled out in the synthesized prompt instruction. Re-implemented
42
+ // locally (not imported from pi.js) per the plan's "duplication here is
43
+ // acceptable, keep changes minimal" guidance.
44
+ const RESOURCE_DIRS = {
45
+ skill: ".github/skills",
46
+ command: ".github/prompts",
47
+ };
48
+
49
+ function resolveResourcePath(name, kind, cwd) {
50
+ const base = cwd || process.cwd();
51
+ const candidates = [];
52
+
53
+ if (path.isAbsolute(name)) {
54
+ candidates.push(name);
55
+ } else if (name.includes("/") || name.endsWith(".md")) {
56
+ candidates.push(path.join(base, name));
57
+ }
58
+
59
+ const dir = RESOURCE_DIRS[kind];
60
+ candidates.push(path.join(base, dir, name, "SKILL.md"));
61
+ candidates.push(path.join(base, dir, `${name}.md`));
62
+
63
+ for (const candidate of candidates) {
64
+ if (fs.existsSync(candidate)) return candidate;
65
+ }
66
+ throw new Error(
67
+ `could not find a ${kind} named "${name}" (looked for: ${candidates.join(", ")})`,
68
+ );
69
+ }
70
+
71
+ class CopilotAdapter extends AgentAdapter {
72
+ validate(resolved) {
73
+ // Copilot model ids are bare (e.g. "claude-sonnet-5", "gpt-5.4"), not
74
+ // "provider/model" like OpenCode's -- deliberately does NOT call
75
+ // assertModelFormat() here (that check is OpenCode-specific).
76
+
77
+ if (resolved.cwd) {
78
+ let stat;
79
+ try {
80
+ stat = fs.statSync(resolved.cwd);
81
+ } catch (err) {
82
+ throw new Error(`cwd does not exist: ${resolved.cwd}`, { cause: err });
83
+ }
84
+ if (!stat.isDirectory()) {
85
+ throw new Error(`cwd is not a directory: ${resolved.cwd}`);
86
+ }
87
+ }
88
+
89
+ if (resolved.invocation === "prompt") {
90
+ if (!resolved.prompt || !String(resolved.prompt).trim()) {
91
+ throw new Error(
92
+ "prompt invocation requires a non-empty prompt (msg.payload or the Prompt field)",
93
+ );
94
+ }
95
+ } else if (resolved.invocation === "skill" || resolved.invocation === "command") {
96
+ if (!resolved.invocationName || !String(resolved.invocationName).trim()) {
97
+ throw new Error(`${resolved.invocation} invocation requires a non-empty name`);
98
+ }
99
+ // Throws its own clear error if nothing matches -- fail before
100
+ // spawning anything, per the adapter contract.
101
+ resolveResourcePath(resolved.invocationName, resolved.invocation, resolved.cwd);
102
+ } else {
103
+ throw new Error(`unknown invocation mode: ${resolved.invocation}`);
104
+ }
105
+
106
+ for (const server of resolved.mcpServers || []) {
107
+ if (!server || !server.name) {
108
+ throw new Error("mcpServers entries require a name");
109
+ }
110
+ if (server.type === "remote" && !server.url) {
111
+ throw new Error(`mcp server "${server.name}" (remote) requires a url`);
112
+ }
113
+ if (server.type === "local" && !server.command) {
114
+ throw new Error(`mcp server "${server.name}" (local) requires a command`);
115
+ }
116
+ if (server.type !== "remote" && server.type !== "local") {
117
+ throw new Error(`mcp server "${server.name}" has unknown type: ${server.type}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ buildExecution(resolved) {
123
+ const args = ["--output-format", "json", "--allow-all-tools"];
124
+
125
+ // --resume <id> is resume-ONLY: verified to hard-fail (exit 1, zero
126
+ // JSON stdout) on an unresolvable id, unlike --session-id's dual
127
+ // create-or-resume semantics (which silently succeeds/creates a new
128
+ // session on an unknown id). Never use --session-id here -- that
129
+ // would make resumeOutcome() always report a false positive.
130
+ if (resolved.sessionID) args.push("--resume", String(resolved.sessionID));
131
+
132
+ if (resolved.cwd) args.push("--add-dir", resolved.cwd);
133
+ if (resolved.model) args.push("--model", resolved.model);
134
+
135
+ // --effort/--reasoning-effort verified working against a real
136
+ // `copilot` invocation (choices: none, minimal, low, medium, high,
137
+ // xhigh, max) -- passed straight through, no clamping.
138
+ if (CopilotAdapter.CAPABILITIES.effortControl && resolved.effort) {
139
+ args.push("--effort", resolved.effort);
140
+ }
141
+
142
+ // allowedTools/deniedTools (issue #25 parity): --allow-tool/--deny-tool
143
+ // verified present in `copilot --help`; the most direct mapping of
144
+ // resolved.allowedTools/deniedTools onto the CLI's own flags.
145
+ const hasAllow =
146
+ CopilotAdapter.CAPABILITIES.toolRestrictions &&
147
+ Array.isArray(resolved.allowedTools) &&
148
+ resolved.allowedTools.length > 0;
149
+ const hasDeny =
150
+ CopilotAdapter.CAPABILITIES.toolRestrictions &&
151
+ Array.isArray(resolved.deniedTools) &&
152
+ resolved.deniedTools.length > 0;
153
+ if (hasAllow) args.push(`--allow-tool=${resolved.allowedTools.join(",")}`);
154
+ if (hasDeny) args.push(`--deny-tool=${resolved.deniedTools.join(",")}`);
155
+
156
+ // --additional-mcp-config <json>: verified schema requires a
157
+ // top-level {"mcpServers": {...}} wrapper (see toCopilotMcp()).
158
+ // KNOWN LIMITATION (not fixed here, see normalize.js's comment): the
159
+ // global ~/.copilot/mcp-config.json always loads regardless of this
160
+ // flag.
161
+ if (Array.isArray(resolved.mcpServers) && resolved.mcpServers.length > 0) {
162
+ args.push("--additional-mcp-config", JSON.stringify(toCopilotMcp(resolved.mcpServers)));
163
+ }
164
+
165
+ // No --skill/--command CLI flag exists (verified) -- synthesize a
166
+ // natural-language instruction instead, exactly like pi.js's pattern.
167
+ let message;
168
+ if (resolved.invocation === "prompt") {
169
+ message = String(resolved.prompt);
170
+ } else {
171
+ const resourcePath = resolveResourcePath(
172
+ resolved.invocationName,
173
+ resolved.invocation,
174
+ resolved.cwd,
175
+ );
176
+ const kind = resolved.invocation === "skill" ? "skill" : "prompt template";
177
+ const argsText =
178
+ resolved.args !== undefined && resolved.args !== null ? String(resolved.args) : "";
179
+ message =
180
+ `Use the "${resolved.invocationName}" ${kind} at ${resourcePath}. ${argsText}`.trim();
181
+ }
182
+ args.push("-p", message);
183
+
184
+ // Auth is fully via env vars already inherited from process.env
185
+ // (lib/execution/lifecycle.js merges these already) -- zero
186
+ // adapter-side auth code needed.
187
+ return { command: "copilot", args, env: {} };
188
+ }
189
+
190
+ parseEvent(line) {
191
+ const trimmed = line.trim();
192
+ if (!trimmed) return null;
193
+
194
+ let raw;
195
+ try {
196
+ raw = JSON.parse(trimmed);
197
+ } catch (err) {
198
+ // Malformed/non-JSON diagnostic output must never crash Node-RED.
199
+ return null;
200
+ }
201
+
202
+ if (raw.type === "result") {
203
+ return { type: "completed", sessionID: raw.sessionId, data: raw };
204
+ }
205
+
206
+ const type = TYPE_MAP[raw.type] || "progress";
207
+ return { type, sessionID: raw.sessionId, data: raw };
208
+ }
209
+
210
+ parseResult(events, exitCode, signal, stderr, resolved) {
211
+ const raw = events.map((e) => e.data);
212
+ const errorEvent = raw.find((e) => e.type === "session.error");
213
+ const resultEvent = [...raw].reverse().find((e) => e.type === "result");
214
+ const finalMessage = [...raw].reverse().find((e) => e.type === "assistant.message");
215
+
216
+ const sessionID = resultEvent
217
+ ? resultEvent.sessionId
218
+ : finalMessage
219
+ ? finalMessage.sessionId
220
+ : raw.length
221
+ ? raw[raw.length - 1].sessionId
222
+ : undefined;
223
+
224
+ const payload =
225
+ finalMessage && finalMessage.data && typeof finalMessage.data.content === "string"
226
+ ? finalMessage.data.content.trim()
227
+ : "";
228
+
229
+ const usage = summarizeUsage(resultEvent);
230
+
231
+ if (errorEvent) {
232
+ const detail = errorEvent.data || {};
233
+ const message = detail.message || "copilot reported a session error";
234
+ const extras = [];
235
+ if (stderr && String(stderr).trim()) extras.push(String(stderr).trim());
236
+ const errorMessage = extras.length ? `${message} (${extras.join("; ")})` : message;
237
+ return Object.assign(
238
+ { payload, sessionID, status: "failed", errorMessage, errorDetail: detail },
239
+ usage,
240
+ );
241
+ }
242
+ if (signal) {
243
+ return Object.assign(
244
+ {
245
+ payload,
246
+ sessionID,
247
+ status: "failed",
248
+ errorMessage: `process killed by signal ${signal}`,
249
+ },
250
+ usage,
251
+ );
252
+ }
253
+ if (exitCode !== 0) {
254
+ // Bad --model and bad --resume both verified to exit 1 with a clear
255
+ // stderr message and zero (or partial) JSON stdout -- surface
256
+ // stderr directly, same as opencode.js's exitCode!==0 branch.
257
+ const stderrText = stderr ? String(stderr).trim() : "";
258
+ let hint = "";
259
+ if (resolved && resolved.model && /model/i.test(stderrText)) {
260
+ hint = ` (possible cause: model "${resolved.model}" may not exist or isn't available)`;
261
+ }
262
+ return Object.assign(
263
+ {
264
+ payload,
265
+ sessionID,
266
+ status: "failed",
267
+ errorMessage: `exited with code ${exitCode}${stderrText ? ": " + stderrText : ""}${hint}`,
268
+ },
269
+ usage,
270
+ );
271
+ }
272
+ if (!payload) {
273
+ return Object.assign(
274
+ {
275
+ payload,
276
+ sessionID,
277
+ status: "failed",
278
+ errorMessage: "copilot produced no assistant output (silent rejection or empty response)",
279
+ },
280
+ usage,
281
+ );
282
+ }
283
+ return Object.assign({ payload, sessionID, status: "completed" }, usage);
284
+ }
285
+ }
286
+
287
+ // Reads cost/token usage from the terminal {type:"result", usage:{...}}
288
+ // event -- a different shape than opencode's per-step summing, needs its
289
+ // own field mapping. Returns {} (no keys at all) when no result event
290
+ // carried usable data, so Object.assign(...) callers never introduce
291
+ // costUsd/tokens keys with `undefined` values.
292
+ function summarizeUsage(resultEvent) {
293
+ const usage = {};
294
+ if (!resultEvent || !resultEvent.usage || typeof resultEvent.usage !== "object") return usage;
295
+ const u = resultEvent.usage;
296
+ if (typeof u.premiumRequests === "number") {
297
+ usage.tokens = {
298
+ premiumRequests: u.premiumRequests,
299
+ totalApiDurationMs: Number(u.totalApiDurationMs) || 0,
300
+ sessionDurationMs: Number(u.sessionDurationMs) || 0,
301
+ };
302
+ }
303
+ return usage;
304
+ }
305
+
306
+ CopilotAdapter.CAPABILITIES = {
307
+ sessionResume: true, // --resume=<id> verified: hard-fails (exit 1, zero stdout) on unknown id
308
+ structuredOutput: "best-effort", // no --schema/--json-schema flag found
309
+ toolRestrictions: true, // --allow-tool/--deny-tool verified present in --help
310
+ effortControl: true, // --effort/--reasoning-effort verified working
311
+ systemPromptControl: false, // no CLI flag found
312
+ costReporting: true, // terminal result.usage + session.usage_checkpoint events
313
+ };
314
+
315
+ const { registerAgent } = require("./registry");
316
+ registerAgent({ id: "copilot", factory: () => new CopilotAdapter() });
317
+
318
+ module.exports = { CopilotAdapter, resolveResourcePath };
@@ -26,7 +26,7 @@ class OpenCodeAdapter extends AgentAdapter {
26
26
  try {
27
27
  stat = fs.statSync(resolved.cwd);
28
28
  } catch (err) {
29
- throw new Error(`cwd does not exist: ${resolved.cwd}`);
29
+ throw new Error(`cwd does not exist: ${resolved.cwd}`, { cause: err });
30
30
  }
31
31
  if (!stat.isDirectory()) {
32
32
  throw new Error(`cwd is not a directory: ${resolved.cwd}`);
@@ -283,7 +283,15 @@ function summarizeUsage(raw) {
283
283
  }
284
284
 
285
285
  OpenCodeAdapter.CAPABILITIES = {
286
- sessionResume: true, // opencode.js -s/--session verified working
286
+ sessionResume: true, // opencode.js -s/--session verified working: an
287
+ // unresolvable/bogus sessionID hard-fails the CLI (exit 1, zero JSON
288
+ // events, "Session not found" on stderr) rather than silently
289
+ // falling back to a fresh session -- verified empirically with real
290
+ // CLI invocations. So `resumeOutcome()` (lib/execution/resume-
291
+ // outcome.js) reaching `false` for this adapter today always
292
+ // co-occurs with a failed execution; there is no known path where
293
+ // OpenCode reports status:"completed" after silently discarding a
294
+ // requested resume.
287
295
  structuredOutput: "best-effort", // no --schema/--json-schema CLI flag
288
296
  toolRestrictions: true, // via materialized temp agent config + --agent
289
297
  effortControl: true, // --variant, verified working
@@ -291,4 +299,7 @@ OpenCodeAdapter.CAPABILITIES = {
291
299
  costReporting: true, // step_finish tokens/cost already in --format json stream
292
300
  };
293
301
 
302
+ const { registerAgent } = require("./registry");
303
+ registerAgent({ id: "opencode", factory: () => new OpenCodeAdapter() });
304
+
294
305
  module.exports = { OpenCodeAdapter };
@@ -61,7 +61,7 @@ class PiAdapter extends AgentAdapter {
61
61
  try {
62
62
  stat = fs.statSync(resolved.cwd);
63
63
  } catch (err) {
64
- throw new Error(`cwd does not exist: ${resolved.cwd}`);
64
+ throw new Error(`cwd does not exist: ${resolved.cwd}`, { cause: err });
65
65
  }
66
66
  if (!stat.isDirectory()) {
67
67
  throw new Error(`cwd is not a directory: ${resolved.cwd}`);
@@ -279,4 +279,7 @@ PiAdapter.CAPABILITIES = {
279
279
  costReporting: false, // pi CLI not installed/verified
280
280
  };
281
281
 
282
+ const { registerAgent } = require("./registry");
283
+ registerAgent({ id: "pi", factory: () => new PiAdapter() });
284
+
282
285
  module.exports = { PiAdapter, resolveResourcePath };
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ const registry = new Map();
4
+
5
+ // entry: { id, factory }
6
+ // factory: () => AgentAdapter instance
7
+ function registerAgent(entry) {
8
+ if (registry.has(entry.id)) return; // idempotent, mirrors Archon
9
+ if (typeof entry.factory !== "function") {
10
+ throw new Error(`agent '${entry.id}': factory must be a function`);
11
+ }
12
+ registry.set(entry.id, entry);
13
+ }
14
+
15
+ function isRegisteredAgent(id) {
16
+ return registry.has(id);
17
+ }
18
+
19
+ // Throws a clear, listing error instead of returning undefined -- callers
20
+ // (agent.js) must not have to null-check every lookup site.
21
+ function getAgentAdapter(id) {
22
+ const entry = registry.get(id);
23
+ if (!entry) {
24
+ throw new Error(
25
+ `unknown agent '${id}'. Registered agents: ${[...registry.keys()].join(", ") || "(none)"}`,
26
+ );
27
+ }
28
+ return entry.factory();
29
+ }
30
+
31
+ function listAgentIds() {
32
+ return [...registry.keys()];
33
+ }
34
+
35
+ // Test-only: clears the registry so adapter-registration tests don't
36
+ // leak state across test files.
37
+ function resetRegistryForTests() {
38
+ registry.clear();
39
+ }
40
+
41
+ module.exports = {
42
+ registerAgent,
43
+ isRegisteredAgent,
44
+ getAgentAdapter,
45
+ listAgentIds,
46
+ resetRegistryForTests,
47
+ };
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+
3
+ // Computes the three-state "was a requested resume actually honored?"
4
+ // signal, mirroring Archon's shared/resumed.ts:
5
+ // undefined -- no resume was requested this invocation (fresh by design,
6
+ // or this adapter doesn't support resume at all)
7
+ // true -- resume was requested and the adapter/CLI honored it
8
+ // false -- resume was requested but did not happen (may co-occur
9
+ // with a failed execution -- see opencode.js's CAPABILITIES
10
+ // comment for the verified OpenCode-specific behavior)
11
+ //
12
+ // `requestedSessionID` is the sessionID this execution was originally
13
+ // invoked with (item.resolved.sessionID, before any internal retry/reask
14
+ // continuity mutation); `actualSessionID` is the FINAL result.sessionID
15
+ // reported by the adapter's parseResult() once the whole execution
16
+ // (including any internal retries/reasks) has settled.
17
+ function resumeOutcome(requestedSessionID, actualSessionID, sessionResumeCapable) {
18
+ if (!requestedSessionID || !sessionResumeCapable) return undefined;
19
+ return requestedSessionID === actualSessionID;
20
+ }
21
+
22
+ module.exports = { resumeOutcome };
@@ -28,4 +28,42 @@ function toOpenCodeMcp(mcpServers) {
28
28
  return out;
29
29
  }
30
30
 
31
- module.exports = { toOpenCodeMcp };
31
+ // Generic mcpServers[] -> Copilot CLI's `--additional-mcp-config` JSON
32
+ // schema, as verified against a real `copilot --additional-mcp-config
33
+ // '{"mcpServers":{...}}' -p ... --output-format json` invocation:
34
+ // { "mcpServers": { "<name>": { "type":"local","command":"npx",
35
+ // "args":["-y","pkg"], "tools":["*"] } } }
36
+ // The remote shape ({"type":"remote","url":"...","tools":["*"]}) mirrors
37
+ // the local shape's structure but was not independently verified against
38
+ // a real remote MCP server -- follows `copilot mcp add`'s own default of
39
+ // `"tools":["*"]` (allow every tool from that server).
40
+ //
41
+ // KNOWN LIMITATION (do not attempt to fix here): the global
42
+ // ~/.copilot/mcp-config.json ALWAYS loads regardless of
43
+ // --additional-mcp-config -- confirmed `--disable-builtin-mcps` does NOT
44
+ // prevent this (it only suppresses actual builtin MCPs like
45
+ // github-mcp-server). Surfacing this to the user is the adapter's job
46
+ // (see copilot.js's validate()), not this pure translation function's.
47
+ function toCopilotMcp(mcpServers) {
48
+ const out = {};
49
+ if (!Array.isArray(mcpServers)) return { mcpServers: out };
50
+
51
+ for (const server of mcpServers) {
52
+ if (!server || typeof server.name !== "string" || !server.name.trim()) continue;
53
+
54
+ if (server.type === "remote") {
55
+ if (typeof server.url !== "string" || !server.url.trim()) continue;
56
+ out[server.name] = { type: "remote", url: server.url, tools: ["*"] };
57
+ } else if (server.type === "local") {
58
+ if (typeof server.command !== "string" || !server.command.trim()) continue;
59
+ const args = Array.isArray(server.args) ? server.args : [];
60
+ out[server.name] = { type: "local", command: server.command, args, tools: ["*"] };
61
+ }
62
+ // Unknown types are silently skipped -- validate() at the adapter
63
+ // level is responsible for surfacing a clear error before execution.
64
+ }
65
+
66
+ return { mcpServers: out };
67
+ }
68
+
69
+ module.exports = { toOpenCodeMcp, toCopilotMcp };
@@ -126,7 +126,7 @@ module.exports = function (RED) {
126
126
  const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
127
127
  return value === undefined || value === null || value === "" ? fallback : value;
128
128
  } catch (err) {
129
- throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
129
+ throw new Error(`invalid ${type} property "${prop}": ${err.message}`, { cause: err });
130
130
  }
131
131
  }
132
132
 
@@ -32,9 +32,9 @@ async function request(url, opts = {}) {
32
32
  });
33
33
  } catch (err) {
34
34
  if (err.name === "AbortError") {
35
- throw new Error(`request to ${url} timed out after ${timeoutMs}ms`);
35
+ throw new Error(`request to ${url} timed out after ${timeoutMs}ms`, { cause: err });
36
36
  }
37
- throw new Error(`request to ${url} failed: ${err.message}`);
37
+ throw new Error(`request to ${url} failed: ${err.message}`, { cause: err });
38
38
  } finally {
39
39
  clearTimeout(timer);
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-agents",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",