omniwire 2.2.1 → 2.4.0

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,4 +1,4 @@
1
- // OmniWire MCP Server — 34-tool universal AI agent interface (25 core + 9 CyberSync)
1
+ // OmniWire MCP Server -- 34-tool universal AI agent interface (25 core + 9 CyberSync)
2
2
  // Works with Claude Code, OpenCode, Oh-My-OpenAgent, OpenClaw, and any MCP client
3
3
  //
4
4
  // SECURITY NOTE: This file does NOT use child_process.exec(). All remote command
@@ -10,108 +10,163 @@ import { ShellManager, kernelExec } from '../nodes/shell.js';
10
10
  import { RealtimeChannel } from '../nodes/realtime.js';
11
11
  import { TunnelManager } from '../nodes/tunnel.js';
12
12
  import { openBrowser } from '../commands/browser.js';
13
- import { allNodes, remoteNodes, findNode, NODE_ROLES, getDefaultNodeForTask } from '../protocol/config.js';
13
+ import { remoteNodes, findNode, NODE_ROLES, getDefaultNodeForTask } from '../protocol/config.js';
14
14
  import { parseMeshPath } from '../protocol/paths.js';
15
- // Compact output helpers — keeps Claude Code tool results clean
15
+ const MAX_OUTPUT = 4000;
16
+ function t(ms) {
17
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
18
+ }
19
+ function trim(s) {
20
+ return s.length > MAX_OUTPUT ? s.slice(0, MAX_OUTPUT) + '\n...(truncated)' : s;
21
+ }
16
22
  function ok(node, ms, body, label) {
17
- const tag = label ?? node;
18
- return { content: [{ type: 'text', text: `[${tag}] (${ms}ms)
19
- ${body}` }] };
23
+ const hdr = label ? `${node} > ${label}` : node;
24
+ return { content: [{ type: 'text', text: `${hdr} ${t(ms)}\n${trim(body)}` }] };
20
25
  }
21
- function fail(msg) {
26
+ function okBrief(msg) {
22
27
  return { content: [{ type: 'text', text: msg }] };
23
28
  }
29
+ function fail(msg) {
30
+ return { content: [{ type: 'text', text: `ERR ${msg}` }] };
31
+ }
32
+ function fmtExecOutput(result, timeoutSec) {
33
+ if (result.code === 0)
34
+ return result.stdout || '(empty)';
35
+ if (result.code === 124)
36
+ return `TIMEOUT ${timeoutSec}s\n${result.stdout || '(empty)'}`;
37
+ return `exit ${result.code}\n${result.stderr}`;
38
+ }
39
+ function fmtJson(node, result, label) {
40
+ return okBrief(JSON.stringify({
41
+ node, ok: result.code === 0, code: result.code, ms: result.durationMs,
42
+ ...(label ? { label } : {}),
43
+ stdout: result.stdout.slice(0, MAX_OUTPUT),
44
+ ...(result.stderr ? { stderr: result.stderr.slice(0, 1000) } : {}),
45
+ }));
46
+ }
47
+ function multiResult(results) {
48
+ const parts = results.map((r) => {
49
+ const mark = r.code === 0 ? 'ok' : `exit ${r.code}`;
50
+ const body = r.code === 0
51
+ ? (r.stdout || '(empty)').split('\n').slice(0, 30).join('\n')
52
+ : r.stderr.split('\n').slice(0, 10).join('\n');
53
+ return `-- ${r.nodeId} ${t(r.durationMs)} ${mark}\n${body}`;
54
+ });
55
+ return okBrief(trim(parts.join('\n\n')));
56
+ }
57
+ function multiResultJson(results) {
58
+ return okBrief(JSON.stringify(results.map((r) => ({
59
+ node: r.nodeId, ok: r.code === 0, code: r.code, ms: r.durationMs,
60
+ stdout: r.stdout.slice(0, MAX_OUTPUT),
61
+ ...(r.stderr ? { stderr: r.stderr.slice(0, 500) } : {}),
62
+ }))));
63
+ }
64
+ // -- Agentic state -- shared across tool calls in the same MCP session --------
65
+ const resultStore = new Map(); // key -> value store for chaining
66
+ // -----------------------------------------------------------------------------
24
67
  export function createOmniWireServer(manager, transfer) {
25
68
  const server = new McpServer({
26
69
  name: 'omniwire',
27
- version: '2.2.1',
70
+ version: '2.4.0',
28
71
  });
29
72
  const shells = new ShellManager(manager);
30
73
  const realtime = new RealtimeChannel(manager);
31
74
  const tunnels = new TunnelManager(manager);
32
75
  // --- Tool 1: omniwire_exec ---
33
- server.tool('omniwire_exec', 'Execute a command on a specific mesh node. Defaults to auto-selecting based on command context.', {
76
+ server.tool('omniwire_exec', 'Execute a command on a mesh node. Supports retry, assertions, JSON output, and result storage for agentic chaining.', {
34
77
  node: z.string().optional().describe('Target node id (windows, contabo, hostinger, thinkpad). Auto-selects if omitted.'),
35
- command: z.string().optional().describe('Shell command to run on the remote node via SSH'),
78
+ command: z.string().optional().describe('Shell command to run. Use {{key}} to interpolate stored results from previous calls.'),
36
79
  timeout: z.number().optional().describe('Timeout in seconds (default 30)'),
37
- script: z.string().optional().describe('Multi-line script content. Sent as temp file via SFTP then executed. Use this instead of command for scripts >3 lines to keep tool calls compact.'),
38
- label: z.string().optional().describe('Short label for the operation (shown in tool call UI instead of full command). Max 60 chars.'),
39
- },
40
- // Remote SSH2 execution — manager.exec() uses ssh2 client.exec(), not child_process
41
- async ({ node, command, timeout, script, label }) => {
80
+ script: z.string().optional().describe('Multi-line script content. Sent as temp file via SFTP then executed.'),
81
+ label: z.string().optional().describe('Short label for the operation. Max 60 chars.'),
82
+ format: z.enum(['text', 'json']).optional().describe('Output format. "json" returns structured {node, ok, code, ms, stdout, stderr} for programmatic parsing.'),
83
+ retry: z.number().optional().describe('Retry N times on failure (with 1s delay between). Default 0.'),
84
+ assert: z.string().optional().describe('Grep pattern to assert in stdout. If not found, returns error. Use for validation in agentic chains.'),
85
+ store_as: z.string().optional().describe('Store trimmed stdout under this key. Retrieve in subsequent calls via {{key}} in command.'),
86
+ }, async ({ node, command, timeout, script, label, format, retry, assert: assertPattern, store_as }) => {
42
87
  if (!command && !script) {
43
- return fail('Error: either command or script is required');
88
+ return fail('either command or script is required');
44
89
  }
45
90
  const nodeId = node ?? 'contabo';
46
91
  const timeoutSec = timeout ?? 30;
92
+ const maxRetries = retry ?? 0;
93
+ const useJson = format === 'json';
94
+ // Interpolate stored results: {{key}} -> value
95
+ let resolvedCmd = command;
96
+ if (resolvedCmd) {
97
+ resolvedCmd = resolvedCmd.replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
98
+ }
47
99
  let effectiveCmd;
48
100
  if (script) {
49
- // Multi-line script: write to temp file via SSH, execute, clean up
50
101
  const tmpFile = `/tmp/.ow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
51
102
  effectiveCmd = `cat << 'OMNIWIRE_SCRIPT_EOF' > ${tmpFile}\n${script}\nOMNIWIRE_SCRIPT_EOF\nchmod +x ${tmpFile} && timeout ${timeoutSec} ${tmpFile}; _rc=$?; rm -f ${tmpFile}; exit $_rc`;
52
103
  }
53
104
  else {
54
105
  effectiveCmd = timeoutSec < 300
55
- ? `timeout ${timeoutSec} bash -c '${command.replace(/'/g, "'\\''")}'`
56
- : command;
106
+ ? `timeout ${timeoutSec} bash -c '${resolvedCmd.replace(/'/g, "'\\''")}'`
107
+ : resolvedCmd;
108
+ }
109
+ // Execute with retry
110
+ let result = await manager.exec(nodeId, effectiveCmd);
111
+ for (let attempt = 0; attempt < maxRetries && result.code !== 0; attempt++) {
112
+ await new Promise((r) => setTimeout(r, 1000));
113
+ result = await manager.exec(nodeId, effectiveCmd);
114
+ }
115
+ // Store result if requested
116
+ if (store_as && result.code === 0) {
117
+ resultStore.set(store_as, result.stdout.trim());
57
118
  }
58
- const result = await manager.exec(nodeId, effectiveCmd);
59
- const output = result.code === 0
60
- ? result.stdout || '(no output)'
61
- : result.code === 124
62
- ? `Timeout after ${timeoutSec}s: ${result.stdout || '(no output)'}`
63
- : `Error (exit ${result.code}): ${result.stderr}`;
64
- return ok(nodeId, result.durationMs, output, label ?? undefined);
119
+ // Assert pattern in output
120
+ if (assertPattern && result.code === 0) {
121
+ const regex = new RegExp(assertPattern);
122
+ if (!regex.test(result.stdout)) {
123
+ return useJson
124
+ ? okBrief(JSON.stringify({ node: nodeId, ok: false, code: -2, ms: result.durationMs, error: `assert failed: /${assertPattern}/ not found in stdout`, stdout: result.stdout.slice(0, 500) }))
125
+ : fail(`${nodeId} assert failed: /${assertPattern}/ not found`);
126
+ }
127
+ }
128
+ return useJson
129
+ ? fmtJson(nodeId, result, label ?? undefined)
130
+ : ok(nodeId, result.durationMs, fmtExecOutput(result, timeoutSec), label ?? undefined);
65
131
  });
66
132
  // --- Tool 2: omniwire_broadcast ---
67
133
  server.tool('omniwire_broadcast', 'Execute a command on all online mesh nodes simultaneously.', {
68
- command: z.string().describe('Shell command to run on all nodes'),
134
+ command: z.string().describe('Shell command to run on all nodes. Supports {{key}} interpolation.'),
69
135
  nodes: z.array(z.string()).optional().describe('Subset of nodes to target. All online nodes if omitted.'),
70
- }, async ({ command, nodes: targetNodes }) => {
136
+ format: z.enum(['text', 'json']).optional().describe('Output format.'),
137
+ }, async ({ command, nodes: targetNodes, format }) => {
138
+ const resolved = command.replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
71
139
  const results = targetNodes
72
- ? await manager.execOn(targetNodes, command)
73
- : await manager.execAll(command);
74
- const text = results.map((r) => {
75
- const body = r.code === 0 ? r.stdout || '(no output)' : `Error: ${r.stderr}`;
76
- return `[${r.nodeId}] (${r.durationMs}ms)\n${body}`;
77
- }).join('\n\n');
78
- return { content: [{ type: 'text', text }] };
140
+ ? await manager.execOn(targetNodes, resolved)
141
+ : await manager.execAll(resolved);
142
+ return format === 'json' ? multiResultJson(results) : multiResult(results);
79
143
  });
80
144
  // --- Tool 3: omniwire_mesh_status ---
81
145
  server.tool('omniwire_mesh_status', 'Get health and resource usage for all mesh nodes.', {}, async () => {
82
146
  const statuses = await manager.getAllStatus();
83
147
  const lines = statuses.map((s) => {
84
- const node = allNodes().find((n) => n.id === s.nodeId);
85
- const role = NODE_ROLES[s.nodeId] ?? 'unknown';
86
- const status = s.online ? 'ONLINE' : 'OFFLINE';
87
- const lat = s.latencyMs !== null ? `${s.latencyMs}ms` : '-';
88
- const mem = s.memUsedPct !== null ? `${s.memUsedPct.toFixed(0)}%` : '-';
89
- const disk = s.diskUsedPct !== null ? `${s.diskUsedPct.toFixed(0)}%` : '-';
90
- return `${s.nodeId} (${role}) | ${node?.host ?? '-'} | ${status} | lat=${lat} | load=${s.loadAvg ?? '-'} | mem=${mem} | disk=${disk}`;
148
+ const status = s.online ? '+' : '-';
149
+ const lat = s.latencyMs !== null ? `${s.latencyMs}ms` : '--';
150
+ const mem = s.memUsedPct !== null ? `${s.memUsedPct.toFixed(0)}%` : '--';
151
+ const disk = s.diskUsedPct !== null ? `${s.diskUsedPct.toFixed(0)}%` : '--';
152
+ const role = NODE_ROLES[s.nodeId] ?? '';
153
+ return `${status} ${s.nodeId.padEnd(10)} ${role.padEnd(8)} lat=${lat.padStart(5)} mem=${mem.padStart(4)} disk=${disk.padStart(4)} load=${s.loadAvg ?? '--'}`;
91
154
  });
92
- return { content: [{ type: 'text', text: lines.join('\n') }] };
155
+ return okBrief(lines.join('\n'));
93
156
  });
94
157
  // --- Tool 4: omniwire_node_info ---
95
158
  server.tool('omniwire_node_info', 'Get detailed information about a specific node.', { node: z.string().describe('Node id') }, async ({ node }) => {
96
159
  const meshNode = findNode(node);
97
160
  if (!meshNode)
98
- return { content: [{ type: 'text', text: `Unknown node: ${node}` }] };
99
- const status = await manager.getNodeStatus(meshNode.id);
100
- const role = NODE_ROLES[meshNode.id] ?? 'unknown';
101
- const text = [
102
- `Node: ${meshNode.id} (${meshNode.alias})`,
103
- `Role: ${role}`,
104
- `Host: ${meshNode.host}:${meshNode.port}`,
105
- `OS: ${meshNode.os}`,
106
- `Tags: ${meshNode.tags.join(', ')}`,
107
- `Status: ${status.online ? 'ONLINE' : 'OFFLINE'}`,
108
- `Latency: ${status.latencyMs ?? '-'}ms`,
109
- `Uptime: ${status.uptime ?? '-'}`,
110
- `Load: ${status.loadAvg ?? '-'}`,
111
- `Memory: ${status.memUsedPct !== null ? `${status.memUsedPct.toFixed(1)}%` : '-'}`,
112
- `Disk: ${status.diskUsedPct !== null ? `${status.diskUsedPct.toFixed(0)}%` : '-'}`,
113
- ].join('\n');
114
- return { content: [{ type: 'text', text }] };
161
+ return fail(`unknown node: ${node}`);
162
+ const s = await manager.getNodeStatus(meshNode.id);
163
+ const role = NODE_ROLES[meshNode.id] ?? '';
164
+ const status = s.online ? 'ONLINE' : 'OFFLINE';
165
+ const text = `${meshNode.id} (${meshNode.alias}) ${status}
166
+ role=${role} host=${meshNode.host}:${meshNode.port} os=${meshNode.os}
167
+ lat=${s.latencyMs ?? '--'}ms up=${s.uptime ?? '--'} load=${s.loadAvg ?? '--'} mem=${s.memUsedPct !== null ? `${s.memUsedPct.toFixed(1)}%` : '--'} disk=${s.diskUsedPct !== null ? `${s.diskUsedPct.toFixed(0)}%` : '--'}
168
+ tags: ${meshNode.tags.join(', ')}`;
169
+ return okBrief(text);
115
170
  });
116
171
  // --- Tool 5: omniwire_read_file ---
117
172
  server.tool('omniwire_read_file', 'Read a file from any mesh node. Default node: contabo (storage).', {
@@ -131,10 +186,10 @@ export function createOmniWireServer(manager, transfer) {
131
186
  if (max_lines) {
132
187
  content = content.split('\n').slice(0, max_lines).join('\n');
133
188
  }
134
- return { content: [{ type: 'text', text: content }] };
189
+ return okBrief(trim(content));
135
190
  }
136
191
  catch (e) {
137
- return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
192
+ return fail(e.message);
138
193
  }
139
194
  });
140
195
  // --- Tool 6: omniwire_write_file ---
@@ -152,10 +207,10 @@ export function createOmniWireServer(manager, transfer) {
152
207
  }
153
208
  try {
154
209
  await transfer.writeFile(nodeId, filePath, content);
155
- return { content: [{ type: 'text', text: `Written ${filePath} on ${nodeId}` }] };
210
+ return okBrief(`${nodeId}:${filePath} written`);
156
211
  }
157
212
  catch (e) {
158
- return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
213
+ return fail(e.message);
159
214
  }
160
215
  });
