Initial release: generic CLI agent nodes for Nextcloud Node-RED

This commit is contained in:
n3wk
2026-08-24 19:06:08 +02:00
commit b9249dd9ca
16 changed files with 1437 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
<script type="text/html" data-template-name="agent-config">
<div class="form-row">
<label for="node-input-cliPath"><i class="fa fa-terminal"></i> CLI Command</label>
<input type="text" id="node-input-cliPath" placeholder="agent (or full path like /usr/local/bin/agent)">
<span class="help-text">Command or full path to the CLI agent binary. Must be in PATH or absolute path.</span>
</div>
<div class="form-row">
<label for="node-input-workspaceDir"><i class="fa fa-folder"></i> Workspace Directory</label>
<input type="text" id="node-input-workspaceDir" placeholder="/data/agent-workspace">
<span class="help-text">Directory where the agent will operate. Should be accessible from Node-RED container.</span>
</div>
<div class="form-row">
<label for="node-input-apiEndpoint"><i class="fa fa-server"></i> API Endpoint</label>
<input type="text" id="node-input-apiEndpoint" placeholder="http://localhost:3000">
<span class="help-text">Agent REST API endpoint (if running in server mode).</span>
</div>
<div class="form-row">
<label for="node-input-apiKey"><i class="fa fa-key"></i> API Key</label>
<input type="password" id="node-input-apiKey" placeholder="Optional API key for agent server">
<span class="help-text">API key if agent server requires authentication.</span>
</div>
<div class="form-row">
<label for="node-input-defaultArgs"><i class="fa fa-list"></i> Default Arguments</label>
<input type="text" id="node-input-defaultArgs" placeholder="--verbose --model default">
<span class="help-text">Default arguments passed to the agent (space-separated).</span>
</div>
<div class="form-row">
<label for="node-input-maxConcurrent"><i class="fa fa-tasks"></i> Max Concurrent Processes</label>
<input type="number" id="node-input-maxConcurrent" min="1" max="10" value="3" style="width: 80px;">
<span class="help-text">Maximum number of concurrent agent processes.</span>
</div>
<div class="form-row">
<label for="node-input-autoStart"><i class="fa fa-play"></i> Auto-start Process</label>
<input type="checkbox" id="node-input-autoStart" checked>
<span class="help-text">Automatically start agent process when node receives input.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-config">
<p>Configuration node for CLI agent integration.</p>
<p>This node stores connection settings for a CLI agent. Other agent nodes reference this config.</p>
<h3>Settings</h3>
<ul>
<li><b>CLI Command</b> - Command or full path to the agent binary. Default: <code>agent</code> (must be in PATH).</li>
<li><b>Workspace Directory</b> - Directory where agent will read/write files. Should be a shared volume accessible from both Node-RED and agent.</li>
<li><b>API Endpoint</b> - If running agent in server mode, the REST API endpoint.</li>
<li><b>API Key</b> - Optional authentication key for agent server mode.</li>
<li><b>Default Arguments</b> - Default arguments passed to the agent.</li>
<li><b>Max Concurrent Processes</b> - Limit concurrent agent processes.</li>
<li><b>Auto-start Process</b> - Start agent automatically on input.</li>
</ul>
<h3>Docker Setup</h3>
<p>When running in Docker, ensure the workspace directory is mounted as a volume in both Node-RED and agent containers:</p>
<pre><code>volumes:
- agent-workspace:/data/agent-workspace</code></pre>
</script>
+20
View File
@@ -0,0 +1,20 @@
'use strict';
module.exports = function (RED) {
function AgentConfigNode(n) {
RED.nodes.createNode(this, n);
this.cliPath = n.cliPath || 'agent';
this.workspaceDir = n.workspaceDir || '/data/agent-workspace';
this.apiEndpoint = (n.apiEndpoint || 'http://localhost:3000').replace(/\/$/, '');
this.apiKey = this.credentials.apiKey || '';
this.autoStart = n.autoStart !== false;
this.defaultArgs = n.defaultArgs || '';
this.maxConcurrent = parseInt(n.maxConcurrent) || 3;
}
RED.nodes.registerType('agent-config', AgentConfigNode, {
credentials: {
apiKey: { type: 'password' }
}
});
};
+70
View File
@@ -0,0 +1,70 @@
<script type="text/html" data-template-name="agent-exec">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node.</span>
</div>
<div class="form-row">
<label for="node-input-command"><i class="fa fa-terminal"></i> Command</label>
<select id="node-input-command">
<option value="run">run - Execute a task</option>
<option value="version">version - Show version</option>
<option value="config">config - Show configuration</option>
<option value="help">help - Show help</option>
</select>
<span class="help-text">Agent subcommand to execute.</span>
</div>
<div class="form-row">
<label for="node-input-args"><i class="fa fa-list"></i> Arguments</label>
<input type="text" id="node-input-args" placeholder="--verbose --output json">
<span class="help-text">Additional arguments (space-separated). Can be overridden by msg.args.</span>
</div>
<div class="form-row">
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
<span class="help-text">Working directory for agent. Overrides config. Can be overridden by msg.workingDir.</span>
</div>
<div class="form-row">
<label for="node-input-timeout"><i class="fa fa-clock"></i> Timeout (ms)</label>
<input type="number" id="node-input-timeout" min="5000" max="600000" value="120000" style="width: 100px;">
<span class="help-text">Maximum execution time in milliseconds.</span>
</div>
<div class="form-row">
<label for="node-input-outputFormat"><i class="fa fa-code"></i> Output Format</label>
<select id="node-input-outputFormat">
<option value="text">Text (raw stdout)</option>
<option value="json">JSON (parse stdout as JSON)</option>
</select>
<span class="help-text">How to parse the output.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-exec">
<p>Execute CLI agent commands from Node-RED flows.</p>
<h3>Usage</h3>
<p>This node runs agent commands and returns the output. The most common use is <code>agent run</code> with a prompt/input.</p>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.command</code> - Override the command (e.g., "run", "version")</li>
<li><code>msg.args</code> - Override arguments (array or space-separated string)</li>
<li><code>msg.prompt</code> - Input text for <code>run</code> command</li>
<li><code>msg.workingDir</code> - Override working directory</li>
<li><code>msg.timeout</code> - Override timeout in ms</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Command output (text or parsed JSON)</li>
<li><code>msg.stderr</code> - Standard error output</li>
<li><code>msg.exitCode</code> - Process exit code</li>
<li><code>msg.duration</code> - Execution time in milliseconds</li>
<li><code>msg.command</code> - Full command that was executed</li>
<li><code>msg.workingDir</code> - Working directory used</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Example Flow</h3>
<pre><code>// Inject node with:
msg.payload = "Process the files in /data/input and generate a report";
msg.prompt = msg.payload;
return msg;</code></pre>
<p>Connect to agent-exec with command "run" to execute the task.</p>
</script>
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const { spawn } = require('child_process');
const path = require('path');
module.exports = function (RED) {
function AgentExecNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.command = config.command || 'run';
this.args = config.args || '';
this.workingDir = config.workingDir || '';
this.timeout = parseInt(config.timeout) || 120000;
this.outputFormat = config.outputFormat || 'text';
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const startTime = Date.now();
const cliCmd = cfg.cliPath || 'agent';
const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace';
const command = msg.command || node.command;
const args = msg.args ? (Array.isArray(msg.args) ? msg.args : msg.args.split(' ').filter(Boolean)) : (node.args ? node.args.split(' ').filter(Boolean) : []);
const timeout = msg.timeout || node.timeout;
const fullArgs = [command, ...args];
if (command === 'run' && msg.prompt) {
fullArgs.push('--prompt', msg.prompt);
}
if (cfg.defaultArgs) {
fullArgs.unshift(...cfg.defaultArgs.split(' ').filter(Boolean));
}
node.status({ fill: 'blue', shape: 'dot', text: `Running: ${command}` });
try {
const result = await runAgent(cliCmd, fullArgs, {
cwd: workspaceDir,
timeout,
env: {
...process.env,
AGENT_WORKSPACE: workspaceDir,
AGENT_API_ENDPOINT: cfg.apiEndpoint,
AGENT_API_KEY: cfg.apiKey || ''
}
});
const duration = Date.now() - startTime;
node.status({ fill: 'green', shape: 'dot', text: `Done (${duration}ms)` });
msg.payload = node.outputFormat === 'json' ? parseOutput(result.stdout) : result.stdout;
msg.stderr = result.stderr;
msg.exitCode = result.exitCode;
msg.duration = duration;
msg.command = `${cliCmd} ${fullArgs.join(' ')}`;
msg.workingDir = workspaceDir;
node.send(msg);
} catch (err) {
const duration = Date.now() - startTime;
node.status({ fill: 'red', shape: 'ring', text: `Error (${duration}ms)` });
msg.error = err.message;
msg.stderr = err.stderr || '';
msg.exitCode = err.exitCode || -1;
msg.duration = duration;
node.error(`agent ${command} failed: ${err.message}`, msg);
}
});
}
function runAgent(cmd, args, options) {
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
let exitCode = 0;
const child = spawn(cmd, args, {
cwd: options.cwd,
env: options.env,
timeout: options.timeout,
windowsHide: true
});
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
child.on('close', (code) => {
exitCode = code || 0;
if (exitCode === 0) {
resolve({ stdout, stderr, exitCode });
} else {
const err = new Error(`Process exited with code ${exitCode}`);
err.exitCode = exitCode;
err.stderr = stderr;
reject(err);
}
});
child.on('error', (err) => {
err.exitCode = -1;
reject(err);
});
child.on('timeout', () => {
child.kill('SIGTERM');
const err = new Error(`Process timed out after ${options.timeout}ms`);
err.exitCode = -2;
err.stderr = stderr;
reject(err);
});
});
}
function parseOutput(text) {
try {
const trimmed = text.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
return JSON.parse(trimmed);
}
return text;
} catch {
return text;
}
}
RED.nodes.registerType('agent-exec', AgentExecNode);
};
+79
View File
@@ -0,0 +1,79 @@
<script type="text/html" data-template-name="agent-file">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node (provides workspace directory).</span>
</div>
<div class="form-row">
<label for="node-input-operation"><i class="fa fa-tasks"></i> Operation</label>
<select id="node-input-operation">
<option value="read">Read File</option>
<option value="write">Write File</option>
<option value="append">Append to File</option>
<option value="delete">Delete File</option>
<option value="exists">Check Exists</option>
<option value="list">List Directory</option>
<option value="stat">File Stats</option>
<option value="mkdir">Create Directory</option>
</select>
<span class="help-text">File operation to perform.</span>
</div>
<div class="form-row">
<label for="node-input-filePath"><i class="fa fa-file"></i> File Path</label>
<input type="text" id="node-input-filePath" placeholder="output/result.txt or /data/agent-workspace/output/result.txt">
<span class="help-text">Path relative to workspace directory, or absolute path. Can be overridden by msg.filePath.</span>
</div>
<div class="form-row">
<label for="node-input-encoding"><i class="fa fa-font"></i> Encoding</label>
<select id="node-input-encoding">
<option value="utf8">UTF-8</option>
<option value="ascii">ASCII</option>
<option value="latin1">Latin1</option>
<option value="base64">Base64</option>
<option value="hex">Hex</option>
</select>
<span class="help-text">Text encoding for read/write operations.</span>
</div>
<div class="form-row">
<label for="node-input-createDirs"><i class="fa fa-folder-plus"></i> Create Parent Directories</label>
<input type="checkbox" id="node-input-createDirs" checked>
<span class="help-text">Automatically create parent directories for write/append operations.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-file">
<p>Read, write, and manage files in the agent workspace directory.</p>
<h3>Operations</h3>
<ul>
<li><b>Read</b> - Returns file content as string (or Buffer for binary encodings)</li>
<li><b>Write</b> - Creates/overwrites file with msg.payload content</li>
<li><b>Append</b> - Appends msg.payload to existing file</li>
<li><b>Delete</b> - Removes file</li>
<li><b>Exists</b> - Returns {exists: true/false}</li>
<li><b>List</b> - Returns array of directory entries with name, path, isDirectory, isFile</li>
<li><b>Stat</b> - Returns file stats (size, timestamps, type)</li>
<li><b>Mkdir</b> - Creates directory recursively</li>
</ul>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.operation</code> - Override operation</li>
<li><code>msg.filePath</code> - Override file path</li>
<li><code>msg.payload</code> - Content for write/append operations</li>
<li><code>msg.encoding</code> - Override encoding</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Operation result (content, stats, or status object)</li>
<li><code>msg.filePath</code> - File path used</li>
<li><code>msg.operation</code> - Operation performed</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Security</h3>
<p>Paths are restricted to the configured workspace directory. Path traversal attempts are blocked.</p>
<h3>Example: Write agent output to file</h3>
<pre><code>// After agent-exec generates output:
msg.filePath = "results/output.txt";
msg.payload = msg.payload; // output from agent-exec
msg.operation = "write";
return msg;</code></pre>
</script>
+133
View File
@@ -0,0 +1,133 @@
'use strict';
const fs = require('fs').promises;
const path = require('path');
module.exports = function (RED) {
function AgentFileNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.operation = config.operation || 'read';
this.filePath = config.filePath || '';
this.encoding = config.encoding || 'utf8';
this.createDirs = config.createDirs !== false;
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const workspaceDir = cfg.workspaceDir || '/data/agent-workspace';
const operation = msg.operation || node.operation;
const filePath = msg.filePath || node.filePath;
const encoding = msg.encoding || node.encoding;
if (!filePath) {
node.error('No file path specified', msg);
return;
}
const fullPath = path.isAbsolute(filePath)
? filePath
: path.join(workspaceDir, filePath);
const resolvedWorkspace = path.resolve(workspaceDir);
const resolvedPath = path.resolve(fullPath);
if (!resolvedPath.startsWith(resolvedWorkspace)) {
node.error('Path traversal attempt blocked', msg);
return;
}
node.status({ fill: 'blue', shape: 'dot', text: operation });
try {
let result;
switch (operation) {
case 'read':
result = await fs.readFile(resolvedPath, encoding);
break;
case 'write':
const content = msg.payload;
if (node.createDirs) {
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
}
await fs.writeFile(resolvedPath, content, encoding);
result = { written: true, path: filePath, bytes: Buffer.byteLength(content, encoding) };
break;
case 'append':
const appendContent = msg.payload;
if (node.createDirs) {
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
}
await fs.appendFile(resolvedPath, appendContent, encoding);
result = { appended: true, path: filePath, bytes: Buffer.byteLength(appendContent, encoding) };
break;
case 'delete':
await fs.unlink(resolvedPath);
result = { deleted: true, path: filePath };
break;
case 'exists':
try {
await fs.access(resolvedPath);
result = { exists: true, path: filePath };
} catch {
result = { exists: false, path: filePath };
}
break;
case 'list':
const entries = await fs.readdir(resolvedPath, { withFileTypes: true });
result = entries.map(e => ({
name: e.name,
path: path.join(filePath, e.name),
isDirectory: e.isDirectory(),
isFile: e.isFile()
}));
break;
case 'stat':
const stats = await fs.stat(resolvedPath);
result = {
path: filePath,
size: stats.size,
isDirectory: stats.isDirectory(),
isFile: stats.isFile(),
created: stats.birthtime,
modified: stats.mtime
};
break;
case 'mkdir':
await fs.mkdir(resolvedPath, { recursive: true });
result = { created: true, path: filePath };
break;
default:
throw new Error(`Unknown operation: ${operation}`);
}
node.status({ fill: 'green', shape: 'dot', text: 'Done' });
msg.payload = result;
msg.filePath = filePath;
msg.operation = operation;
node.send(msg);
} catch (err) {
node.status({ fill: 'red', shape: 'ring', text: 'Error' });
msg.error = err.message;
msg.filePath = filePath;
msg.operation = operation;
node.error(`File ${operation} failed: ${err.message}`, msg);
}
});
}
RED.nodes.registerType('agent-file', AgentFileNode);
};
+83
View File
@@ -0,0 +1,83 @@
<script type="text/html" data-template-name="agent-process">
<div class="form-row">
<label for="node-input-agentConfig"><i class="fa fa-cog"></i> Agent Config</label>
<input type="text" id="node-input-agentConfig" class="node-config-input" placeholder="Select agent config node">
<span class="help-text">Select the agent configuration node.</span>
</div>
<div class="form-row">
<label for="node-input-processId"><i class="fa fa-id-badge"></i> Process ID</label>
<input type="text" id="node-input-processId" placeholder="default">
<span class="help-text">Unique identifier for this process instance.</span>
</div>
<div class="form-row">
<label for="node-input-action"><i class="fa fa-play"></i> Action</label>
<select id="node-input-action">
<option value="start">Start Process</option>
<option value="stop">Stop Process</option>
<option value="restart">Restart Process</option>
<option value="send">Send Input</option>
<option value="status">Get Status</option>
</select>
<span class="help-text">Action to perform on the process.</span>
</div>
<div class="form-row">
<label for="node-input-initialInput"><i class="fa fa-comment"></i> Initial Input</label>
<input type="text" id="node-input-initialInput" placeholder="Process the files in /data/input...">
<span class="help-text">Initial input for the process (used on start). Can be overridden by msg.payload or msg.initialInput.</span>
</div>
<div class="form-row">
<label for="node-input-workingDir"><i class="fa fa-folder"></i> Working Directory</label>
<input type="text" id="node-input-workingDir" placeholder="/data/agent-workspace">
<span class="help-text">Workspace directory for this process. Overrides config.</span>
</div>
<div class="form-row">
<label for="node-input-autoRestart"><i class="fa fa-redo"></i> Auto Restart</label>
<input type="checkbox" id="node-input-autoRestart" checked>
<span class="help-text">Automatically restart process if it crashes.</span>
</div>
</script>
<script type="text/html" data-help-name="agent-process">
<p>Manage persistent CLI agent processes.</p>
<h3>Actions</h3>
<ul>
<li><b>Start</b> - Launch a new persistent agent process</li>
<li><b>Stop</b> - Gracefully terminate the process</li>
<li><b>Restart</b> - Stop and start the process</li>
<li><b>Send</b> - Send input to a running process via stdin</li>
<li><b>Status</b> - Get current process status (running, pid, uptime)</li>
</ul>
<h3>Input Message Properties</h3>
<ul>
<li><code>msg.action</code> - Override action</li>
<li><code>msg.processId</code> - Override process ID</li>
<li><code>msg.payload</code> - Input for start/send actions</li>
<li><code>msg.initialInput</code> - Alternative to msg.payload for start action</li>
<li><code>msg.workingDir</code> - Override workspace directory</li>
</ul>
<h3>Output Message Properties</h3>
<ul>
<li><code>msg.payload</code> - Status object with process info</li>
<li><code>msg.processId</code> - Process ID</li>
<li><code>msg.error</code> - Error message if failed</li>
</ul>
<h3>Process Lifecycle</h3>
<p>Processes run as persistent child processes. They maintain state across interactions. Use <b>Send</b> action to continue interactions with a running process.</p>
<h3>Example: Continuous processing session</h3>
<pre><code>// Start process with initial context
msg.action = "start";
msg.processId = "data-processor";
msg.payload = "Watch /data/input for new files and process them";
return msg;
// Later, send follow-up commands
msg.action = "send";
msg.processId = "data-processor";
msg.payload = "Now also generate summary report";
return msg;
// Check status
msg.action = "status";
msg.processId = "data-processor";
return msg;</code></pre>
</script>
+180
View File
@@ -0,0 +1,180 @@
'use strict';
const { spawn } = require('child_process');
module.exports = function (RED) {
const agentSessions = new Map();
function AgentProcessNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.configNode = RED.nodes.getNode(config.agentConfig);
this.processId = config.processId || 'default';
this.action = config.action || 'start';
this.initialInput = config.initialInput || '';
this.workingDir = config.workingDir || '';
this.autoRestart = config.autoRestart !== false;
if (!this.configNode) {
node.error('No agent config node selected');
return;
}
node.on('input', async function (msg) {
const cfg = node.configNode;
const action = msg.action || node.action;
const processId = msg.processId || node.processId;
const workspaceDir = msg.workingDir || node.workingDir || cfg.workspaceDir || '/data/agent-workspace';
try {
switch (action) {
case 'start':
await startProcess(node, cfg, processId, workspaceDir, msg);
break;
case 'stop':
await stopProcess(node, processId);
break;
case 'restart':
await stopProcess(node, processId);
await startProcess(node, cfg, processId, workspaceDir, msg);
break;
case 'send':
await sendToProcess(node, processId, msg.payload || msg.initialInput);
break;
case 'status':
await getProcessStatus(node, processId);
break;
default:
throw new Error(`Unknown action: ${action}`);
}
} catch (err) {
node.error(`Process ${action} failed: ${err.message}`, msg);
}
});
node.on('close', function (removed, done) {
if (removed) {
stopProcess(node, node.processId).finally(done);
} else {
done();
}
});
}
async function startProcess(node, cfg, processId, workspaceDir, msg) {
if (agentSessions.has(processId)) {
node.warn(`Process ${processId} already running`);
msg.payload = { status: 'running', processId };
node.send(msg);
return;
}
const cliCmd = cfg.cliPath || 'agent';
const initialInput = msg.initialInput || msg.payload || node.initialInput;
const defaultArgs = cfg.defaultArgs ? cfg.defaultArgs.split(' ').filter(Boolean) : [];
const args = ['run', ...defaultArgs];
if (initialInput) args.push('--input', initialInput);
node.status({ fill: 'yellow', shape: 'dot', text: `Starting ${processId}...` });
const child = spawn(cliCmd, args, {
cwd: workspaceDir,
env: {
...process.env,
AGENT_WORKSPACE: workspaceDir,
AGENT_API_ENDPOINT: cfg.apiEndpoint,
AGENT_API_KEY: cfg.apiKey || ''
},
windowsHide: true
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.stderr.on('data', (data) => { stderr += data.toString(); });
const session = {
id: processId,
process: child,
workspaceDir,
startTime: Date.now(),
stdout,
stderr
};
agentSessions.set(processId, session);
child.on('close', (code) => {
agentSessions.delete(processId);
node.status({ fill: 'red', shape: 'ring', text: `Stopped (${code})` });
node.send({
payload: { status: 'stopped', processId, exitCode: code, stdout, stderr },
processId
});
});
child.on('error', (err) => {
agentSessions.delete(processId);
node.status({ fill: 'red', shape: 'ring', text: 'Error' });
node.error(`Process ${processId} error: ${err.message}`);
});
await new Promise(r => setTimeout(r, 2000));
node.status({ fill: 'green', shape: 'dot', text: `Running: ${processId}` });
msg.payload = { status: 'started', processId, pid: child.pid };
msg.processId = processId;
node.send(msg);
}
async function stopProcess(node, processId) {
const session = agentSessions.get(processId);
if (!session) {
node.warn(`Process ${processId} not found`);
return;
}
session.process.kill('SIGTERM');
await new Promise(r => setTimeout(r, 1000));
if (!session.process.killed) {
session.process.kill('SIGKILL');
}
}
async function sendToProcess(node, processId, input) {
const session = agentSessions.get(processId);
if (!session) {
throw new Error(`Process ${processId} not running`);
}
if (session.process.stdin.writable) {
session.process.stdin.write(input + '\n');
}
node.status({ fill: 'blue', shape: 'dot', text: `Sent to ${processId}` });
node.send({ payload: { sent: true, processId, input }, processId });
}
async function getProcessStatus(node, processId) {
const session = agentSessions.get(processId);
if (!session) {
node.send({ payload: { status: 'not running', processId }, processId });
return;
}
node.send({
payload: {
status: 'running',
processId,
pid: session.process.pid,
uptime: Date.now() - session.startTime,
workspaceDir: session.workspaceDir
},
processId
});
}
RED.nodes.registerType('agent-process', AgentProcessNode);
};