@tbrandenburg/node-red-agents 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/nodes/agent/agent.html +550 -0
  4. package/nodes/agent/agent.js +396 -0
  5. package/nodes/agent/icons/agent.svg +27 -0
  6. package/nodes/agent/lib/agents/base.js +42 -0
  7. package/nodes/agent/lib/agents/opencode.js +141 -0
  8. package/nodes/agent/lib/agents/pi.js +220 -0
  9. package/nodes/agent/lib/execution/lifecycle.js +69 -0
  10. package/nodes/agent/lib/execution/scheduler.js +97 -0
  11. package/nodes/agent/lib/execution/status.js +24 -0
  12. package/nodes/agent/lib/mcp/normalize.js +31 -0
  13. package/nodes/agent/lib/runtimes/base.js +21 -0
  14. package/nodes/agent/lib/runtimes/direct.js +28 -0
  15. package/nodes/agent/lib/runtimes/process-exec.js +105 -0
  16. package/nodes/agent/lib/runtimes/srt.js +63 -0
  17. package/nodes/agent-server/agent-server.html +365 -0
  18. package/nodes/agent-server/agent-server.js +481 -0
  19. package/nodes/agent-server/icons/agent.svg +27 -0
  20. package/nodes/agent-server/lib/daemon.js +149 -0
  21. package/nodes/agent-server/lib/http.js +60 -0
  22. package/nodes/agent-server/lib/model.js +20 -0
  23. package/nodes/agent-server/lib/port.js +31 -0
  24. package/nodes/agent-server/lib/registry.js +77 -0
  25. package/nodes/agent-server/lib/status.js +15 -0
  26. package/nodes/gh/README.md +75 -0
  27. package/nodes/gh/examples/list-pull-requests.json +48 -0
  28. package/nodes/gh/examples/run-workflow.json +42 -0
  29. package/nodes/gh/gh.html +146 -0
  30. package/nodes/gh/gh.js +237 -0
  31. package/nodes/gh/icons/gh.svg +15 -0
  32. package/nodes/gh/lib/parse-args.js +67 -0
  33. package/package.json +60 -0
  34. package/shared/srt-settings.js +71 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Brandenburg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # node-red-agents