161
216
  // --- Tool 7: omniwire_transfer_file ---
@@ -167,15 +222,18 @@ export function createOmniWireServer(manager, transfer) {
167
222
  const srcParsed = parseMeshPath(src);
168
223
  const dstParsed = parseMeshPath(dst);
169
224
  if (!srcParsed)
170
- return { content: [{ type: 'text', text: `Invalid source path: ${src}. Use node:/path format.` }] };
225
+ return fail(`invalid source: ${src} (use node:/path)`);
171
226
  if (!dstParsed)
172
- return { content: [{ type: 'text', text: `Invalid dest path: ${dst}. Use node:/path format.` }] };
227
+ return fail(`invalid dest: ${dst} (use node:/path)`);
173
228
  try {
174
- const result = await transfer.transfer(srcParsed.nodeId, srcParsed.path, dstParsed.nodeId, dstParsed.path, mode ? { mode } : undefined);
175
- return { content: [{ type: 'text', text: `Transferred ${result.bytesTransferred} bytes via ${result.mode} in ${result.durationMs}ms (${result.speedMBps.toFixed(1)} MB/s)` }] };
229
+ const r = await transfer.transfer(srcParsed.nodeId, srcParsed.path, dstParsed.nodeId, dstParsed.path, mode ? { mode } : undefined);
230
+ const size = r.bytesTransferred > 1048576
231
+ ? `${(r.bytesTransferred / 1048576).toFixed(1)}MB`
232
+ : `${(r.bytesTransferred / 1024).toFixed(0)}KB`;
233
+ return okBrief(`${srcParsed.nodeId} -> ${dstParsed.nodeId} ${size} via ${r.mode} ${t(r.durationMs)} ${r.speedMBps.toFixed(1)}MB/s`);
176
234
  }
177
235
  catch (e) {
178
- return { content: [{ type: 'text', text: `Transfer error: ${e.message}` }] };
236
+ return fail(e.message);
179
237
  }
180
238
  });
181
239
  // --- Tool 8: omniwire_list_files ---
