@tbrandenburg/node-red-agents 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,481 +1,518 @@
1
- 'use strict';
1
+ "use strict";
2
2
 
3
- const fs = require('fs');
4
- const { findFreePort } = require('./lib/port');
5
- const { InstanceRegistry } = require('./lib/registry');
6
- const { spawnDaemon, waitForHealthy, killDaemon } = require('./lib/daemon');
7
- const { computeNodeStatus } = require('./lib/status');
8
- const { writeInlineSettingsFile } = require('../../shared/srt-settings');
9
- const { request } = require('./lib/http');
10
- const { parseModel } = require('./lib/model');
3
+ const fs = require("fs");
4
+ const { findFreePort } = require("./lib/port");
5
+ const { InstanceRegistry } = require("./lib/registry");
6
+ const { spawnDaemon, waitForHealthy, killDaemon } = require("./lib/daemon");
7
+ const { computeNodeStatus } = require("./lib/status");
8
+ const { writeInlineSettingsFile } = require("../../shared/srt-settings");
9
+ const { request } = require("./lib/http");
10
+ const { parseModel } = require("./lib/model");
11
11
 
12
12
  module.exports = function (RED) {
13
- 'use strict';
14
-
15
- function AgentServerNode(config) {
16
- RED.nodes.createNode(this, config);
17
- const node = this;
18
-
19
- node.hostname = config.hostname || '127.0.0.1';
20
- node.opencodeBinary = config.opencodeBinary || '';
21
-
22
- node.operation = config.operation || 'message';
23
-
24
- node.sessionIdProp = config.sessionIdProp !== undefined ? config.sessionIdProp : 'sessionID';
25
- node.sessionIdPropType = config.sessionIdPropType || 'msg';
26
-
27
- node.promptProp = config.promptProp !== undefined ? config.promptProp : 'payload';
28
- node.promptPropType = config.promptPropType || 'msg';
29
-
30
- node.model = config.model || '';
31
- node.modelType = config.modelType || 'str';
32
-
33
- node.startupTimeoutMs = Number(config.startupTimeoutMs) || 15000;
34
- node.requestTimeoutMs = Number(config.requestTimeoutMs) || 120000;
35
-
36
- const maxInstancesNum = parseInt(config.maxInstances, 10);
37
- // 0 (or invalid/blank) means unlimited -- not everyone needs a cap,
38
- // and 0 reads more naturally as "no limit" than as "allow zero".
39
- node.maxInstances = Number.isFinite(maxInstancesNum) && maxInstancesNum > 0 ? maxInstancesNum : 0;
40
-
41
- node.authUsername = config.authUsername || '';
42
- node.authPassword = config.authPassword || '';
43
-
44
- node.runtime = config.runtime || 'direct';
45
- node.srtBinary = config.srtBinary || '';
46
- node.srtSettingsMode = config.srtSettingsMode || 'file';
47
- node.srtSettingsPath = config.srtSettingsPath || '';
48
- node.srtAllowedDomains = Array.isArray(config.srtAllowedDomains) ? config.srtAllowedDomains : [];
49
- node.srtAllowedWriteDirs = Array.isArray(config.srtAllowedWriteDirs) ? config.srtAllowedWriteDirs : [];
50
- node.srtStrictAllowlist = config.srtStrictAllowlist !== false;
51
- node.srtAdvancedJson = config.srtAdvancedJson || '';
52
-
53
- // Resolved once at construction time, same pattern as the `agent`
54
- // node -- these settings don't change without a redeploy.
55
- node.resolvedSrtSettingsPath = undefined;
56
- node.srtTempSettingsFile = undefined;
57
- node.srtSettingsError = undefined;
58
-
59
- if (node.runtime === 'srt') {
60
- if (node.srtSettingsMode === 'inline') {
61
- try {
62
- node.resolvedSrtSettingsPath = writeInlineSettingsFile(node.id, {
63
- allowedDomains: node.srtAllowedDomains,
64
- allowedWriteDirs: node.srtAllowedWriteDirs,
65
- strictAllowlist: node.srtStrictAllowlist,
66
- advancedJson: node.srtAdvancedJson
67
- }, 'agent-server-srt-settings');
68
- node.srtTempSettingsFile = node.resolvedSrtSettingsPath;
69
- } catch (err) {
70
- node.srtSettingsError = `invalid inline SRT settings JSON: ${err.message}`;
71
- node.error(`agent-server: ${node.srtSettingsError}`);
72
- node.status({ fill: 'red', shape: 'ring', text: 'bad srt settings' });
73
- }
74
- } else {
75
- node.resolvedSrtSettingsPath = node.srtSettingsPath || undefined;
76
- }
13
+ "use strict";
14
+
15
+ function AgentServerNode(config) {
16
+ RED.nodes.createNode(this, config);
17
+ const node = this;
18
+
19
+ node.hostname = config.hostname || "127.0.0.1";
20
+ node.opencodeBinary = config.opencodeBinary || "";
21
+
22
+ node.operation = config.operation || "message";
23
+
24
+ node.sessionIdProp = config.sessionIdProp !== undefined ? config.sessionIdProp : "sessionID";
25
+ node.sessionIdPropType = config.sessionIdPropType || "msg";
26
+
27
+ node.promptProp = config.promptProp !== undefined ? config.promptProp : "payload";
28
+ node.promptPropType = config.promptPropType || "msg";
29
+
30
+ node.model = config.model || "";
31
+ node.modelType = config.modelType || "str";
32
+
33
+ node.startupTimeoutMs = Number(config.startupTimeoutMs) || 15000;
34
+ node.requestTimeoutMs = Number(config.requestTimeoutMs) || 120000;
35
+
36
+ const maxInstancesNum = parseInt(config.maxInstances, 10);
37
+ // 0 (or invalid/blank) means unlimited -- not everyone needs a cap,
38
+ // and 0 reads more naturally as "no limit" than as "allow zero".
39
+ node.maxInstances =
40
+ Number.isFinite(maxInstancesNum) && maxInstancesNum > 0 ? maxInstancesNum : 0;
41
+
42
+ node.authUsername = config.authUsername || "";
43
+ node.authPassword = config.authPassword || "";
44
+
45
+ node.runtime = config.runtime || "direct";
46
+ node.srtBinary = config.srtBinary || "";
47
+ node.srtSettingsMode = config.srtSettingsMode || "file";
48
+ node.srtSettingsPath = config.srtSettingsPath || "";
49
+ node.srtAllowedDomains = Array.isArray(config.srtAllowedDomains)
50
+ ? config.srtAllowedDomains
51
+ : [];
52
+ node.srtAllowedWriteDirs = Array.isArray(config.srtAllowedWriteDirs)
53
+ ? config.srtAllowedWriteDirs
54
+ : [];
55
+ node.srtStrictAllowlist = config.srtStrictAllowlist !== false;
56
+ node.srtAdvancedJson = config.srtAdvancedJson || "";
57
+
58
+ // Resolved once at construction time, same pattern as the `agent`
59
+ // node -- these settings don't change without a redeploy.
60
+ node.resolvedSrtSettingsPath = undefined;
61
+ node.srtTempSettingsFile = undefined;
62
+ node.srtSettingsError = undefined;
63
+
64
+ if (node.runtime === "srt") {
65
+ if (node.srtSettingsMode === "inline") {
66
+ try {
67
+ node.resolvedSrtSettingsPath = writeInlineSettingsFile(
68
+ node.id,
69
+ {
70
+ allowedDomains: node.srtAllowedDomains,
71
+ allowedWriteDirs: node.srtAllowedWriteDirs,
72
+ strictAllowlist: node.srtStrictAllowlist,
73
+ advancedJson: node.srtAdvancedJson,
74
+ },
75
+ "agent-server-srt-settings",
76
+ );
77
+ node.srtTempSettingsFile = node.resolvedSrtSettingsPath;
78
+ } catch (err) {
79
+ node.srtSettingsError = `invalid inline SRT settings JSON: ${err.message}`;
80
+ node.error(`agent-server: ${node.srtSettingsError}`);
81
+ node.status({ fill: "red", shape: "ring", text: "bad srt settings" });
77
82
  }
83
+ } else {
84
+ node.resolvedSrtSettingsPath = node.srtSettingsPath || undefined;
85
+ }
86
+ }
78
87
 
79
- node.registry = new InstanceRegistry();
88
+ node.registry = new InstanceRegistry();
80
89
 
81
- function updateStatus() {
82
- node.status(computeNodeStatus(node.registry.summary()));
83
- }
90
+ function updateStatus() {
91
+ node.status(computeNodeStatus(node.registry.summary()));
92
+ }
84
93
 
85
- // Envelope shared by every message on output 2 -- same shape/intent
86
- // as the `agent` node's lifecycle envelope (topic correlation + live
87
- // counts + timestamp), just counting daemons/sessions instead of
88
- // executions. This is what a downstream ui-table/context-aggregator
89
- // uses to build a "what has been running" history.
90
- function emitEvent(sessionID, type, msg) {
91
- const summary = node.registry.summary();
92
- node.send([
93
- null,
94
- {
95
- _msgid: msg && msg._msgid,
96
- topic: msg && msg.topic,
97
- payload: { type },
98
- sessionID,
99
- active: summary.busy,
100
- tracked: summary.total,
101
- timestamp: Date.now()
102
- }
103
- ]);
104
- }
94
+ // Envelope shared by every message on output 2 -- same shape/intent
95
+ // as the `agent` node's lifecycle envelope (topic correlation + live
96
+ // counts + timestamp), just counting daemons/sessions instead of
97
+ // executions. This is what a downstream ui-table/context-aggregator
98
+ // uses to build a "what has been running" history.
99
+ function emitEvent(sessionID, type, msg) {
100
+ const summary = node.registry.summary();
101
+ node.send([
102
+ null,
103
+ {
104
+ _msgid: msg && msg._msgid,
105
+ topic: msg && msg.topic,
106
+ payload: { type },
107
+ sessionID,
108
+ active: summary.busy,
109
+ tracked: summary.total,
110
+ timestamp: Date.now(),
111
+ },
112
+ ]);
113
+ }
105
114
 
106
- function resolveTyped(prop, type, msg, fallback) {
107
- if (prop === '') return fallback;
108
- try {
109
- const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
110
- return value === undefined || value === null || value === '' ? fallback : value;
111
- } catch (err) {
112
- throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
113
- }
114
- }
115
+ function resolveTyped(prop, type, msg, fallback) {
116
+ if (prop === "") return fallback;
117
+ try {
118
+ const value = RED.util.evaluateNodeProperty(prop, type, node, msg);
119
+ return value === undefined || value === null || value === "" ? fallback : value;
120
+ } catch (err) {
121
+ throw new Error(`invalid ${type} property "${prop}": ${err.message}`);
122
+ }
123
+ }
115
124
 
116
- function authOptions() {
117
- return node.authUsername || node.authPassword
118
- ? { username: node.authUsername, password: node.authPassword }
119
- : {};
120
- }
125
+ function authOptions() {
126
+ return node.authUsername || node.authPassword
127
+ ? { username: node.authUsername, password: node.authPassword }
128
+ : {};
129
+ }
121
130
 
122
- // Extracts the assistant's text reply the same way the `agent`
123
- // node's OpenCodeAdapter does: join every text-type part.
124
- function extractText(messageResponse) {
125
- const parts = (messageResponse && messageResponse.parts) || [];
126
- return parts
127
- .filter((p) => p.type === 'text' && typeof p.text === 'string')
128
- .map((p) => p.text)
129
- .join('\n')
130
- .trim();
131
- }
131
+ // Extracts the assistant's text reply the same way the `agent`
132
+ // node's OpenCodeAdapter does: join every text-type part.
133
+ function extractText(messageResponse) {
134
+ const parts = (messageResponse && messageResponse.parts) || [];
135
+ return parts
136
+ .filter((p) => p.type === "text" && typeof p.text === "string")
137
+ .map((p) => p.text)
138
+ .join("\n")
139
+ .trim();
140
+ }
132
141
 
133
- // Spawns a brand-new daemon + session, registers it, and resolves
134
- // with { sessionID, baseUrl }. Cleans up (kills the half-started
135
- // process) and rejects on any failure along the way -- callers
136
- // never have to clean up a partially-started daemon themselves.
137
- async function spawnNewInstance(msg) {
138
- if (node.maxInstances > 0 && node.registry.size() >= node.maxInstances) {
139
- throw new Error(`agent-server: max instances (${node.maxInstances}) reached, cannot spawn a new daemon`);
140
- }
141
-
142
- const port = await findFreePort(node.hostname);
143
- const baseUrl = `http://${node.hostname}:${port}`;
144
- const auth = authOptions();
145
-
146
- const env = Object.assign({}, process.env);
147
- if (node.authUsername || node.authPassword) {
148
- env.OPENCODE_SERVER_PASSWORD = node.authPassword;
149
- if (node.authUsername) env.OPENCODE_SERVER_USERNAME = node.authUsername;
150
- }
151
-
152
- const { child, diagnostics } = spawnDaemon({
153
- binary: node.opencodeBinary || undefined,
154
- hostname: node.hostname,
155
- port,
156
- env,
157
- srt: {
158
- enabled: node.runtime === 'srt',
159
- binary: node.srtBinary || undefined,
160
- settingsPath: node.resolvedSrtSettingsPath
161
- }
162
- });
163
-
164
- try {
165
- await waitForHealthy(baseUrl, {
166
- timeoutMs: node.startupTimeoutMs,
167
- diagnostics,
168
- ...auth
169
- });
170
- } catch (err) {
171
- await killDaemon(child);
172
- throw err;
173
- }
174
-
175
- let session;
176
- try {
177
- session = await request(`${baseUrl}/session`, {
178
- method: 'POST',
179
- body: { title: (msg && msg.topic) || 'agent-server' },
180
- timeoutMs: node.requestTimeoutMs,
181
- ...auth
182
- });
183
- } catch (err) {
184
- await killDaemon(child);
185
- throw err;
186
- }
187
-
188
- const sessionID = session.id;
189
- node.registry.register(sessionID, { child, host: node.hostname, port, baseUrl });
190
- emitEvent(sessionID, 'spawned', msg);
191
- updateStatus();
192
- return { sessionID, baseUrl };
193
- }
142
+ // Spawns a brand-new daemon + session, registers it, and resolves
143
+ // with { sessionID, baseUrl }. Cleans up (kills the half-started
144
+ // process) and rejects on any failure along the way -- callers
145
+ // never have to clean up a partially-started daemon themselves.
146
+ async function spawnNewInstance(msg) {
147
+ if (node.maxInstances > 0 && node.registry.size() >= node.maxInstances) {
148
+ throw new Error(
149
+ `agent-server: max instances (${node.maxInstances}) reached, cannot spawn a new daemon`,
150
+ );
151
+ }
152
+
153
+ const port = await findFreePort(node.hostname);
154
+ const baseUrl = `http://${node.hostname}:${port}`;
155
+ const auth = authOptions();
156
+
157
+ const env = Object.assign({}, process.env);
158
+ if (node.authUsername || node.authPassword) {
159
+ env.OPENCODE_SERVER_PASSWORD = node.authPassword;
160
+ if (node.authUsername) env.OPENCODE_SERVER_USERNAME = node.authUsername;
161
+ }
162
+
163
+ const { child, diagnostics } = spawnDaemon({
164
+ binary: node.opencodeBinary || undefined,
165
+ hostname: node.hostname,
166
+ port,
167
+ env,
168
+ srt: {
169
+ enabled: node.runtime === "srt",
170
+ binary: node.srtBinary || undefined,
171
+ settingsPath: node.resolvedSrtSettingsPath,
172
+ },
173
+ });
174
+
175
+ try {
176
+ await waitForHealthy(baseUrl, {
177
+ timeoutMs: node.startupTimeoutMs,
178
+ diagnostics,
179
+ ...auth,
180
+ });
181
+ } catch (err) {
182
+ await killDaemon(child);
183
+ throw err;
184
+ }
185
+
186
+ let session;
187
+ try {
188
+ session = await request(`${baseUrl}/session`, {
189
+ method: "POST",
190
+ body: { title: (msg && msg.topic) || "agent-server" },
191
+ timeoutMs: node.requestTimeoutMs,
192
+ ...auth,
193
+ });
194
+ } catch (err) {
195
+ await killDaemon(child);
196
+ throw err;
197
+ }
198
+
199
+ const sessionID = session.id;
200
+ node.registry.register(sessionID, { child, host: node.hostname, port, baseUrl });
201
+ emitEvent(sessionID, "spawned", msg);
202
+ updateStatus();
203
+ return { sessionID, baseUrl };
204
+ }
194
205
 
195
- // Shared by both the "spawn new" and "reuse existing" paths.
196
- async function sendMessage(sessionID, baseUrl, prompt, model, msg, send, done) {
197
- node.registry.setBusy(sessionID, true);
198
- emitEvent(sessionID, 'running', msg);
199
- updateStatus();
200
-
201
- const auth = authOptions();
202
- // process.hrtime.bigint() rather than Date.now() for the
203
- // duration measurement specifically: it's monotonic, so it
204
- // can't ever go negative from a wall-clock adjustment mid-call
205
- // (observed once in this sandbox's WSL2 VM). timestamp fields
206
- // elsewhere deliberately stay Date.now() -- those are for
207
- // human-readable/correlatable wall-clock history, not duration.
208
- const startedAtNs = process.hrtime.bigint();
209
- try {
210
- const body = {
211
- agent: msg.agent || 'build',
212
- parts: [{ type: 'text', text: String(prompt) }]
213
- };
214
- if (model) body.model = model;
215
- const response = await request(`${baseUrl}/session/${sessionID}/message`, {
216
- method: 'POST',
217
- body,
218
- timeoutMs: node.requestTimeoutMs,
219
- ...auth
220
- });
221
-
222
- node.registry.setBusy(sessionID, false);
223
- const record = node.registry.get(sessionID);
224
- const durationMs = Number((process.hrtime.bigint() - startedAtNs) / 1000000n);
225
- const resultMsg = Object.assign({}, msg, {
226
- payload: extractText(response),
227
- sessionID,
228
- agentServer: {
229
- sessionID,
230
- host: record && record.host,
231
- port: record && record.port,
232
- durationMs
233
- }
234
- });
235
- send([resultMsg, null]);
236
- emitEvent(sessionID, 'completed', msg);
237
- updateStatus();
238
- done();
239
- } catch (err) {
240
- node.registry.setBusy(sessionID, false);
241
- emitEvent(sessionID, 'failed', msg);
242
- updateStatus();
243
- done(new Error(`agent-server: message to session "${sessionID}" failed: ${err.message}`));
244
- }
245
- }
206
+ // Shared by both the "spawn new" and "reuse existing" paths.
207
+ async function sendMessage(sessionID, baseUrl, prompt, model, msg, send, done) {
208
+ node.registry.setBusy(sessionID, true);
209
+ emitEvent(sessionID, "running", msg);
210
+ updateStatus();
211
+
212
+ const auth = authOptions();
213
+ // process.hrtime.bigint() rather than Date.now() for the
214
+ // duration measurement specifically: it's monotonic, so it
215
+ // can't ever go negative from a wall-clock adjustment mid-call
216
+ // (observed once in this sandbox's WSL2 VM). timestamp fields
217
+ // elsewhere deliberately stay Date.now() -- those are for
218
+ // human-readable/correlatable wall-clock history, not duration.
219
+ const startedAtNs = process.hrtime.bigint();
220
+ try {
221
+ const body = {
222
+ agent: msg.agent || "build",
223
+ parts: [{ type: "text", text: String(prompt) }],
224
+ };
225
+ if (model) body.model = model;
226
+ const response = await request(`${baseUrl}/session/${sessionID}/message`, {
227
+ method: "POST",
228
+ body,
229
+ timeoutMs: node.requestTimeoutMs,
230
+ ...auth,
231
+ });
246
232
 
247
- function handleMessageOperation(msg, send, done) {
248
- let sessionID;
249
- try {
250
- sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
251
- } catch (err) {
252
- done(err);
253
- return;
254
- }
255
-
256
- let prompt;
257
- try {
258
- prompt = resolveTyped(node.promptProp, node.promptPropType, msg, undefined);
259
- } catch (err) {
260
- done(err);
261
- return;
262
- }
263
- if (prompt === undefined || prompt === null || String(prompt).trim() === '') {
264
- done(new Error('agent-server: no prompt (msg.payload or the configured Prompt field is empty)'));
265
- return;
266
- }
267
-
268
- let model;
269
- try {
270
- model = parseModel(resolveTyped(node.model, node.modelType, msg, ''));
271
- } catch (err) {
272
- done(new Error(`agent-server: ${err.message}`));
273
- return;
274
- }
275
-
276
- if (sessionID) {
277
- // Foreign sessionID -- not tracked by this node instance --
278
- // is always an error, never a fallback spawn/resume.
279
- if (!node.registry.has(sessionID)) {
280
- done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
281
- return;
282
- }
283
- const record = node.registry.get(sessionID);
284
- if (record.busy) {
285
- done(new Error(`agent-server: session "${sessionID}" is already processing a message`));
286
- return;
287
- }
288
- sendMessage(sessionID, record.baseUrl, prompt, model, msg, send, done);
289
- return;
290
- }
291
-
292
- spawnNewInstance(msg)
293
- .then(({ sessionID: newSessionID, baseUrl }) => sendMessage(newSessionID, baseUrl, prompt, model, msg, send, done))
294
- .catch((err) => {
295
- updateStatus();
296
- done(err);
297
- });
298
- }
233
+ node.registry.setBusy(sessionID, false);
234
+ const record = node.registry.get(sessionID);
235
+ const durationMs = Number((process.hrtime.bigint() - startedAtNs) / 1000000n);
236
+ const resultMsg = Object.assign({}, msg, {
237
+ payload: extractText(response),
238
+ sessionID,
239
+ agentServer: {
240
+ sessionID,
241
+ host: record && record.host,
242
+ port: record && record.port,
243
+ durationMs,
244
+ },
245
+ });
246
+ send([resultMsg, null]);
247
+ emitEvent(sessionID, "completed", msg);
248
+ updateStatus();
249
+ done();
250
+ } catch (err) {
251
+ node.registry.setBusy(sessionID, false);
252
+ emitEvent(sessionID, "failed", msg);
253
+ updateStatus();
254
+ done(new Error(`agent-server: message to session "${sessionID}" failed: ${err.message}`));
255
+ }
256
+ }
299
257
 
300
- function handleStatusOperation(msg, send, done) {
301
- let sessionID;
302
- try {
303
- sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
304
- } catch (err) {
305
- done(err);
306
- return;
307
- }
308
-
309
- if (!sessionID) {
310
- // Aggregate across every daemon this node instance is
311
- // tracking -- purely local, no network calls.
312
- send([Object.assign({}, msg, { payload: node.registry.summary() }), null]);
313
- done();
314
- return;
315
- }
316
-
317
- if (!node.registry.has(sessionID)) {
318
- done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
319
- return;
320
- }
321
-
322
- const record = node.registry.get(sessionID);
323
- send([
324
- Object.assign({}, msg, {
325
- payload: {
326
- sessionID,
327
- busy: record.busy,
328
- host: record.host,
329
- port: record.port,
330
- startedAt: record.startedAt,
331
- lastUsed: record.lastUsed
332
- }
333
- }),
334
- null
335
- ]);
336
- done();
258
+ function handleMessageOperation(msg, send, done) {
259
+ let sessionID;
260
+ try {
261
+ sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
262
+ } catch (err) {
263
+ done(err);
264
+ return;
265
+ }
266
+
267
+ let prompt;
268
+ try {
269
+ prompt = resolveTyped(node.promptProp, node.promptPropType, msg, undefined);
270
+ } catch (err) {
271
+ done(err);
272
+ return;
273
+ }
274
+ if (prompt === undefined || prompt === null || String(prompt).trim() === "") {
275
+ done(
276
+ new Error(
277
+ "agent-server: no prompt (msg.payload or the configured Prompt field is empty)",
278
+ ),
279
+ );
280
+ return;
281
+ }
282
+
283
+ let model;
284
+ try {
285
+ model = parseModel(resolveTyped(node.model, node.modelType, msg, ""));
286
+ } catch (err) {
287
+ done(new Error(`agent-server: ${err.message}`));
288
+ return;
289
+ }
290
+
291
+ if (sessionID) {
292
+ // Foreign sessionID -- not tracked by this node instance --
293
+ // is always an error, never a fallback spawn/resume.
294
+ if (!node.registry.has(sessionID)) {
295
+ done(
296
+ new Error(
297
+ `agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`,
298
+ ),
299
+ );
300
+ return;
337
301
  }
338
-
339
- function handleAbortOperation(msg, send, done) {
340
- let sessionID;
341
- try {
342
- sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
343
- } catch (err) {
344
- done(err);
345
- return;
346
- }
347
- if (!sessionID || !node.registry.has(sessionID)) {
348
- done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
349
- return;
350
- }
351
-
352
- const record = node.registry.get(sessionID);
353
- request(`${record.baseUrl}/session/${sessionID}/abort`, {
354
- method: 'POST',
355
- timeoutMs: node.requestTimeoutMs,
356
- ...authOptions()
357
- })
358
- .then(() => {
359
- node.registry.setBusy(sessionID, false);
360
- updateStatus();
361
- send([Object.assign({}, msg, { payload: true, sessionID }), null]);
362
- done();
363
- })
364
- .catch((err) => done(new Error(`agent-server: abort failed: ${err.message}`)));
302
+ const record = node.registry.get(sessionID);
303
+ if (record.busy) {
304
+ done(new Error(`agent-server: session "${sessionID}" is already processing a message`));
305
+ return;
365
306
  }
307
+ sendMessage(sessionID, record.baseUrl, prompt, model, msg, send, done);
308
+ return;
309
+ }
310
+
311
+ spawnNewInstance(msg)
312
+ .then(({ sessionID: newSessionID, baseUrl }) =>
313
+ sendMessage(newSessionID, baseUrl, prompt, model, msg, send, done),
314
+ )
315
+ .catch((err) => {
316
+ updateStatus();
317
+ done(err);
318
+ });
319
+ }
366
320
 
367
- function handleHistoryOperation(msg, send, done) {
368
- let sessionID;
369
- try {
370
- sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
371
- } catch (err) {
372
- done(err);
373
- return;
374
- }
375
- if (!sessionID || !node.registry.has(sessionID)) {
376
- done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
377
- return;
378
- }
379
-
380
- const record = node.registry.get(sessionID);
381
- request(`${record.baseUrl}/session/${sessionID}/message`, {
382
- timeoutMs: node.requestTimeoutMs,
383
- ...authOptions()
384
- })
385
- .then((history) => {
386
- send([Object.assign({}, msg, { payload: history, sessionID }), null]);
387
- done();
388
- })
389
- .catch((err) => done(new Error(`agent-server: history fetch failed: ${err.message}`)));
390
- }
321
+ function handleStatusOperation(msg, send, done) {
322
+ let sessionID;
323
+ try {
324
+ sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
325
+ } catch (err) {
326
+ done(err);
327
+ return;
328
+ }
329
+
330
+ if (!sessionID) {
331
+ // Aggregate across every daemon this node instance is
332
+ // tracking -- purely local, no network calls.
333
+ send([Object.assign({}, msg, { payload: node.registry.summary() }), null]);
334
+ done();
335
+ return;
336
+ }
337
+
338
+ if (!node.registry.has(sessionID)) {
339
+ done(
340
+ new Error(
341
+ `agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`,
342
+ ),
343
+ );
344
+ return;
345
+ }
346
+
347
+ const record = node.registry.get(sessionID);
348
+ send([
349
+ Object.assign({}, msg, {
350
+ payload: {
351
+ sessionID,
352
+ busy: record.busy,
353
+ host: record.host,
354
+ port: record.port,
355
+ startedAt: record.startedAt,
356
+ lastUsed: record.lastUsed,
357
+ },
358
+ }),
359
+ null,
360
+ ]);
361
+ done();
362
+ }
391
363
 
392
- // Kills the daemon process itself, unlike `abort` (which only
393
- // HTTP-cancels the in-flight message and leaves the daemon running,
394
- // reusable for a later message). After this, the sessionID is no
395
- // longer tracked -- a later message with this sessionID is a foreign/
396
- // unknown id, same as if it had never been spawned.
397
- function handleTerminateOperation(msg, send, done) {
398
- let sessionID;
399
- try {
400
- sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
401
- } catch (err) {
402
- done(err);
403
- return;
404
- }
405
- if (!sessionID || !node.registry.has(sessionID)) {
406
- done(new Error(`agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`));
407
- return;
408
- }
409
-
410
- const record = node.registry.get(sessionID);
411
- node.registry.delete(sessionID);
412
- killDaemon(record.child)
413
- .then(() => {
414
- emitEvent(sessionID, 'terminated', msg);
415
- updateStatus();
416
- send([Object.assign({}, msg, { payload: true, sessionID }), null]);
417
- done();
418
- })
419
- .catch((err) => done(new Error(`agent-server: terminate failed: ${err.message}`)));
420
- }
364
+ function handleAbortOperation(msg, send, done) {
365
+ let sessionID;
366
+ try {
367
+ sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
368
+ } catch (err) {
369
+ done(err);
370
+ return;
371
+ }
372
+ if (!sessionID || !node.registry.has(sessionID)) {
373
+ done(
374
+ new Error(
375
+ `agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`,
376
+ ),
377
+ );
378
+ return;
379
+ }
380
+
381
+ const record = node.registry.get(sessionID);
382
+ request(`${record.baseUrl}/session/${sessionID}/abort`, {
383
+ method: "POST",
384
+ timeoutMs: node.requestTimeoutMs,
385
+ ...authOptions(),
386
+ })
387
+ .then(() => {
388
+ node.registry.setBusy(sessionID, false);
389
+ updateStatus();
390
+ send([Object.assign({}, msg, { payload: true, sessionID }), null]);
391
+ done();
392
+ })
393
+ .catch((err) => done(new Error(`agent-server: abort failed: ${err.message}`)));
394
+ }
421
395
 
422
- const VALID_OPERATIONS = ['message', 'status', 'abort', 'history', 'terminate'];
423
-
424
- node.on('input', function (msg, send, done) {
425
- if (node.srtSettingsError) {
426
- done(new Error(`agent-server: ${node.srtSettingsError}`));
427
- return;
428
- }
429
-
430
- // msg.operation can override the configured default for this one
431
- // trigger. This matters because each node instance's registry is
432
- // private (per-node-instance scoping, same as the `agent` node's
433
- // active/queued counts) -- a *separate* node configured with
434
- // operation 'status' would only ever see its own (always empty)
435
- // registry, never another node's spawned daemons. Overriding lets
436
- // the same node instance that does the spawning also be queried
437
- // for its own status/history/abort on demand.
438
- const operation = VALID_OPERATIONS.includes(msg.operation) ? msg.operation : node.operation;
439
-
440
- switch (operation) {
441
- case 'status':
442
- handleStatusOperation(msg, send, done);
443
- return;
444
- case 'abort':
445
- handleAbortOperation(msg, send, done);
446
- return;
447
- case 'history':
448
- handleHistoryOperation(msg, send, done);
449
- return;
450
- case 'terminate':
451
- handleTerminateOperation(msg, send, done);
452
- return;
453
- case 'message':
454
- default:
455
- handleMessageOperation(msg, send, done);
456
- return;
457
- }
458
- });
396
+ function handleHistoryOperation(msg, send, done) {
397
+ let sessionID;
398
+ try {
399
+ sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
400
+ } catch (err) {
401
+ done(err);
402
+ return;
403
+ }
404
+ if (!sessionID || !node.registry.has(sessionID)) {
405
+ done(
406
+ new Error(
407
+ `agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`,
408
+ ),
409
+ );
410
+ return;
411
+ }
412
+
413
+ const record = node.registry.get(sessionID);
414
+ request(`${record.baseUrl}/session/${sessionID}/message`, {
415
+ timeoutMs: node.requestTimeoutMs,
416
+ ...authOptions(),
417
+ })
418
+ .then((history) => {
419
+ send([Object.assign({}, msg, { payload: history, sessionID }), null]);
420
+ done();
421
+ })
422
+ .catch((err) => done(new Error(`agent-server: history fetch failed: ${err.message}`)));
423
+ }
459
424
 
460
- node.on('close', function (done) {
461
- const entries = Array.from(node.registry.list());
462
- Promise.all(
463
- entries.map((sessionID) => {
464
- const record = node.registry.get(sessionID);
465
- node.registry.delete(sessionID);
466
- return killDaemon(record.child).then(() => {
467
- emitEvent(sessionID, 'closed', {});
468
- });
469
- })
470
- ).then(() => {
471
- if (node.srtTempSettingsFile) {
472
- fs.unlink(node.srtTempSettingsFile, () => {});
473
- }
474
- node.status({});
475
- done();
476
- });
477
- });
425
+ // Kills the daemon process itself, unlike `abort` (which only
426
+ // HTTP-cancels the in-flight message and leaves the daemon running,
427
+ // reusable for a later message). After this, the sessionID is no
428
+ // longer tracked -- a later message with this sessionID is a foreign/
429
+ // unknown id, same as if it had never been spawned.
430
+ function handleTerminateOperation(msg, send, done) {
431
+ let sessionID;
432
+ try {
433
+ sessionID = resolveTyped(node.sessionIdProp, node.sessionIdPropType, msg, undefined);
434
+ } catch (err) {
435
+ done(err);
436
+ return;
437
+ }
438
+ if (!sessionID || !node.registry.has(sessionID)) {
439
+ done(
440
+ new Error(
441
+ `agent-server: unknown sessionID "${sessionID}" (not tracked by this node instance)`,
442
+ ),
443
+ );
444
+ return;
445
+ }
446
+
447
+ const record = node.registry.get(sessionID);
448
+ node.registry.delete(sessionID);
449
+ killDaemon(record.child)
450
+ .then(() => {
451
+ emitEvent(sessionID, "terminated", msg);
452
+ updateStatus();
453
+ send([Object.assign({}, msg, { payload: true, sessionID }), null]);
454
+ done();
455
+ })
456
+ .catch((err) => done(new Error(`agent-server: terminate failed: ${err.message}`)));
478
457
  }
479
458
 
480
- RED.nodes.registerType('agent-server', AgentServerNode);
459
+ const VALID_OPERATIONS = ["message", "status", "abort", "history", "terminate"];
460
+
461
+ node.on("input", function (msg, send, done) {
462
+ if (node.srtSettingsError) {
463
+ done(new Error(`agent-server: ${node.srtSettingsError}`));
464
+ return;
465
+ }
466
+
467
+ // msg.operation can override the configured default for this one
468
+ // trigger. This matters because each node instance's registry is
469
+ // private (per-node-instance scoping, same as the `agent` node's
470
+ // active/queued counts) -- a *separate* node configured with
471
+ // operation 'status' would only ever see its own (always empty)
472
+ // registry, never another node's spawned daemons. Overriding lets
473
+ // the same node instance that does the spawning also be queried
474
+ // for its own status/history/abort on demand.
475
+ const operation = VALID_OPERATIONS.includes(msg.operation) ? msg.operation : node.operation;
476
+
477
+ switch (operation) {
478
+ case "status":
479
+ handleStatusOperation(msg, send, done);
480
+ return;
481
+ case "abort":
482
+ handleAbortOperation(msg, send, done);
483
+ return;
484
+ case "history":
485
+ handleHistoryOperation(msg, send, done);
486
+ return;
487
+ case "terminate":
488
+ handleTerminateOperation(msg, send, done);
489
+ return;
490
+ case "message":
491
+ default:
492
+ handleMessageOperation(msg, send, done);
493
+ return;
494
+ }
495
+ });
496
+
497
+ node.on("close", function (done) {
498
+ const entries = Array.from(node.registry.list());
499
+ Promise.all(
500
+ entries.map((sessionID) => {
501
+ const record = node.registry.get(sessionID);
502
+ node.registry.delete(sessionID);
503
+ return killDaemon(record.child).then(() => {
504
+ emitEvent(sessionID, "closed", {});
505
+ });
506
+ }),
507
+ ).then(() => {
508
+ if (node.srtTempSettingsFile) {
509
+ fs.unlink(node.srtTempSettingsFile, () => {});
510
+ }
511
+ node.status({});
512
+ done();
513
+ });
514
+ });
515
+ }
516
+
517
+ RED.nodes.registerType("agent-server", AgentServerNode);
481
518
  };