2
+
3
+ Node-RED nodes for running coding agents and GitHub CLI operations from a
4
+ flow.
5
+
6
+ ## Nodes
7
+
8
+ - **agent** — runs a coding-agent CLI (OpenCode first, `pi` also
9
+ supported) either directly or sandboxed via SRT (Anthropic's
10
+ sandbox-runtime), one execution per input message.
11
+ - **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.
15
+
16
+ See each node's built-in help (Node-RED editor info panel) for
17
+ configuration details, or `nodes/gh/README.md` for `gh`-specific usage
18
+ and example flows (`nodes/gh/examples/`).
19
+
20
+ ## Requirements
21
+
22
+ - Node-RED >= 4.0.0, Node.js >= 22
23
+ - The [`opencode`](https://opencode.ai) CLI on `PATH` (for `agent`/`agent-server`)
24
+ - [`srt`](https://github.com/anthropics/sandbox-runtime) on `PATH`, only if using the SRT runtime option
25
+ - The [`gh`](https://cli.github.com) CLI on `PATH`, authenticated (for `gh`)
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ npm install @tbrandenburg/node-red-agents
31
+ ```
32
+
33
+ Then restart Node-RED, or install via the editor: **Menu -> Manage
34
+ palette -> Install tab -> search "node-red-agents"**.
@@ -0,0 +1,550 @@
1
+ <script type="text/javascript">
2
+ (function () {
3
+ 'use strict';
4
+
5
+ const MODEL_SUGGESTIONS = [
6
+ 'opencode/big-pickle',
7
+ 'opencode/deepseek-v4-flash-free',
8
+ 'opencode/hy3-free',
9
+ 'opencode/laguna-s-2.1-free',
10
+ 'opencode/mimo-v2.5-free',
11
+ 'lund-anthropic/claude-sonnet-5',
12
+ 'lund-anthropic/claude-opus-4-8',
13
+ 'lund-anthropic/claude-haiku-4-5',
14
+ 'github-copilot/claude-sonnet-5',
15
+ 'github-copilot/gpt-5.4',
16
+ 'lund-openai-gpt5/gpt-5.4',
17
+ 'lund-openai-gpt4/gpt-4.1'
18
+ ];
19
+
20
+ function mcpServerRow(container, data) {
21
+ data = data || { name: '', type: 'remote', url: '', command: '', args: [] };
22
+ const row = $('<div/>', { style: 'display:flex; gap:4px; align-items:flex-start; width:100%;' }).appendTo(container);
23
+
24
+ const nameInput = $('<input/>', { type: 'text', placeholder: 'name', style: 'width:110px' })
25
+ .val(data.name || '')
26
+ .appendTo(row);
27
+
28
+ const typeSelect = $('<select/>', { style: 'width:90px' })
29
+ .append('<option value="remote">Remote</option>')
30
+ .append('<option value="local">Local</option>')
31
+ .val(data.type || 'remote')
32
+ .appendTo(row);
33
+
34
+ const fieldsContainer = $('<div/>', { style: 'flex:1; display:flex; gap:4px;' }).appendTo(row);
35
+
36
+ function renderFields() {
37
+ fieldsContainer.empty();
38
+ if (typeSelect.val() === 'remote') {
39
+ $('<input/>', { type: 'text', placeholder: 'https://mcp.example.com/...', class: 'mcp-url', style: 'width:100%' })
40
+ .val(data.url || '')
41
+ .appendTo(fieldsContainer);
42
+ } else {
43
+ $('<input/>', { type: 'text', placeholder: 'command (e.g. npx)', class: 'mcp-command', style: 'width:35%' })
44
+ .val(data.command || '')
45
+ .appendTo(fieldsContainer);
46
+ $('<input/>', { type: 'text', placeholder: 'args (space separated)', class: 'mcp-args', style: 'width:65%' })
47
+ .val(Array.isArray(data.args) ? data.args.join(' ') : (data.args || ''))
48
+ .appendTo(fieldsContainer);
49
+ }
50
+ }
51
+ typeSelect.on('change', renderFields);
52
+ renderFields();
53
+
54
+ row.data('mcpGetValue', function () {
55
+ const type = typeSelect.val();
56
+ const out = { name: nameInput.val().trim(), type: type };
57
+ if (type === 'remote') {
58
+ out.url = fieldsContainer.find('.mcp-url').val().trim();
59
+ } else {
60
+ out.command = fieldsContainer.find('.mcp-command').val().trim();
61
+ const argsRaw = fieldsContainer.find('.mcp-args').val().trim();
62
+ out.args = argsRaw ? argsRaw.split(/\s+/) : [];
63
+ }
64
+ return out;
65
+ });
66
+ }
67
+
68
+ // Much simpler than mcpServerRow: one plain text input per row, no
69
+ // per-row type switch. Used for the SRT "Allowed domains" and
70
+ // "Allowed write directories" lists.
71
+ function simpleStringRow(container, value) {
72
+ const input = $('<input/>', { type: 'text', style: 'width:100%' }).val(value || '').appendTo(container);
73
+ container.data('getValue', function () {
74
+ return input.val().trim();
75
+ });
76
+ }
77
+
78
+ RED.nodes.registerType('agent', {
79
+ category: 'Agent',
80
+ color: '#8B5CF6',
81
+ defaults: {
82
+ name: { value: '' },
83
+ agent: { value: 'opencode' },
84
+ runtime: { value: 'direct' },
85
+ invocation: { value: 'prompt' },
86
+
87
+ model: { value: '' },
88
+ modelType: { value: 'str' },
89
+
90
+ prompt: { value: 'payload' },
91
+ promptType: { value: 'msg' },
92
+
93
+ sessionIdProp: { value: 'sessionID' },
94
+ sessionIdPropType: { value: 'msg' },
95
+
96
+ invocationName: { value: '' },
97
+ invocationNameType: { value: 'str' },
98
+
99
+ arguments: { value: 'payload' },
100
+ argumentsType: { value: 'msg' },
101
+
102
+ cwd: { value: 'cwd' },
103
+ cwdType: { value: 'msg' },
104
+
105
+ auto: { value: false },
106
+
107
+ concurrency: { value: 1, validate: RED.validators.number() },
108
+
109
+ timeout: { value: '' },
110
+ timeoutType: { value: 'num' },
111
+
112
+ mcpServers: { value: [] },
113
+
114
+ srtBinary: { value: '' },
115
+ srtSettingsMode: { value: 'file' },
116
+ srtSettingsPath: { value: '' },
117
+ srtAllowedDomains: { value: [] },
118
+ srtAllowedWriteDirs: { value: ['.', '/tmp', '~/.local/share/opencode'] },
119
+ srtStrictAllowlist: { value: true },
120
+ srtAdvancedJson: { value: '' }
121
+ },
122
+ inputs: 1,
123
+ outputs: 2,
124
+ outputLabels: ['result', 'events'],
125
+ icon: 'agent.svg',
126
+ paletteLabel: 'agent',
127
+ label: function () {
128
+ return this.name || (this.agent + ' (' + this.runtime + ')');
129
+ },
130
+ oneditprepare: function () {
131
+ const node = this;
132
+
133
+ $('#node-input-model').typedInput({
134
+ typeField: '#node-input-modelType',
135
+ types: ['str', 'msg', 'flow', 'global', 'env']
136
+ });
137
+ $('#node-input-model').attr('list', 'agent-model-suggestions');
138
+ if (!$('#agent-model-suggestions').length) {
139
+ const datalist = $('<datalist id="agent-model-suggestions"></datalist>').appendTo('body');
140
+ MODEL_SUGGESTIONS.forEach((m) => datalist.append('<option value="' + m + '">'));
141
+ }
142
+
143
+ $('#node-input-prompt').typedInput({
144
+ typeField: '#node-input-promptType',
145
+ types: ['msg', 'str', 'flow', 'global', 'env']
146
+ });
147
+ $('#node-input-sessionIdProp').typedInput({
148
+ typeField: '#node-input-sessionIdPropType',
149
+ types: ['msg', 'str', 'flow', 'global']
150
+ });
151
+ // RED.popover (Node-RED's own hover-tooltip system) rather
152
+ // than a plain HTML `title` attribute: `title` is the
153
+ // browser's native tooltip, whose appearance/delay/is even
154
+ // shown at all depends on OS/browser/accessibility settings
155
+ // outside our control (reported: not appearing at all in
156
+ // Firefox). RED.popover.create with trigger:'hover' is
157
+ // Node-RED's own JS-driven popover, used throughout its own
158
+ // editor (e.g. the debug sidebar's copy buttons) -- it
159
+ // doesn't depend on the browser's native tooltip behavior at
160
+ // all, so it renders identically regardless of browser.
161
+ RED.popover.create({
162
+ target: $('#agent-session-id-hint'),
163
+ direction: 'right',
164
+ trigger: 'hover',
165
+ size: 'small',
166
+ width: 320,
167
+ content:
168
+ 'Empty/absent &rarr; starts a <b>new</b> session (today\'s default). A <b>present</b> id ' +
169
+ 'resumes that session instead (<code>opencode run --session &lt;id&gt; ...</code>). The ' +
170
+ 'result\'s <code>msg.sessionID</code> (also under <code>agentExecution.sessionID</code>) ' +
171
+ 'can be fed straight back into this field on the next message to continue the ' +
172
+ 'conversation. OpenCode only -- Pi does not support session continuation.'
173
+ });
174
+ $('#node-input-invocationName').typedInput({
175
+ typeField: '#node-input-invocationNameType',
176
+ types: ['str', 'msg', 'flow', 'global']
177
+ });
178
+ $('#node-input-arguments').typedInput({
179
+ typeField: '#node-input-argumentsType',
180
+ types: ['msg', 'str', 'flow', 'global', 'env']
181
+ });
182
+ $('#node-input-cwd').typedInput({
183
+ typeField: '#node-input-cwdType',
184
+ types: ['msg', 'str', 'flow', 'global', 'env']
185
+ });
186
+ $('#node-input-timeout').typedInput({
187
+ typeField: '#node-input-timeoutType',
188
+ types: ['num', 'msg', 'flow', 'global', 'env']
189
+ });
190
+
191
+ const NAME_PLACEHOLDERS = {
192
+ opencode: { skill: 'code-review', command: 'review' },
193
+ pi: { skill: 'code-review (looked up under .github/skills/)', command: 'review (looked up under .github/prompts/)' }
194
+ };
195
+
196
+ function updateInvocationRows() {
197
+ const invocation = $('#node-input-invocation').val();
198
+ const agent = $('#node-input-agent').val();
199
+ $('.agent-row-prompt').toggle(invocation === 'prompt');
200
+ $('.agent-row-name').toggle(invocation !== 'prompt');
201
+ $('.agent-row-arguments').toggle(invocation !== 'prompt');
202
+ const label = invocation === 'skill' ? 'Skill' : 'Command';
203
+ $('#agent-name-label-text').text(label);
204
+ const placeholders = NAME_PLACEHOLDERS[agent] || NAME_PLACEHOLDERS.opencode;
205
+ $('#node-input-invocationName').attr('placeholder', placeholders[invocation] || '');
206
+ }
207
+ $('#node-input-invocation').on('change', updateInvocationRows);
208
+
209
+ function updateRuntimeRows() {
210
+ $('.agent-row-srt').toggle($('#node-input-runtime').val() === 'srt');
211
+ }
212
+ $('#node-input-runtime').on('change', updateRuntimeRows);
213
+
214
+ function updateSrtModeRows() {
215
+ const mode = $('#node-input-srtSettingsMode').val();
216
+ $('.agent-row-srt-file').toggle(mode === 'file');
217
+ $('.agent-row-srt-inline').toggle(mode === 'inline');
218
+ }
219
+ $('#node-input-srtSettingsMode').on('change', updateSrtModeRows);
220
+
221
+ const AUTO_HINTS = {
222
+ opencode: 'Maps to --auto: skips permission prompts for actions not explicitly denied.',
223
+ pi: 'Pi has no permission prompts to bypass. Off = read-only tools (read/grep/find/ls). On = full tool access (bash/edit/write too).'
224
+ };
225
+
226
+ // Grey out (rather than hide) fields an agent doesn't
227
+ // support, so it's clear the setting exists but won't take
228
+ // effect for the currently selected agent -- instead of the
229
+ // section silently disappearing.
230
+ function setSectionSupported($section, supported) {
231
+ $section.css({
232
+ opacity: supported ? '' : 0.45,
233
+ pointerEvents: supported ? '' : 'none'
234
+ });
235
+ $section.find('input, select, button, textarea').prop('disabled', !supported);
236
+ }
237
+
238
+ const list = $('#node-input-mcpServers-list');
239
+ list.editableList({
240
+ addButton: 'Add MCP Server',
241
+ height: 150,
242
+ addItem: function (container, index, data) {
243
+ mcpServerRow(container, data);
244
+ },
245
+ removable: true,
246
+ sortable: true
247
+ });
248
+ (node.mcpServers || []).forEach((entry) => list.editableList('addItem', entry));
249
+
250
+ const domainsList = $('#node-input-srtAllowedDomains-list');
251
+ domainsList.editableList({
252
+ addButton: 'Add domain',
253
+ height: 100,
254
+ addItem: function (container, index, value) {
255
+ simpleStringRow(container, value);
256
+ },
257
+ removable: true,
258
+ sortable: true
259
+ });
260
+ (node.srtAllowedDomains || []).forEach((entry) => domainsList.editableList('addItem', entry));
261
+
262
+ const writeDirsList = $('#node-input-srtAllowedWriteDirs-list');
263
+ writeDirsList.editableList({
264
+ addButton: 'Add directory',
265
+ height: 100,
266
+ addItem: function (container, index, value) {
267
+ simpleStringRow(container, value);
268
+ },
269
+ removable: true,
270
+ sortable: true
271
+ });
272
+ (node.srtAllowedWriteDirs || []).forEach((entry) => writeDirsList.editableList('addItem', entry));
273
+
274
+ function updateAgentRows() {
275
+ const agent = $('#node-input-agent').val();
276
+ $('#agent-auto-hint').attr('title', AUTO_HINTS[agent] || '');
277
+ const mcpSupported = agent !== 'pi';
278
+ setSectionSupported($('.agent-row-mcp'), mcpSupported);
279
+ $('.agent-row-mcp-hint').toggle(!mcpSupported);
280
+ const sessionSupported = agent !== 'pi';
281
+ setSectionSupported($('.agent-row-session'), sessionSupported);
282
+ $('.agent-row-session-hint').toggle(!sessionSupported);
283
+ updateInvocationRows();
284
+ }
285
+ $('#node-input-agent').on('change', updateAgentRows);
286
+ updateAgentRows();
287
+ updateRuntimeRows();
288
+ updateSrtModeRows();
289
+ },
290
+ oneditsave: function () {
291
+ const list = $('#node-input-mcpServers-list');
292
+ const items = list.editableList('items');
293
+ const out = [];
294
+ items.each(function () {
295
+ out.push($(this).data('mcpGetValue')());
296
+ });
297
+ this.mcpServers = out;
298
+
299
+ function collectStrings(listEl) {
300
+ const values = [];
301
+ listEl.editableList('items').each(function () {
302
+ const v = $(this).data('getValue')();
303
+ if (v) values.push(v);
304
+ });
305
+ return values;
306
+ }
307
+ this.srtAllowedDomains = collectStrings($('#node-input-srtAllowedDomains-list'));
308
+ this.srtAllowedWriteDirs = collectStrings($('#node-input-srtAllowedWriteDirs-list'));
309
+ }
310
+ });
311
+ }());
312
+ </script>
313
+
314
+ <script type="text/html" data-template-name="agent">
315
+ <div class="form-row">
316
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
317
+ <input type="text" id="node-input-name" placeholder="Agent">
318
+ </div>
319
+
320
+ <div class="form-row">
321
+ <label for="node-input-agent"><i class="fa fa-cube"></i> Agent</label>
322
+ <select id="node-input-agent" style="width:70%">
323
+ <option value="opencode">OpenCode</option>
324
+ <option value="pi">Pi</option>
325
+ </select>
326
+ </div>
327
+
328
+ <div class="form-row">
329
+ <label for="node-input-runtime"><i class="fa fa-cogs"></i> Runtime</label>
330
+ <select id="node-input-runtime" style="width:70%">
331
+ <option value="direct">Direct</option>
332
+ <option value="srt">SRT (sandboxed)</option>
333
+ </select>
334
+ </div>
335
+ <div class="form-row agent-row-srt">
336
+ <label for="node-input-srtSettingsMode"><i class="fa fa-shield"></i> SRT settings</label>
337
+ <select id="node-input-srtSettingsMode" style="width:60%">
338
+ <option value="file">File path</option>
339
+ <option value="inline">Inline</option>
340
+ </select>
341
+ </div>
342
+ <div class="form-row agent-row-srt agent-row-srt-file">
343
+ <label for="node-input-srtSettingsPath">&nbsp;</label>
344
+ <input type="text" id="node-input-srtSettingsPath" placeholder="~/.srt-settings.json (srt's own default)" style="width:60%">
345
+ </div>
346
+
347
+ <div class="form-row agent-row-srt agent-row-srt-inline">
348
+ <label>
349
+ <i class="fa fa-globe"></i> Allowed domains
350
+ <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
351
+ title="e.g. api.githubcopilot.com, opencode.ai -- the domain your configured model's API lives on."></i>
352
+ </label>
353
+ <ol id="node-input-srtAllowedDomains-list"></ol>
354
+ </div>
355
+
356
+ <div class="form-row agent-row-srt agent-row-srt-inline">
357
+ <label><i class="fa fa-folder"></i> Allowed write dirs</label>
358
+ <ol id="node-input-srtAllowedWriteDirs-list"></ol>
359
+ </div>
360
+
361
+ <div class="form-row agent-row-srt agent-row-srt-inline">
362
+ <input type="checkbox" id="node-input-srtStrictAllowlist" style="display:inline-block; width:auto; vertical-align:top;">
363
+ <label for="node-input-srtStrictAllowlist" style="width:auto;">Strict allowlist</label>
364
+ <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
365
+ title="Deny any host not in the allowed domains list. There is no interactive prompt to fall back on in non-interactive mode, so this should normally stay on."></i>
366
+ </div>
367
+
368
+ <div class="form-row agent-row-srt agent-row-srt-inline">
369
+ <details>
370
+ <summary style="cursor:pointer;">Advanced (raw JSON)</summary>
371
+ <textarea id="node-input-srtAdvancedJson" rows="4" style="width:100%; font-family:monospace;"
372
+ placeholder='If set, replaces the domains/directories above entirely, e.g. {"network":{"allowedDomains":[],"deniedDomains":[]},"filesystem":{"allowWrite":[],"denyRead":[],"denyWrite":[]}}'></textarea>
373
+ </details>
374
+ </div>
375
+
376
+ <div class="form-row agent-row-srt">
377
+ <label for="node-input-srtBinary"><i class="fa fa-terminal"></i> SRT binary</label>
378
+ <input type="text" id="node-input-srtBinary" placeholder="srt" style="width:60%">
379
+ </div>
380
+
381
+ <div class="form-row">
382
+ <label for="node-input-invocation"><i class="fa fa-play"></i> Invocation</label>
383
+ <select id="node-input-invocation" style="width:70%">
384
+ <option value="prompt">Prompt</option>
385
+ <option value="skill">Skill</option>
386
+ <option value="command">Command / Template</option>
387
+ </select>
388
+ </div>
389
+
390
+ <div class="form-row agent-row-session">
391
+ <label for="node-input-sessionIdProp"><i class="fa fa-key"></i> Session ID
392
+ <i id="agent-session-id-hint" class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"></i>
393
+ </label>
394
+ <input type="text" id="node-input-sessionIdProp" style="width:60%">
395
+ <input type="hidden" id="node-input-sessionIdPropType">
396
+ </div>
397
+ <div class="form-tips agent-row-session-hint" style="display:none;">Pi does not support session continuation; switch Agent to OpenCode to use it.</div>
398
+
399
+ <div class="form-row agent-row-prompt">
400
+ <label for="node-input-prompt"><i class="fa fa-comment"></i> Prompt</label>
401
+ <input type="text" id="node-input-prompt" placeholder="leave blank to use msg.payload" style="width:60%">
402
+ <input type="hidden" id="node-input-promptType">
403
+ </div>
404
+
405
+ <div class="form-row agent-row-name">
406
+ <label for="node-input-invocationName"><i class="fa fa-magic"></i> <span id="agent-name-label-text">Command</span></label>
407
+ <input type="text" id="node-input-invocationName" style="width:60%">
408
+ <input type="hidden" id="node-input-invocationNameType">
409
+ </div>
410
+ <div class="form-row agent-row-arguments">
411
+ <label for="node-input-arguments"><i class="fa fa-list"></i> Arguments</label>
412
+ <input type="text" id="node-input-arguments" placeholder="leave blank to use msg.payload" style="width:60%">
413
+ <input type="hidden" id="node-input-argumentsType">
414
+ </div>
415
+
416
+ <div class="form-row">
417
+ <label for="node-input-model"><i class="fa fa-microchip"></i> Model</label>
418
+ <input type="text" id="node-input-model" placeholder="leave blank for agent default" style="width:60%">
419
+ <input type="hidden" id="node-input-modelType">
420
+ </div>
421
+
422
+ <div class="form-row">
423
+ <label for="node-input-cwd"><i class="fa fa-folder-open"></i> Working directory</label>
424
+ <input type="text" id="node-input-cwd" placeholder="leave blank for Node-RED's own working directory" style="width:60%">
425
+ <input type="hidden" id="node-input-cwdType">
426
+ </div>
427
+
428
+ <div class="form-row">
429
+ <input type="checkbox" id="node-input-auto" style="display:inline-block; width:auto; vertical-align:top;">
430
+ <label for="node-input-auto" style="width:auto;">Auto approve (dangerous)</label>
431
+ <i id="agent-auto-hint" class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"></i>
432
+ </div>
433
+
434
+ <div class="form-row">
435
+ <label for="node-input-timeout"><i class="fa fa-clock-o"></i> Timeout</label>
436
+ <input type="text" id="node-input-timeout" placeholder="seconds, leave empty for no timeout" style="width:60%">
437
+ <input type="hidden" id="node-input-timeoutType">
438
+ </div>
439
+
440
+ <div class="form-row">
441
+ <label for="node-input-concurrency"><i class="fa fa-tasks"></i> Concurrency</label>
442
+ <input type="text" id="node-input-concurrency" placeholder="1" style="width:80px">
443
+ <i class="fa fa-info-circle" style="color:#888; cursor:help; margin-left:4px;"
444
+ title="Maximum parallel executions for this node. Additional incoming messages queue (FIFO) until a slot frees up. 1 = sequential."></i>
445
+ </div>
446
+
447
+ <div class="form-row agent-row-mcp">
448
+ <label><i class="fa fa-plug"></i> MCP Servers</label>
449
+ <ol id="node-input-mcpServers-list"></ol>
450
+ </div>
451
+ <div class="form-tips agent-row-mcp-hint" style="display:none;">Pi does not support MCP servers; switch Agent to OpenCode to use them.</div>
452
+ </script>
453
+
454
+ <script type="text/html" data-help-name="agent">
455
+ <p>Runs a coding agent (<b>OpenCode</b> or <b>Pi</b>) non-interactively,
456
+ either directly on this host or sandboxed through <code>srt</code>.</p>
457
+ <h3>Inputs</h3>
458
+ <dl class="message-properties">
459
+ <dt class="optional">payload <span class="property-type">string</span></dt>
460
+ <dd>Used as the prompt/arguments if the corresponding field is left at
461
+ its default (<code>msg.payload</code>).</dd>
462
+ <dt class="optional">cwd <span class="property-type">string</span></dt>
463
+ <dd>Used as the working directory if the Working directory field is
464
+ left at its default (<code>msg.cwd</code>).</dd>
465
+ <dt class="optional">sessionID <span class="property-type">string</span></dt>
466
+ <dd>Used to resume a previous session if the Session ID field is left
467
+ at its default (<code>msg.sessionID</code>). Empty/absent starts a
468
+ new session. OpenCode only.</dd>
469
+ <dt class="optional">operation <span class="property-type">string</span></dt>
470
+ <dd>Set to <code>"terminate"</code> to kill an in-flight (or still-
471
+ queued) execution of this node instance instead of starting a new
472
+ one -- see <code>executionId</code> below.</dd>
473
+ <dt class="optional">executionId <span class="property-type">string</span></dt>
474
+ <dd>Required when <code>operation</code> is <code>"terminate"</code>:
475
+ the id of the execution to kill, as returned by an earlier run in
476
+ <code>msg.agentExecution.id</code> or an Events message's
477
+ <code>executionId</code>.</dd>
478
+ </dl>
479
+ <h3>Outputs</h3>
480
+ <ol class="node-ports">
481
+ <li>Result
482
+ <dl class="message-properties">
483
+ <dt>payload <span class="property-type">string</span></dt>
484
+ <dd>The agent's final text reply.</dd>
485
+ <dt>sessionID <span class="property-type">string</span></dt>
486
+ <dd>The session this execution ran in (new or resumed). Feed
487
+ this back into the Session ID field to continue the
488
+ conversation on the next message.</dd>
489
+ <dt>agentExecution <span class="property-type">object</span></dt>
490
+ <dd>id, status, exitCode, signal, timedOut, durationMs, sessionID.</dd>
491
+ </dl>
492
+ </li>
493
+ <li>Events
494
+ <dl class="message-properties">
495
+ <dt>payload <span class="property-type">object</span></dt>
496
+ <dd>One structured event per message as the agent runs
497
+ (queued/started/tool/agent/progress/completed/failed/timeout/cancelled).</dd>
498
+ <dt>executionId <span class="property-type">string</span></dt>
499
+ <dd>Correlates events (and the eventual result) belonging to
500
+ the same execution when several run concurrently.</dd>
501
+ </dl>
502
+ </li>
503
+ </ol>
504
+ <p><b>Concurrency</b> bounds how many executions this node runs at once
505
+ (default 1 = sequential). Extra incoming messages wait in an internal
506
+ FIFO queue -- each waiting message emits a <code>queued</code> event
507
+ immediately, then <code>started</code> once a slot frees up. The node
508
+ status shows e.g. <code>2 running &middot; 3 queued</code>. Results may
509
+ complete in a different order than the messages arrived; ordering is
510
+ not guaranteed above concurrency 1. On redeploy/removal, queued
511
+ messages are cancelled (a <code>cancelled</code> event plus a Node-RED
512
+ error) and any still-running agent processes are terminated.</p>
513
+ <p><b>Terminating a specific execution on demand</b>: send a message with
514
+ <code>msg.operation = "terminate"</code> and <code>msg.executionId</code>
515
+ set to the id to kill (from a prior run's <code>msg.agentExecution.id</code>
516
+ or an Events message's <code>executionId</code>). A still-queued
517
+ execution is cancelled immediately (never spawned); an active one gets
518
+ SIGTERM to its whole process group, escalating to SIGKILL after a grace
519
+ period -- this works the same way for both the Direct and SRT runtimes.
520
+ The terminated execution's own original trigger still completes on its
521
+ own (status <code>failed</code>, with the kill signal) once the process
522
+ actually exits; this terminate trigger's own output is only the
523
+ immediate "kill requested" acknowledgement.</p>
524
+ <p><b>Auto approve</b> maps to OpenCode's <code>--auto</code> (off by
525
+ default -- without it, any action gated by a permission policy not
526
+ already set to "allow" is auto-rejected, but the run still completes).
527
+ Pi has no equivalent permission-prompt system to bypass in
528
+ non-interactive mode -- for Pi this instead restricts the available
529
+ tools to read-only (<code>read/grep/find/ls</code>) when off, and
530
+ allows everything (including <code>bash/edit/write</code>) when on.</p>
531
+ <p><b>Skill</b>/<b>Command</b> invocation: for OpenCode this is the name
532
+ of a discovered skill or a <code>.opencode/command(s)/&lt;name&gt;.md</code>
533
+ file, dispatched deterministically via <code>--command</code>. Pi has
534
+ no equivalent dispatch mechanism -- the name is resolved to a file
535
+ under <code>.github/skills/</code> (skill) or
536
+ <code>.github/prompts/</code> (command), passed via
537
+ <code>--skill</code>/<code>--prompt-template</code>, and the model is
538
+ explicitly told in the prompt to use it. Pi also does not support
539
+ configuring MCP servers through this node.</p>
540
+ <p><b>Session ID</b>: leave blank to always start a fresh session (the
541
+ default). Set it (typically from the previous result's
542
+ <code>msg.sessionID</code>) to continue that conversation instead --
543
+ maps to OpenCode's <code>opencode run --session &lt;id&gt; ...</code>.
544
+ Only the OpenCode agent supports this; Pi rejects a non-empty Session
545
+ ID (every Pi run uses <code>--no-session</code>).</p>
546
+ <p><b>SRT</b> runtime shells out to the <code>srt</code> sandbox-runtime
547
+ CLI already installed on this host; its network/filesystem policy is
548
+ configured via its own settings file (default <code>~/.srt-settings.json</code>),
549
+ not through this node.</p>
550
+ </script>