@@ -193,10 +251,10 @@ export function createOmniWireServer(manager, transfer) {
193
251
  try {
194
252
  const entries = await transfer.readdir(nodeId, dirPath);
195
253
  const text = entries.map((e) => `${e.isDirectory ? 'd' : '-'} ${e.permissions.padEnd(11)} ${String(e.size).padStart(10)} ${e.modified} ${e.name}`).join('\n');
196
- return { content: [{ type: 'text', text: text || '(empty directory)' }] };
254
+ return okBrief(trim(text || '(empty)'));
197
255
  }
198
256
  catch (e) {
199
- return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
257
+ return fail(e.message);
200
258
  }
201
259
  });
202
260
  // --- Tool 9: omniwire_find_files ---
@@ -214,11 +272,7 @@ export function createOmniWireServer(manager, transfer) {
214
272
  const results = targetNodes
215
273
  ? await manager.execOn(targetNodes, cmd)
216
274
  : await manager.execAll(cmd);
217
- const text = results.map((r) => {
218
- const body = r.code === 0 ? r.stdout || '(no matches)' : `Error: ${r.stderr}`;
219
- return `[${r.nodeId}]\n${body}`;
220
- }).join('\n\n');
221
- return { content: [{ type: 'text', text }] };
275
+ return multiResult(results);
222
276
  });
223
277
  // --- Tool 10: omniwire_tail_log ---
