@tbrandenburg/node-red-agents 0.4.0 → 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 +14 -4
- package/nodes/agent/agent.html +46 -21
- package/nodes/agent/agent.js +43 -16
- package/nodes/agent/lib/agents/copilot.js +318 -0
- package/nodes/agent/lib/agents/opencode.js +13 -2
- package/nodes/agent/lib/agents/pi.js +4 -1
- package/nodes/agent/lib/agents/registry.js +47 -0
- package/nodes/agent/lib/execution/inputs.js +15 -4
- package/nodes/agent/lib/execution/resume-outcome.js +22 -0
- package/nodes/agent/lib/mcp/normalize.js +39 -1
- package/nodes/agent-server/agent-server.js +1 -1
- package/nodes/agent-server/lib/http.js +2 -2
- package/package.json +1 -1
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
package/nodes/agent/agent.html
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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)
|
|
490
|
+
if (v && v.name) promptArgsOut.push(v);
|
|
484
491
|
});
|
|
485
|
-
this.
|
|
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>
|
|
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)/<name>.md</code> file, dispatched deterministically via
|
|
1002
|
-
<code>--command</code>. Pi
|
|
1003
|
-
file under <code>.github/skills/</code> (skill) or <code>.github/prompts/</code>
|
|
1004
|
-
passed via <code>--skill</code>/<code>--prompt-template</code
|
|
1005
|
-
|
|
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 <id> ...</code
|
|
1011
|
-
|
|
1022
|
+
maps to OpenCode's <code>opencode run --session <id> ...</code> or Copilot's
|
|
1023
|
+
<code>copilot --resume <id> ...</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
|
|
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
|
|
1025
|
-
<b>Allowed/ Denied tools</b> are supported by
|
|
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 <id></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>
|
package/nodes/agent/agent.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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.
|
|
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 &&
|
|
162
|
-
const capabilities = getCapabilities(
|
|
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 =
|
|
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.
|
|
721
|
+
if (typeof raw !== "string" || node.promptArgs.length === 0) return raw;
|
|
709
722
|
const inputsMap = {};
|
|
710
|
-
node.
|
|
723
|
+
node.promptArgs.forEach((entry) => {
|
|
711
724
|
inputsMap[entry.name] = resolveTyped(
|
|
712
725
|
entry.value,
|
|
713
726
|
entry.valueType || "msg",
|
|
@@ -715,7 +728,21 @@ module.exports = function (RED) {
|
|
|
715
728
|
"",
|
|
716
729
|
);
|
|
717
730
|
});
|
|
718
|
-
|
|
731
|
+
// issue #29: warn once per distinct unmatched $INPUTS.<name>
|
|
732
|
+
// token found in this run (de-duplicated so a typo'd token
|
|
733
|
+
// repeated in `arguments` doesn't spam the log) -- purely
|
|
734
|
+
// additive observability, the substituted text itself (and
|
|
735
|
+
// thus the eventual invocation) is unchanged.
|
|
736
|
+
const unmatchedNames = new Set();
|
|
737
|
+
const substituted = substituteInputs(raw, inputsMap, (name) =>
|
|
738
|
+
unmatchedNames.add(name),
|
|
739
|
+
);
|
|
740
|
+
unmatchedNames.forEach((name) => {
|
|
741
|
+
node.warn(
|
|
742
|
+
`$INPUTS.${name} has no matching 'inputs' entry and was left unsubstituted`,
|
|
743
|
+
);
|
|
744
|
+
});
|
|
745
|
+
return substituted;
|
|
719
746
|
})()
|
|
720
747
|
: undefined,
|
|
721
748
|
cwd: (() => {
|
|
@@ -758,7 +785,7 @@ module.exports = function (RED) {
|
|
|
758
785
|
return;
|
|
759
786
|
}
|
|
760
787
|
|
|
761
|
-
if (!
|
|
788
|
+
if (!isRegisteredAgent(node.agent)) {
|
|
762
789
|
node.lastTerminal = "failed";
|
|
763
790
|
node.lastText = "unknown agent";
|
|
764
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
|
+
};
|
|
@@ -7,15 +7,26 @@
|
|
|
7
7
|
// tokens are left as literal text rather than throwing, so a typo in a
|
|
8
8
|
// flow's arguments string degrades to visible-but-harmless output instead
|
|
9
9
|
// of a hard failure.
|
|
10
|
+
//
|
|
11
|
+
// issue #29: this module has no access to the Node-RED `node` object (and
|
|
12
|
+
// unit tests call it directly without a fake node), so it never warns
|
|
13
|
+
// itself -- callers that do have a `node` may pass an optional
|
|
14
|
+
// onUnmatched(name) callback, invoked for every token left as literal text
|
|
15
|
+
// (missing from inputsMap, or present but mapped to null/undefined -- both
|
|
16
|
+
// produce the same visible symptom of a literal token reaching the LLM).
|
|
10
17
|
const TOKEN_RE = /\$INPUTS\.([A-Za-z0-9_]+)/g;
|
|
11
18
|
|
|
12
|
-
function substituteInputs(text, inputsMap) {
|
|
19
|
+
function substituteInputs(text, inputsMap, onUnmatched) {
|
|
13
20
|
if (typeof text !== "string" || !text) return text;
|
|
14
21
|
const map = inputsMap || {};
|
|
15
22
|
return text.replace(TOKEN_RE, (match, name) => {
|
|
16
|
-
|
|
17
|
-
const value = map[name];
|
|
18
|
-
|
|
23
|
+
const hasName = Object.prototype.hasOwnProperty.call(map, name);
|
|
24
|
+
const value = hasName ? map[name] : undefined;
|
|
25
|
+
if (!hasName || value === undefined || value === null) {
|
|
26
|
+
if (typeof onUnmatched === "function") onUnmatched(name);
|
|
27
|
+
return match;
|
|
28
|
+
}
|
|
29
|
+
return String(value);
|
|
19
30
|
});
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -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
|
-
|
|
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
|
}
|