224
278
  server.tool('omniwire_tail_log', 'Read the last N lines of a log file on a node.', {
@@ -228,8 +282,9 @@ export function createOmniWireServer(manager, transfer) {
228
282
  }, async ({ path, node, lines }) => {
229
283
  const n = lines ?? 50;
230
284
  const result = await manager.exec(node, `tail -n ${n} "${path}"`);
231
- const text = result.code === 0 ? result.stdout : `Error: ${result.stderr}`;
232
- return { content: [{ type: 'text', text: `[${node}] ${path} (last ${n} lines)\n${text}` }] };
285
+ if (result.code !== 0)
286
+ return fail(result.stderr);
287
+ return ok(node, result.durationMs, result.stdout, `tail ${path}`);
233
288
  });
234
289
  // --- Tool 11: omniwire_process_list ---
235
290
  server.tool('omniwire_process_list', 'List processes across mesh nodes, optionally filtered.', {
@@ -243,8 +298,7 @@ export function createOmniWireServer(manager, transfer) {
243
298
  const results = targetNodes
244
299
  ? await manager.execOn(targetNodes, cmd)
245
300
  : await manager.execAll(cmd);
246
- const text = results.map((r) => `[${r.nodeId}]\n${r.stdout}`).join('\n\n');
247
- return { content: [{ type: 'text', text }] };
301
+ return multiResult(results);
248
302
  });
249
303
  // --- Tool 12: omniwire_disk_usage ---
250
304
  server.tool('omniwire_disk_usage', 'Show disk usage across mesh nodes.', { nodes: z.array(z.string()).optional() }, async ({ nodes: targetNodes }) => {
@@ -252,8 +306,7 @@ export function createOmniWireServer(manager, transfer) {
252
306
  const results = targetNodes
253
307
  ? await manager.execOn(targetNodes, cmd)
254
308
  : await manager.execAll(cmd);
255
- const text = results.map((r) => `[${r.nodeId}]\n${r.stdout}`).join('\n\n');
256
- return { content: [{ type: 'text', text }] };
309
+ return multiResult(results);
257
310
  });
258
311
  // --- Tool 13: omniwire_install_package ---
259
312
  server.tool('omniwire_install_package', 'Install a package on a node via apt, npm, or pip.', {
@@ -275,10 +328,9 @@ export function createOmniWireServer(manager, transfer) {
275
328
  break;
276
329
  }
277
330
  const result = await manager.exec(node, cmd);
278
- const text = result.code === 0
279
- ? `Installed ${package_name} via ${pm} on ${node}`
280
- : `Install failed: ${result.stderr}`;
281
- return { content: [{ type: 'text', text }] };
331
+ return result.code === 0
332
+ ? okBrief(`${node} installed ${package_name} (${pm})`)
333
+ : fail(`${node} ${pm} install ${package_name}: ${result.stderr.split('\n').slice(-3).join('\n')}`);
282
334
  });
283
335
  // --- Tool 14: omniwire_service_control ---
284
336
  server.tool('omniwire_service_control', 'Control systemd services on a node.', {
@@ -287,8 +339,10 @@ export function createOmniWireServer(manager, transfer) {
287
339
  node: z.string().describe('Target node'),
288
340
  }, async ({ service, action, node }) => {
289
341
  const result = await manager.exec(node, `systemctl ${action} ${service}`);
290
- const text = result.code === 0 ? result.stdout || `${action} ${service}: OK` : `Error: ${result.stderr}`;
291
- return { content: [{ type: 'text', text: `[${node}] ${text}` }] };
342
+ if (result.code !== 0)
343
+ return fail(`${node} systemctl ${action} ${service}: ${result.stderr}`);
344
+ const body = result.stdout || 'ok';
345
+ return okBrief(`${node} ${action} ${service}: ${body.split('\n').slice(0, 5).join('\n')}`);
292
346
  });
293
347
  // --- Tool 15: omniwire_docker ---
294
348
  server.tool('omniwire_docker', 'Run docker commands on a node. Default: contabo.', {
@@ -297,8 +351,9 @@ export function createOmniWireServer(manager, transfer) {
297
351
  }, async ({ command, node }) => {
298
352
  const nodeId = node ?? 'contabo';
299
353
  const result = await manager.exec(nodeId, `docker ${command}`);
300
- const text = result.code === 0 ? result.stdout : `Error: ${result.stderr}`;
301
- return { content: [{ type: 'text', text: `[${nodeId}] docker ${command}\n${text}` }] };
354
+ if (result.code !== 0)
355
+ return fail(`${nodeId} docker ${command}: ${result.stderr}`);
356
+ return ok(nodeId, result.durationMs, result.stdout, `docker ${command.split(' ')[0]}`);
302
357
  });
303
358
  // --- Tool 16: omniwire_open_browser ---
304
359
  server.tool('omniwire_open_browser', 'Open a URL in a browser. Default: thinkpad (has GPU + display).', {
@@ -306,7 +361,7 @@ export function createOmniWireServer(manager, transfer) {
306
361
  node: z.string().optional().describe('Node to open on (default: thinkpad)'),
307
362
  }, async ({ url, node }) => {
308
363
  const result = await openBrowser(manager, url, node);
309
- return { content: [{ type: 'text', text: result }] };
364
+ return okBrief(result);
310
365
  });
311
366
  // --- Tool 17: omniwire_port_forward ---
312
367
  server.tool('omniwire_port_forward', 'Create an SSH port forward tunnel to a node.', {
@@ -320,21 +375,20 @@ export function createOmniWireServer(manager, transfer) {
320
375
  const act = action ?? 'create';
321
376
  if (act === 'list') {
322
377
  const list = tunnels.list();
323
- const text = list.length === 0
324
- ? 'No active tunnels'
325
- : list.map((t) => `${t.id}: localhost:${t.localPort} → ${t.nodeId}:${t.remotePort}`).join('\n');
326
- return { content: [{ type: 'text', text }] };
378
+ if (list.length === 0)
379
+ return okBrief('no active tunnels');
380
+ return okBrief(list.map((tn) => `${tn.id} :${tn.localPort} -> ${tn.nodeId}:${tn.remotePort}`).join('\n'));
327
381
  }
328
382
  if (act === 'close' && tunnel_id) {
329
383
  tunnels.close(tunnel_id);
330
- return { content: [{ type: 'text', text: `Closed tunnel ${tunnel_id}` }] };
384
+ return okBrief(`closed ${tunnel_id}`);
331
385
  }
332
386
  try {
333
387
  const info = await tunnels.create(node, local_port, remote_port, remote_host);
334
- return { content: [{ type: 'text', text: `Tunnel ${info.id}: localhost:${info.localPort} → ${info.nodeId}:${info.remotePort}` }] };
388
+ return okBrief(`tunnel ${info.id} :${info.localPort} -> ${info.nodeId}:${info.remotePort}`);
335
389
  }
336
390
  catch (e) {
337
- return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
391
+ return fail(e.message);
338
392
  }
339
393
  });
340
394
  // --- Tool 18: omniwire_deploy ---
@@ -345,15 +399,14 @@ export function createOmniWireServer(manager, transfer) {
345
399
  dst_nodes: z.array(z.string()).optional().describe('Target nodes (default: all remote)'),
346
400
  }, async ({ src_node, src_path, dst_path, dst_nodes }) => {
347
401
  const targets = (dst_nodes ?? remoteNodes().map((n) => n.id)).filter((dst) => dst !== src_node);
348
- // Parallel deployment to all targets
349
402
  const settled = await Promise.allSettled(targets.map(async (dst) => {
350
403
  const r = await transfer.transfer(src_node, src_path, dst, dst_path);
351
404
  return { dst, speed: r.speedMBps };
352
405
  }));
353
- const results = settled.map((s, i) => s.status === 'fulfilled'
354
- ? `${s.value.dst}: OK (${s.value.speed.toFixed(1)} MB/s)`
355
- : `${targets[i]}: FAILED — ${s.reason.message}`);
356
- return { content: [{ type: 'text', text: `Deploy ${src_path} → ${dst_path}\n${results.join('\n')}` }] };
406
+ const lines = settled.map((s, i) => s.status === 'fulfilled'
407
+ ? ` ${s.value.dst} ok ${s.value.speed.toFixed(1)}MB/s`
408
+ : ` ${targets[i]} FAIL ${s.reason.message}`);
409
+ return okBrief(`deploy ${src_path} -> ${dst_path}\n${lines.join('\n')}`);
357
410
  });
358
411
  // --- Tool 19: omniwire_kernel ---
359
412
  server.tool('omniwire_kernel', 'Kernel-level operations: dmesg, sysctl, modprobe, lsmod, strace, perf.', {
@@ -362,7 +415,7 @@ export function createOmniWireServer(manager, transfer) {
362
415
  node: z.string().describe('Target node'),
363
416
  }, async ({ operation, args, node }) => {
364
417
  const output = await kernelExec(manager, node, operation, args ?? '');
365
- return { content: [{ type: 'text', text: `[${node}] ${operation} ${args ?? ''}\n${output}` }] };
418
+ return ok(node, 0, output, `${operation} ${args ?? ''}`.trim());
366
419
  });
367
420
  // --- Tool 20: omniwire_stream ---
368
421
  server.tool('omniwire_stream', 'Capture streaming command output (for tail -f, watch, etc.) for a limited duration.', {
@@ -383,7 +436,7 @@ export function createOmniWireServer(manager, transfer) {
383
436
  finally {
384
437
  clearTimeout(timer);
385
438
  }
386
- return { content: [{ type: 'text', text: `[${node}] stream: ${command}\n${output}` }] };
439
+ return ok(node, maxMs, output, `stream`);
387
440
  });
388
441
  // --- Tool 21: omniwire_shell ---
389
442
  server.tool('omniwire_shell', 'Run a sequence of commands in a persistent shell session (preserves cwd, env vars).', {
@@ -407,7 +460,7 @@ export function createOmniWireServer(manager, transfer) {
407
460
  channel.write('exit\n');
408
461
  await closePromise;
409
462
  shells.closeShell(session.id);
410
- return { content: [{ type: 'text', text: `[${node}] persistent shell\n${output}` }] };
463
+ return ok(node, 0, output, `shell (${commands.length} cmds)`);
411
464
  });
412
465
  // --- Tool 22: omniwire_live_monitor ---
413
466
  server.tool('omniwire_live_monitor', 'Watch system metrics across all nodes (snapshot).', {
@@ -429,12 +482,11 @@ export function createOmniWireServer(manager, transfer) {
429
482
  cmd = "ss -s";
430
483
  break;
431
484
  case 'all':
432
- cmd = "echo '=== CPU ===' && top -bn1 | head -5 && echo '=== MEM ===' && free -h && echo '=== DISK ===' && df -h / 2>/dev/null";
485
+ cmd = "echo '=CPU=' && top -bn1 | head -5 && echo '=MEM=' && free -h && echo '=DISK=' && df -h / 2>/dev/null";
433
486
  break;
434
487
  }
435
488
  const results = await manager.execAll(cmd);
436
- const text = results.map((r) => `[${r.nodeId}]\n${r.stdout}`).join('\n\n');
437
- return { content: [{ type: 'text', text }] };
489
+ return multiResult(results);
438
490
  });
439
491
  // --- Tool 23: omniwire_run (compact multi-line script execution) ---
440
492
  server.tool('omniwire_run', 'Execute a multi-line script on a node. The script is written to a temp file and executed, keeping tool call display compact. Use this instead of omniwire_exec for Python scripts, heredocs, or any command >3 lines.', {
@@ -463,41 +515,68 @@ export function createOmniWireServer(manager, transfer) {
463
515
  `_rc=$?; rm -f ${tmpFile}; exit $_rc`,
464
516
  ].join('\n');
465
517
  const result = await manager.exec(nodeId, wrappedCmd);
466
- const displayLabel = label ? ` (${label})` : '';
467
- const output = result.code === 0
468
- ? result.stdout || '(no output)'
469
- : result.code === 124
470
- ? `Timeout after ${timeoutSec}s: ${result.stdout || '(no output)'}`
471
- : `Error (exit ${result.code}): ${result.stderr}`;
472
- return { content: [{ type: 'text', text: `[${nodeId}]${displayLabel} (${result.durationMs}ms)\n${output}` }] };
473
- });
474
- // --- Tool 25: omniwire_batch (multiple commands, single tool call) ---
475
- server.tool('omniwire_batch', 'Run multiple commands on one or more nodes in a single tool call. Returns all results. Use this to avoid multiple sequential omniwire_exec calls.', {
518
+ return ok(nodeId, result.durationMs, fmtExecOutput(result, timeoutSec), label ?? `${interp} script`);
519
+ });
520
+ // --- Tool 25: omniwire_batch (chaining-aware multi-command) ---
521
+ server.tool('omniwire_batch', 'Run multiple commands in a single tool call. Supports chaining (sequential with {{prev}} interpolation), abort-on-fail, store_as, and JSON output. Use this to reduce agentic round-trips.', {
476
522
  commands: z.array(z.object({
477
523
  node: z.string().optional().describe('Node id (default: contabo)'),
478
- command: z.string().describe('Command to run'),
479
- label: z.string().optional().describe('Short label for this command'),
524
+ command: z.string().describe('Command. Use {{key}} to interpolate stored results, {{prev}} for previous command stdout.'),
525
+ label: z.string().optional().describe('Short label'),
526
+ store_as: z.string().optional().describe('Store stdout under this key for later use'),
480
527
  })).describe('Array of commands to execute'),
481
- parallel: z.boolean().optional().describe('Run all commands in parallel (default: true)'),
482
- }, async ({ commands, parallel }) => {
528
+ parallel: z.boolean().optional().describe('Run in parallel (default: true). Set false for sequential chaining with {{prev}}.'),
529
+ abort_on_fail: z.boolean().optional().describe('Stop executing remaining commands if one fails (sequential mode only). Default: false.'),
530
+ format: z.enum(['text', 'json']).optional().describe('Output format.'),
531
+ }, async ({ commands, parallel, abort_on_fail, format }) => {
483
532
  const runParallel = parallel !== false;
484
- const execute = async (item) => {
533
+ const useJson = format === 'json';
534
+ if (runParallel) {
535
+ const execute = async (item) => {
536
+ const nodeId = item.node ?? 'contabo';
537
+ const resolved = item.command.replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
538
+ const result = await manager.exec(nodeId, resolved);
539
+ if (item.store_as && result.code === 0)
540
+ resultStore.set(item.store_as, result.stdout.trim());
541
+ if (useJson) {
542
+ return JSON.stringify({ node: nodeId, ok: result.code === 0, code: result.code, ms: result.durationMs, label: item.label, stdout: result.stdout.slice(0, 2000), ...(result.stderr ? { stderr: result.stderr.slice(0, 500) } : {}) });
543
+ }
544
+ const lbl = item.label ?? item.command.slice(0, 40);
545
+ const body = result.code === 0
546
+ ? (result.stdout || '(empty)').split('\n').slice(0, 20).join('\n')
547
+ : `exit ${result.code}: ${result.stderr.split('\n').slice(0, 5).join('\n')}`;
548
+ return `-- ${nodeId} > ${lbl} ${t(result.durationMs)}\n${body}`;
549
+ };
550
+ const results = await Promise.all(commands.map(execute));
551
+ return useJson ? okBrief(`[${results.join(',')}]`) : okBrief(trim(results.join('\n\n')));
552
+ }
553
+ // Sequential with chaining
554
+ const outputs = [];
555
+ let prevStdout = '';
556
+ for (const item of commands) {
485
557
  const nodeId = item.node ?? 'contabo';
486
- const result = await manager.exec(nodeId, item.command);
487
- const lbl = item.label ? ` ${item.label}` : '';
488
- const output = result.code === 0
489
- ? result.stdout || '(no output)'
490
- : `Error (exit ${result.code}): ${result.stderr}`;
491
- return `[${nodeId}]${lbl} (${result.durationMs}ms)\n${output}`;
492
- };
493
- const results = runParallel
494
- ? await Promise.all(commands.map(execute))
495
- : await commands.reduce(async (acc, cmd) => {
496
- const prev = await acc;
497
- const result = await execute(cmd);
498
- return [...prev, result];
499
- }, Promise.resolve([]));
500
- return { content: [{ type: 'text', text: results.join('\n\n') }] };
558
+ let resolved = item.command.replace(/\{\{prev\}\}/g, prevStdout.trim());
559
+ resolved = resolved.replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
560
+ const result = await manager.exec(nodeId, resolved);
561
+ prevStdout = result.stdout;
562
+ if (item.store_as && result.code === 0)
563
+ resultStore.set(item.store_as, result.stdout.trim());
564
+ if (useJson) {
565
+ outputs.push(JSON.stringify({ node: nodeId, ok: result.code === 0, code: result.code, ms: result.durationMs, label: item.label, stdout: result.stdout.slice(0, 2000), ...(result.stderr ? { stderr: result.stderr.slice(0, 500) } : {}) }));
566
+ }
567
+ else {
568
+ const lbl = item.label ?? item.command.slice(0, 40);
569
+ const body = result.code === 0
570
+ ? (result.stdout || '(empty)').split('\n').slice(0, 20).join('\n')
571
+ : `exit ${result.code}: ${result.stderr.split('\n').slice(0, 5).join('\n')}`;
572
+ outputs.push(`-- ${nodeId} > ${lbl} ${t(result.durationMs)}\n${body}`);
573
+ }
574
+ if (abort_on_fail && result.code !== 0) {
575
+ const msg = useJson ? `[${outputs.join(',')}]` : outputs.join('\n\n') + '\n\n-- ABORTED (command failed)';
576
+ return okBrief(trim(msg));
577
+ }
578
+ }
579
+ return useJson ? okBrief(`[${outputs.join(',')}]`) : okBrief(trim(outputs.join('\n\n')));
501
580
  });
502
581
  // --- Tool 26: omniwire_update ---
503
582
  server.tool('omniwire_update', 'Check for updates and self-update OmniWire to the latest version.', {
@@ -507,13 +586,575 @@ export function createOmniWireServer(manager, transfer) {
507
586
  const info = getSystemInfo();
508
587
  if (check_only) {
509
588
  const check = await checkForUpdate();
510
- const text = check.updateAvailable
511
- ? `Update available: ${check.current} → ${check.latest}\nRun omniwire_update to install.\n\nSystem: ${info.platform}/${info.arch} node ${info.nodeVersion}`
512
- : `Up to date (${check.current})\n\nSystem: ${info.platform}/${info.arch} node ${info.nodeVersion}`;
513
- return { content: [{ type: 'text', text }] };
589
+ return check.updateAvailable
590
+ ? okBrief(`update available: ${check.current} -> ${check.latest} (${info.platform}/${info.arch})`)
591
+ : okBrief(`up to date (${check.current}) ${info.platform}/${info.arch}`);
514
592
  }
515
593
  const result = await selfUpdate();
516
- return { content: [{ type: 'text', text: `${result.message}\n\nSystem: ${info.platform}/${info.arch} node ${info.nodeVersion}` }] };
594
+ return okBrief(`${result.message} ${info.platform}/${info.arch}`);
595
+ });
596
+ // --- Tool 27: omniwire_cron ---
597
+ server.tool('omniwire_cron', 'Manage cron jobs on a node. List, add, or remove scheduled tasks.', {
598
+ action: z.enum(['list', 'add', 'remove']).describe('Action'),
599
+ node: z.string().describe('Target node'),
600
+ schedule: z.string().optional().describe('Cron schedule (e.g., "0 */6 * * *"). Required for add.'),
601
+ command: z.string().optional().describe('Command to schedule. Required for add.'),
602
+ pattern: z.string().optional().describe('Pattern to match for remove (removes matching lines from crontab)'),
603
+ }, async ({ action, node, schedule, command, pattern }) => {
604
+ if (action === 'list') {
605
+ const result = await manager.exec(node, 'crontab -l 2>/dev/null || echo "(no crontab)"');
606
+ return ok(node, result.durationMs, result.stdout, 'crontab');
607
+ }
608
+ if (action === 'add') {
609
+ if (!schedule || !command)
610
+ return fail('schedule and command required for add');
611
+ const escaped = command.replace(/'/g, "'\\''");
612
+ const result = await manager.exec(node, `(crontab -l 2>/dev/null; echo '${schedule} ${escaped}') | sort -u | crontab -`);
613
+ return result.code === 0
614
+ ? okBrief(`${node} cron added: ${schedule} ${command.slice(0, 50)}`)
615
+ : fail(`${node} cron add: ${result.stderr}`);
616
+ }
617
+ if (action === 'remove' && pattern) {
618
+ const esc = pattern.replace(/'/g, "'\\''");
619
+ const result = await manager.exec(node, `crontab -l 2>/dev/null | grep -v '${esc}' | crontab -`);
620
+ return result.code === 0
621
+ ? okBrief(`${node} cron removed matching: ${pattern}`)
622
+ : fail(`${node} cron remove: ${result.stderr}`);
623
+ }
624
+ return fail('invalid action/params');
625
+ });
626
+ // --- Tool 28: omniwire_env ---
627
+ server.tool('omniwire_env', 'Get or set environment variables on a node (persistent via /etc/environment).', {
628
+ action: z.enum(['get', 'set', 'list']).describe('Action'),
629
+ node: z.string().describe('Target node'),
630
+ key: z.string().optional().describe('Variable name'),
631
+ value: z.string().optional().describe('Variable value (for set)'),
632
+ }, async ({ action, node, key, value }) => {
633
+ if (action === 'list') {
634
+ const result = await manager.exec(node, 'cat /etc/environment 2>/dev/null; echo "---"; env | sort | head -40');
635
+ return ok(node, result.durationMs, result.stdout, 'env list');
636
+ }
637
+ if (action === 'get' && key) {
638
+ const result = await manager.exec(node, `bash -c 'source /etc/environment 2>/dev/null; echo "\${${key}}"'`);
639
+ const val = result.stdout.trim();
640
+ return okBrief(`${node} ${key}=${val || '(unset)'}`);
641
+ }
642
+ if (action === 'set' && key && value !== undefined) {
643
+ const esc = value.replace(/'/g, "'\\''");
644
+ const result = await manager.exec(node, `grep -q '^${key}=' /etc/environment 2>/dev/null && sed -i 's|^${key}=.*|${key}=${esc}|' /etc/environment || echo '${key}=${esc}' >> /etc/environment`);
645
+ return result.code === 0
646
+ ? okBrief(`${node} ${key}=${value.slice(0, 40)} (persisted)`)
647
+ : fail(`${node} env set: ${result.stderr}`);
648
+ }
649
+ return fail('invalid action/params');
650
+ });
651
+ // --- Tool 29: omniwire_network ---
652
+ server.tool('omniwire_network', 'Network diagnostics: ping, traceroute, dns lookup, open ports, bandwidth test.', {
653
+ action: z.enum(['ping', 'traceroute', 'dns', 'ports', 'speed', 'connections']).describe('Diagnostic action'),
654
+ node: z.string().describe('Node to run from'),
655
+ target: z.string().optional().describe('Target host/IP (required for ping, traceroute, dns)'),
656
+ }, async ({ action, node, target }) => {
657
+ let cmd;
658
+ switch (action) {
659
+ case 'ping':
660
+ if (!target)
661
+ return fail('target required');
662
+ cmd = `ping -c 4 -W 2 ${target} 2>&1 | tail -5`;
663
+ break;
664
+ case 'traceroute':
665
+ if (!target)
666
+ return fail('target required');
667
+ cmd = `traceroute -m 15 -w 2 ${target} 2>&1 | head -20`;
668
+ break;
669
+ case 'dns':
670
+ if (!target)
671
+ return fail('target required');
672
+ cmd = `dig +short ${target} 2>/dev/null || nslookup ${target} 2>&1 | tail -5`;
673
+ break;
674
+ case 'ports':
675
+ cmd = 'ss -tlnp | head -30';
676
+ break;
677
+ case 'speed':
678
+ cmd = "curl -s -o /dev/null -w 'download: %{speed_download} bytes/s\\ntime: %{time_total}s\\n' https://speed.cloudflare.com/__down?bytes=10000000 2>&1";
679
+ break;
680
+ case 'connections':
681
+ cmd = 'ss -s';
682
+ break;
683
+ }
684
+ const result = await manager.exec(node, cmd);
685
+ return ok(node, result.durationMs, result.code === 0 ? result.stdout : result.stderr, `net ${action}`);
686
+ });
687
+ // --- Tool 30: omniwire_clipboard ---
688
+ server.tool('omniwire_clipboard', 'Copy text between nodes via a shared clipboard buffer.', {
689
+ action: z.enum(['copy', 'paste', 'clear']).describe('Action'),
690
+ content: z.string().optional().describe('Text to copy (for copy action)'),
691
+ node: z.string().optional().describe('Node for paste (default: all)'),
692
+ }, async ({ action, content, node }) => {
693
+ const clipPath = '/tmp/.omniwire-clipboard';
694
+ if (action === 'copy' && content) {
695
+ const results = await manager.execAll(`cat << 'OW_CLIP' > ${clipPath}\n${content}\nOW_CLIP`);
696
+ const ok_count = results.filter((r) => r.code === 0).length;
697
+ return okBrief(`clipboard set on ${ok_count} nodes (${content.length} chars)`);
698
+ }
699
+ if (action === 'paste') {
700
+ const nodeId = node ?? 'contabo';
701
+ const result = await manager.exec(nodeId, `cat ${clipPath} 2>/dev/null || echo '(empty)'`);
702
+ return ok(nodeId, result.durationMs, result.stdout, 'clipboard');
703
+ }
704
+ if (action === 'clear') {
705
+ await manager.execAll(`rm -f ${clipPath}`);
706
+ return okBrief('clipboard cleared');
707
+ }
708
+ return fail('invalid action');
709
+ });
710
+ // --- Tool 31: omniwire_git ---
711
+ server.tool('omniwire_git', 'Run git commands on a repository on any node.', {
712
+ command: z.string().describe('Git subcommand (status, log --oneline -5, pull, etc.)'),
713
+ path: z.string().describe('Repository path on the node'),
714
+ node: z.string().optional().describe('Node (default: contabo)'),
715
+ }, async ({ command, path, node }) => {
716
+ const nodeId = node ?? 'contabo';
717
+ const result = await manager.exec(nodeId, `cd "${path}" && git ${command}`);
718
+ const shortCmd = command.split(' ').slice(0, 2).join(' ');
719
+ if (result.code !== 0)
720
+ return fail(`${nodeId} git ${shortCmd}: ${result.stderr}`);
721
+ return ok(nodeId, result.durationMs, result.stdout, `git ${shortCmd}`);
722
+ });
723
+ // --- Tool 32: omniwire_syslog ---
724
+ server.tool('omniwire_syslog', 'Query system logs via journalctl on a node.', {
725
+ node: z.string().describe('Target node'),
726
+ unit: z.string().optional().describe('Systemd unit to filter (e.g., nginx, docker)'),
727
+ lines: z.number().optional().describe('Number of lines (default 30)'),
728
+ since: z.string().optional().describe('Time filter (e.g., "1 hour ago", "today")'),
729
+ priority: z.enum(['emerg', 'alert', 'crit', 'err', 'warning', 'notice', 'info', 'debug']).optional(),
730
+ }, async ({ node, unit, lines, since, priority }) => {
731
+ const parts = ['journalctl --no-pager'];
732
+ if (unit)
733
+ parts.push(`-u ${unit}`);
734
+ if (lines)
735
+ parts.push(`-n ${lines}`);
736
+ else
737
+ parts.push('-n 30');
738
+ if (since)
739
+ parts.push(`--since '${since}'`);
740
+ if (priority)
741
+ parts.push(`-p ${priority}`);
742
+ const result = await manager.exec(node, parts.join(' '));
743
+ const label = unit ? `syslog ${unit}` : 'syslog';
744
+ return ok(node, result.durationMs, result.code === 0 ? result.stdout : result.stderr, label);
745
+ });
746
+ // =========================================================================
747
+ // AGENTIC / A2A / MULTI-AGENT TOOLS
748
+ // =========================================================================
749
+ // --- Tool 33: omniwire_store ---
750
+ server.tool('omniwire_store', 'Key-value store for chaining results across tool calls in the same session. Agents can store intermediate results and retrieve them later. Keys persist until session ends.', {
751
+ action: z.enum(['get', 'set', 'delete', 'list', 'clear']).describe('Action'),
752
+ key: z.string().optional().describe('Key name (required for get/set/delete)'),
753
+ value: z.string().optional().describe('Value to store (for set)'),
754
+ }, async ({ action, key, value }) => {
755
+ switch (action) {
756
+ case 'get':
757
+ if (!key)
758
+ return fail('key required');
759
+ return okBrief(resultStore.get(key) ?? '(not found)');
760
+ case 'set':
761
+ if (!key || value === undefined)
762
+ return fail('key and value required');
763
+ resultStore.set(key, value);
764
+ return okBrief(`stored ${key} (${value.length} chars)`);
765
+ case 'delete':
766
+ if (!key)
767
+ return fail('key required');
768
+ resultStore.delete(key);
769
+ return okBrief(`deleted ${key}`);
770
+ case 'list':
771
+ if (resultStore.size === 0)
772
+ return okBrief('(empty store)');
773
+ return okBrief([...resultStore.entries()].map(([k, v]) => `${k} = ${v.slice(0, 80)}${v.length > 80 ? '...' : ''}`).join('\n'));
774
+ case 'clear':
775
+ resultStore.clear();
776
+ return okBrief('store cleared');
777
+ }
778
+ });
779
+ // --- Tool 34: omniwire_pipeline ---
780
+ server.tool('omniwire_pipeline', 'Execute a multi-step pipeline across nodes. Each step can depend on previous step output. Steps run sequentially on potentially different nodes. Pipeline aborts on first failure unless ignore_errors is set. Designed for multi-agent orchestration.', {
781
+ steps: z.array(z.object({
782
+ node: z.string().optional().describe('Node (default: contabo)'),
783
+ command: z.string().describe('Command. Use {{prev}} for previous stdout, {{stepN}} for step N output, {{key}} for store.'),
784
+ label: z.string().optional().describe('Step label'),
785
+ store_as: z.string().optional().describe('Store stdout under this key'),
786
+ on_fail: z.enum(['abort', 'skip', 'continue']).optional().describe('Behavior on failure (default: abort)'),
787
+ })).describe('Pipeline steps'),
788
+ format: z.enum(['text', 'json']).optional(),
789
+ }, async ({ steps, format }) => {
790
+ const useJson = format === 'json';
791
+ const stepOutputs = [];
792
+ const results = [];
793
+ let prevStdout = '';
794
+ for (let i = 0; i < steps.length; i++) {
795
+ const step = steps[i];
796
+ const nodeId = step.node ?? 'contabo';
797
+ let cmd = step.command
798
+ .replace(/\{\{prev\}\}/g, prevStdout.trim())
799
+ .replace(/\{\{step(\d+)\}\}/g, (_, n) => stepOutputs[parseInt(n)] ?? '')
800
+ .replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
801
+ const result = await manager.exec(nodeId, cmd);
802
+ prevStdout = result.stdout;
803
+ stepOutputs[i] = result.stdout.trim();
804
+ if (step.store_as && result.code === 0)
805
+ resultStore.set(step.store_as, result.stdout.trim());
806
+ results.push({
807
+ step: i, node: nodeId, label: step.label, ok: result.code === 0,
808
+ code: result.code, ms: result.durationMs,
809
+ stdout: result.stdout.slice(0, 2000),
810
+ ...(result.stderr ? { stderr: result.stderr.slice(0, 500) } : {}),
811
+ });
812
+ if (result.code !== 0) {
813
+ const onFail = step.on_fail ?? 'abort';
814
+ if (onFail === 'abort')
815
+ break;
816
+ if (onFail === 'skip') {
817
+ prevStdout = '';
818
+ continue;
819
+ }
820
+ }
821
+ }
822
+ if (useJson)
823
+ return okBrief(JSON.stringify(results));
824
+ const lines = results.map((r) => {
825
+ const status = r.ok ? 'ok' : `exit ${r.code}`;
826
+ const lbl = r.label ?? `step ${r.step}`;
827
+ const body = r.ok ? r.stdout.split('\n').slice(0, 10).join('\n') : (r.stderr ?? '').split('\n').slice(0, 5).join('\n');
828
+ return `[${r.step}] ${r.node} > ${lbl} ${t(r.ms)} ${status}\n${body}`;
829
+ });
830
+ return okBrief(trim(lines.join('\n\n')));
831
+ });
832
+ // --- Tool 35: omniwire_watch ---
833
+ server.tool('omniwire_watch', 'Poll a command until a condition is met or timeout. Useful for waiting on deployments, services starting, builds completing. Returns when the assert pattern matches stdout.', {
834
+ node: z.string().optional().describe('Node (default: contabo)'),
835
+ command: z.string().describe('Command to poll'),
836
+ assert: z.string().describe('Regex pattern to match in stdout. Returns success when found.'),
837
+ interval: z.number().optional().describe('Poll interval in seconds (default: 3)'),
838
+ timeout: z.number().optional().describe('Max wait in seconds (default: 60)'),
839
+ label: z.string().optional(),
840
+ store_as: z.string().optional().describe('Store matching stdout on success'),
841
+ }, async ({ node, command, assert: pattern, interval, timeout, label, store_as }) => {
842
+ const nodeId = node ?? 'contabo';
843
+ const intervalMs = (interval ?? 3) * 1000;
844
+ const timeoutMs = (timeout ?? 60) * 1000;
845
+ const regex = new RegExp(pattern);
846
+ const start = Date.now();
847
+ while (Date.now() - start < timeoutMs) {
848
+ const result = await manager.exec(nodeId, command);
849
+ if (result.code === 0 && regex.test(result.stdout)) {
850
+ if (store_as)
851
+ resultStore.set(store_as, result.stdout.trim());
852
+ return ok(nodeId, Date.now() - start, result.stdout, label ?? `watch (matched after ${t(Date.now() - start)})`);
853
+ }
854
+ await new Promise((r) => setTimeout(r, intervalMs));
855
+ }
856
+ return fail(`${nodeId} watch timeout after ${t(timeoutMs)}: /${pattern}/ never matched`);
857
+ });
858
+ // --- Tool 36: omniwire_healthcheck ---
859
+ server.tool('omniwire_healthcheck', 'Run a comprehensive health check across all nodes. Returns structured per-node status with connectivity, disk, memory, load, and service checks. Single tool call replaces 4+ individual calls.', {
860
+ checks: z.array(z.enum(['connectivity', 'disk', 'memory', 'load', 'docker', 'services'])).optional().describe('Which checks to run (default: all)'),
861
+ nodes: z.array(z.string()).optional().describe('Nodes to check (default: all online)'),
862
+ format: z.enum(['text', 'json']).optional(),
863
+ }, async ({ checks, nodes: targetNodes, format }) => {
864
+ const checkList = checks ?? ['connectivity', 'disk', 'memory', 'load'];
865
+ const useJson = format === 'json';
866
+ const parts = [];
867
+ if (checkList.includes('connectivity'))
868
+ parts.push("echo 'CONN:ok'");
869
+ if (checkList.includes('disk'))
870
+ parts.push("echo -n 'DISK:'; df / --output=pcent | tail -1 | tr -d ' %'");
871
+ if (checkList.includes('memory'))
872
+ parts.push("echo -n 'MEM:'; free | awk '/Mem:/{printf \"%.0f\", $3/$2*100}'");
873
+ if (checkList.includes('load'))
874
+ parts.push("echo -n 'LOAD:'; cat /proc/loadavg | awk '{print $1}'");
875
+ if (checkList.includes('docker'))
876
+ parts.push("echo -n 'DOCKER:'; docker ps -q 2>/dev/null | wc -l | tr -d ' '");
877
+ if (checkList.includes('services'))
878
+ parts.push("echo -n 'SVCFAIL:'; systemctl --failed --no-legend 2>/dev/null | wc -l | tr -d ' '");
879
+ const cmd = parts.join('; echo; ');
880
+ const nodeIds = targetNodes ?? manager.getOnlineNodes();
881
+ const results = await Promise.all(nodeIds.map((id) => manager.exec(id, cmd)));
882
+ if (useJson) {
883
+ const parsed = results.map((r) => {
884
+ const data = { node: r.nodeId, online: r.code !== -1 };
885
+ for (const line of r.stdout.split('\n')) {
886
+ const [k, v] = line.split(':');
887
+ if (k && v)
888
+ data[k.toLowerCase()] = isNaN(Number(v)) ? v : Number(v);
889
+ }
890
+ data.ms = r.durationMs;
891
+ return data;
892
+ });
893
+ return okBrief(JSON.stringify(parsed));
894
+ }
895
+ const lines = results.map((r) => {
896
+ if (r.code === -1)
897
+ return `- ${r.nodeId.padEnd(10)} OFFLINE`;
898
+ const metrics = r.stdout.split('\n').filter(Boolean).join(' ');
899
+ return `+ ${r.nodeId.padEnd(10)} ${t(r.durationMs).padStart(6)} ${metrics}`;
900
+ });
901
+ return okBrief(lines.join('\n'));
902
+ });
903
+ // --- Tool 37: omniwire_agent_task ---
904
+ server.tool('omniwire_agent_task', 'Dispatch a task to a specific node for background execution and retrieve results later. Creates a task file on the node, runs it in background, and provides a task ID for polling. Designed for A2A (agent-to-agent) workflows where one agent dispatches work and another retrieves results.', {
905
+ action: z.enum(['dispatch', 'status', 'result', 'list', 'cancel']).describe('Action'),
906
+ node: z.string().optional().describe('Node (default: contabo)'),
907
+ command: z.string().optional().describe('Command to dispatch (for dispatch action)'),
908
+ task_id: z.string().optional().describe('Task ID (for status/result/cancel)'),
909
+ label: z.string().optional(),
910
+ }, async ({ action, node, command, task_id, label }) => {
911
+ const nodeId = node ?? 'contabo';
912
+ const taskDir = '/tmp/.omniwire-tasks';
913
+ if (action === 'dispatch') {
914
+ if (!command)
915
+ return fail('command required');
916
+ const id = `ow-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
917
+ const escaped = command.replace(/'/g, "'\\''");
918
+ const script = `mkdir -p ${taskDir} && echo 'running' > ${taskDir}/${id}.status && echo '${label ?? command.slice(0, 60)}' > ${taskDir}/${id}.label && (bash -c '${escaped}' > ${taskDir}/${id}.stdout 2> ${taskDir}/${id}.stderr; echo $? > ${taskDir}/${id}.exit; echo 'done' > ${taskDir}/${id}.status) &`;
919
+ const result = await manager.exec(nodeId, script);
920
+ return result.code === 0
921
+ ? okBrief(`${nodeId} task dispatched: ${id}`)
922
+ : fail(`dispatch failed: ${result.stderr}`);
923
+ }
924
+ if (action === 'status' && task_id) {
925
+ const result = await manager.exec(nodeId, `cat ${taskDir}/${task_id}.status 2>/dev/null || echo 'not found'`);
926
+ return okBrief(`${nodeId} ${task_id}: ${result.stdout.trim()}`);
927
+ }
928
+ if (action === 'result' && task_id) {
929
+ const result = await manager.exec(nodeId, `echo "EXIT:$(cat ${taskDir}/${task_id}.exit 2>/dev/null)"; echo "---STDOUT---"; cat ${taskDir}/${task_id}.stdout 2>/dev/null; echo "---STDERR---"; cat ${taskDir}/${task_id}.stderr 2>/dev/null`);
930
+ return ok(nodeId, result.durationMs, result.stdout, `task ${task_id}`);
931
+ }
932
+ if (action === 'list') {
933
+ const result = await manager.exec(nodeId, `for f in ${taskDir}/*.status 2>/dev/null; do id=$(basename "$f" .status); echo "$id $(cat "$f") $(cat ${taskDir}/$id.label 2>/dev/null)"; done 2>/dev/null | tail -20`);
934
+ return ok(nodeId, result.durationMs, result.stdout || '(no tasks)', 'task list');
935
+ }
936
+ if (action === 'cancel' && task_id) {
937
+ await manager.exec(nodeId, `echo 'cancelled' > ${taskDir}/${task_id}.status`);
938
+ return okBrief(`${nodeId} ${task_id} cancelled`);
939
+ }
940
+ return fail('invalid action/params');
941
+ });
942
+ // --- Tool 38: omniwire_a2a_message ---
943
+ server.tool('omniwire_a2a_message', 'Agent-to-agent messaging via shared message queues on mesh nodes. Agents can send/receive typed messages, enabling multi-agent coordination without direct coupling. Messages are stored on disk and survive process restarts.', {
944
+ action: z.enum(['send', 'receive', 'peek', 'list_channels', 'clear']).describe('Action'),
945
+ channel: z.string().optional().describe('Message channel name (e.g., "recon-results", "scan-tasks")'),
946
+ node: z.string().optional().describe('Node hosting the queue (default: contabo)'),
947
+ message: z.string().optional().describe('Message content (for send). Can be JSON.'),
948
+ sender: z.string().optional().describe('Sender agent name (for send)'),
949
+ count: z.number().optional().describe('Number of messages to receive (default: 1). Messages are dequeued on receive.'),
950
+ }, async ({ action, channel, node, message, sender, count }) => {
951
+ const nodeId = node ?? 'contabo';
952
+ const queueDir = '/tmp/.omniwire-a2a';
953
+ if (action === 'send') {
954
+ if (!channel || !message)
955
+ return fail('channel and message required');
956
+ const ts = Date.now();
957
+ const id = `${ts}-${Math.random().toString(36).slice(2, 6)}`;
958
+ const payload = JSON.stringify({ id, ts, sender: sender ?? 'unknown', message });
959
+ const escaped = payload.replace(/'/g, "'\\''");
960
+ const result = await manager.exec(nodeId, `mkdir -p ${queueDir}/${channel} && echo '${escaped}' >> ${queueDir}/${channel}/queue`);
961
+ return result.code === 0
962
+ ? okBrief(`${channel}: message sent (${message.length} chars)`)
963
+ : fail(result.stderr);
964
+ }
965
+ if (action === 'receive') {
966
+ if (!channel)
967
+ return fail('channel required');
968
+ const n = count ?? 1;
969
+ const result = await manager.exec(nodeId, `head -${n} ${queueDir}/${channel}/queue 2>/dev/null && sed -i '1,${n}d' ${queueDir}/${channel}/queue 2>/dev/null || echo '(empty queue)'`);
970
+ return ok(nodeId, result.durationMs, result.stdout, `a2a recv ${channel}`);
971
+ }
972
+ if (action === 'peek') {
973
+ if (!channel)
974
+ return fail('channel required');
975
+ const n = count ?? 5;
976
+ const result = await manager.exec(nodeId, `head -${n} ${queueDir}/${channel}/queue 2>/dev/null || echo '(empty queue)'`);
977
+ return ok(nodeId, result.durationMs, result.stdout, `a2a peek ${channel}`);
978
+ }
979
+ if (action === 'list_channels') {
980
+ const result = await manager.exec(nodeId, `ls -1 ${queueDir}/ 2>/dev/null || echo '(no channels)'`);
981
+ return ok(nodeId, result.durationMs, result.stdout, 'a2a channels');
982
+ }
983
+ if (action === 'clear') {
984
+ if (!channel)
985
+ return fail('channel required');
986
+ await manager.exec(nodeId, `rm -f ${queueDir}/${channel}/queue`);
987
+ return okBrief(`${channel}: cleared`);
988
+ }
989
+ return fail('invalid action');
990
+ });
991
+ // --- Tool 39: omniwire_semaphore ---
992
+ server.tool('omniwire_semaphore', 'Distributed locking / semaphore for multi-agent coordination. Prevents race conditions when multiple agents operate on the same resource. Uses atomic file-based locks on mesh nodes.', {
993
+ action: z.enum(['acquire', 'release', 'status', 'list']).describe('Action'),
994
+ lock_name: z.string().optional().describe('Lock name (e.g., "deploy-prod", "db-migration")'),
995
+ node: z.string().optional().describe('Node hosting the lock (default: contabo)'),
996
+ owner: z.string().optional().describe('Owner/agent name (for acquire)'),
997
+ ttl: z.number().optional().describe('Lock TTL in seconds (default: 300). Auto-releases after TTL.'),
998
+ }, async ({ action, lock_name, node, owner, ttl }) => {
999
+ const nodeId = node ?? 'contabo';
1000
+ const lockDir = '/tmp/.omniwire-locks';
1001
+ const ttlSec = ttl ?? 300;
1002
+ if (action === 'acquire') {
1003
+ if (!lock_name)
1004
+ return fail('lock_name required');
1005
+ const lockFile = `${lockDir}/${lock_name}.lock`;
1006
+ const ownerName = owner ?? 'agent';
1007
+ const now = Date.now();
1008
+ // Simple atomic lock: mkdir as atomic test-and-set, write owner info inside
1009
+ const acquireScript = [
1010
+ `mkdir -p ${lockDir}`,
1011
+ `if mkdir ${lockFile}.d 2>/dev/null; then`,
1012
+ ` echo '${ownerName}:${now}:${ttlSec}' > ${lockFile}`,
1013
+ ` echo 'acquired'`,
1014
+ `else`,
1015
+ ` cat ${lockFile} 2>/dev/null || echo 'locked (unknown owner)'`,
1016
+ `fi`,
1017
+ ].join('\n');
1018
+ const result = await manager.exec(nodeId, acquireScript);
1019
+ return okBrief(`${lock_name}: ${result.stdout.trim()}`);
1020
+ }
1021
+ if (action === 'release') {
1022
+ if (!lock_name)
1023
+ return fail('lock_name required');
1024
+ await manager.exec(nodeId, `rm -f ${lockDir}/${lock_name}.lock && rmdir ${lockDir}/${lock_name}.lock.d 2>/dev/null`);
1025
+ return okBrief(`${lock_name}: released`);
1026
+ }
1027
+ if (action === 'status') {
1028
+ if (!lock_name)
1029
+ return fail('lock_name required');
1030
+ const result = await manager.exec(nodeId, `cat ${lockDir}/${lock_name}.lock 2>/dev/null || echo '(unlocked)'`);
1031
+ return okBrief(`${lock_name}: ${result.stdout.trim()}`);
1032
+ }
1033
+ if (action === 'list') {
1034
+ const cmd = "for f in " + lockDir + "/*.lock 2>/dev/null; do [ -f \"$f\" ] && echo \"$(basename $f .lock): $(cat $f)\"; done 2>/dev/null || echo '(no locks)'";
1035
+ const result = await manager.exec(nodeId, cmd);
1036
+ return ok(nodeId, result.durationMs, result.stdout, 'locks');
1037
+ }
1038
+ return fail('invalid action');
1039
+ });
1040
+ // --- Tool 40: omniwire_event ---
1041
+ server.tool('omniwire_event', 'Publish/subscribe events for agent coordination. Agents can emit events and other agents can poll for them. Events are timestamped and stored in a log for audit. Supports the ACP/A2A event-driven pattern.', {
1042
+ action: z.enum(['emit', 'poll', 'history', 'clear']).describe('Action'),
1043
+ topic: z.string().optional().describe('Event topic (e.g., "deploy.complete", "scan.found-vuln")'),
1044
+ node: z.string().optional().describe('Node hosting events (default: contabo)'),
1045
+ data: z.string().optional().describe('Event data/payload (for emit). Can be JSON.'),
1046
+ source: z.string().optional().describe('Source agent name (for emit)'),
1047
+ since: z.string().optional().describe('Only return events after this timestamp (epoch ms) for poll'),
1048
+ limit: z.number().optional().describe('Max events to return (default: 10)'),
1049
+ }, async ({ action, topic, node, data, source, since, limit }) => {
1050
+ const nodeId = node ?? 'contabo';
1051
+ const eventDir = '/tmp/.omniwire-events';
1052
+ const n = limit ?? 10;
1053
+ if (action === 'emit') {
1054
+ if (!topic)
1055
+ return fail('topic required');
1056
+ const event = JSON.stringify({ ts: Date.now(), topic, source: source ?? 'agent', data: data ?? '' });
1057
+ const escaped = event.replace(/'/g, "'\\''");
1058
+ await manager.exec(nodeId, `mkdir -p ${eventDir} && echo '${escaped}' >> ${eventDir}/events.log`);
1059
+ return okBrief(`event emitted: ${topic}`);
1060
+ }
1061
+ if (action === 'poll') {
1062
+ let cmd;
1063
+ if (topic && since) {
1064
+ cmd = `grep '"topic":"${topic}"' ${eventDir}/events.log 2>/dev/null | awk -F'"ts":' '{split($2,a,","); if(a[1]>${since}) print}' | tail -${n}`;
1065
+ }
1066
+ else if (topic) {
1067
+ cmd = `grep '"topic":"${topic}"' ${eventDir}/events.log 2>/dev/null | tail -${n}`;
1068
+ }
1069
+ else if (since) {
1070
+ cmd = `awk -F'"ts":' '{split($2,a,","); if(a[1]>${since}) print}' ${eventDir}/events.log 2>/dev/null | tail -${n}`;
1071
+ }
1072
+ else {
1073
+ cmd = `tail -${n} ${eventDir}/events.log 2>/dev/null || echo '(no events)'`;
1074
+ }
1075
+ const result = await manager.exec(nodeId, cmd);
1076
+ return ok(nodeId, result.durationMs, result.stdout || '(no events)', `events ${topic ?? 'all'}`);
1077
+ }
1078
+ if (action === 'history') {
1079
+ const result = await manager.exec(nodeId, `wc -l ${eventDir}/events.log 2>/dev/null | awk '{print $1}'`);
1080
+ const count = result.stdout.trim() || '0';
1081
+ return okBrief(`${count} events total`);
1082
+ }
1083
+ if (action === 'clear') {
1084
+ await manager.exec(nodeId, `rm -f ${eventDir}/events.log`);
1085
+ return okBrief('events cleared');
1086
+ }
1087
+ return fail('invalid action');
1088
+ });
1089
+ // --- Tool 41: omniwire_workflow ---
1090
+ server.tool('omniwire_workflow', 'Define and execute a named workflow (DAG of steps) that can be reused. Workflows are stored on disk and can be triggered by any agent. Supports conditional steps, fan-out/fan-in, and cross-node orchestration.', {
1091
+ action: z.enum(['define', 'run', 'list', 'get', 'delete']).describe('Action'),
1092
+ name: z.string().optional().describe('Workflow name'),
1093
+ node: z.string().optional().describe('Node to store/run workflow (default: contabo)'),
1094
+ definition: z.string().optional().describe('JSON workflow definition for define action. Format: {steps: [{node, command, label, depends_on?, store_as?}]}'),
1095
+ format: z.enum(['text', 'json']).optional(),
1096
+ }, async ({ action, name, node, definition, format }) => {
1097
+ const nodeId = node ?? 'contabo';
1098
+ const wfDir = '/tmp/.omniwire-workflows';
1099
+ const useJson = format === 'json';
1100
+ if (action === 'define') {
1101
+ if (!name || !definition)
1102
+ return fail('name and definition required');
1103
+ const escaped = definition.replace(/'/g, "'\\''");
1104
+ const result = await manager.exec(nodeId, `mkdir -p ${wfDir} && echo '${escaped}' > ${wfDir}/${name}.json`);
1105
+ return result.code === 0 ? okBrief(`workflow ${name} defined`) : fail(result.stderr);
1106
+ }
1107
+ if (action === 'run') {
1108
+ if (!name)
1109
+ return fail('name required');
1110
+ const readResult = await manager.exec(nodeId, `cat ${wfDir}/${name}.json 2>/dev/null`);
1111
+ if (readResult.code !== 0)
1112
+ return fail(`workflow ${name} not found`);
1113
+ let wf;
1114
+ try {
1115
+ wf = JSON.parse(readResult.stdout);
1116
+ }
1117
+ catch {
1118
+ return fail('invalid workflow definition');
1119
+ }
1120
+ const stepResults = [];
1121
+ const stepOutputs = [];
1122
+ for (let i = 0; i < wf.steps.length; i++) {
1123
+ const step = wf.steps[i];
1124
+ const stepNode = step.node ?? nodeId;
1125
+ let cmd = step.command
1126
+ .replace(/\{\{step(\d+)\}\}/g, (_, n) => stepOutputs[parseInt(n)] ?? '')
1127
+ .replace(/\{\{(\w+)\}\}/g, (_, key) => resultStore.get(key) ?? `{{${key}}}`);
1128
+ const result = await manager.exec(stepNode, cmd);
1129
+ stepOutputs[i] = result.stdout.trim();
1130
+ if (step.store_as && result.code === 0)
1131
+ resultStore.set(step.store_as, result.stdout.trim());
1132
+ const status = result.code === 0 ? 'ok' : `exit ${result.code}`;
1133
+ stepResults.push(useJson
1134
+ ? JSON.stringify({ step: i, node: stepNode, label: step.label, ok: result.code === 0, code: result.code, ms: result.durationMs, stdout: result.stdout.slice(0, 1000) })
1135
+ : `[${i}] ${stepNode} > ${step.label ?? `step ${i}`} ${t(result.durationMs)} ${status}\n${result.stdout.split('\n').slice(0, 5).join('\n')}`);
1136
+ if (result.code !== 0)
1137
+ break;
1138
+ }
1139
+ return useJson ? okBrief(`[${stepResults.join(',')}]`) : okBrief(trim(stepResults.join('\n\n')));
1140
+ }
1141
+ if (action === 'list') {
1142
+ const result = await manager.exec(nodeId, `ls -1 ${wfDir}/*.json 2>/dev/null | xargs -I{} basename {} .json || echo '(no workflows)'`);
1143
+ return ok(nodeId, result.durationMs, result.stdout, 'workflows');
1144
+ }
1145
+ if (action === 'get') {
1146
+ if (!name)
1147
+ return fail('name required');
1148
+ const result = await manager.exec(nodeId, `cat ${wfDir}/${name}.json 2>/dev/null || echo 'not found'`);
1149
+ return ok(nodeId, result.durationMs, result.stdout, `workflow ${name}`);
1150
+ }
1151
+ if (action === 'delete') {
1152
+ if (!name)
1153
+ return fail('name required');
1154
+ await manager.exec(nodeId, `rm -f ${wfDir}/${name}.json`);
1155
+ return okBrief(`workflow ${name} deleted`);
1156
+ }
1157
+ return fail('invalid action');
517
1158
  });
518
1159
  return server;
519
1160
  }