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.
- package/README.md +426 -438
- package/dist/mcp/server.js +785 -144
- package/dist/mcp/server.js.map +1 -1
- package/dist/nodes/manager.js +16 -14
- package/dist/nodes/manager.js.map +1 -1
- package/dist/nodes/transfer.js +3 -3
- package/dist/nodes/transfer.js.map +1 -1
- package/package.json +1 -1
package/dist/mcp/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// OmniWire MCP Server
|
|
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 {
|
|
13
|
+
import { remoteNodes, findNode, NODE_ROLES, getDefaultNodeForTask } from '../protocol/config.js';
|
|
14
14
|
import { parseMeshPath } from '../protocol/paths.js';
|
|
15
|
-
|
|
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
|
|
18
|
-
return { content: [{ type: 'text', text:
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
38
|
-
label: z.string().optional().describe('Short label for the operation
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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('
|
|
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 '${
|
|
56
|
-
:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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,
|
|
73
|
-
: await manager.execAll(
|
|
74
|
-
|
|
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
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
|
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
|
|
99
|
-
const
|
|
100
|
-
const role = NODE_ROLES[meshNode.id] ?? '
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
189
|
+
return okBrief(trim(content));
|
|
135
190
|
}
|
|
136
191
|
catch (e) {
|
|
137
|
-
return
|
|
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 {
|
|
210
|
+
return okBrief(`${nodeId}:${filePath} written`);
|
|
156
211
|
}
|
|
157
212
|
catch (e) {
|
|
158
|
-
return
|
|
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
|
|
225
|
+
return fail(`invalid source: ${src} (use node:/path)`);
|
|
171
226
|
if (!dstParsed)
|
|
172
|
-
return
|
|
227
|
+
return fail(`invalid dest: ${dst} (use node:/path)`);
|
|
173
228
|
try {
|
|
174
|
-
const
|
|
175
|
-
|
|
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
|
|
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
|
|
254
|
+
return okBrief(trim(text || '(empty)'));
|
|
197
255
|
}
|
|
198
256
|
catch (e) {
|
|
199
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
?
|
|
280
|
-
:
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
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
|
|
388
|
+
return okBrief(`tunnel ${info.id} :${info.localPort} -> ${info.nodeId}:${info.remotePort}`);
|
|
335
389
|
}
|
|
336
390
|
catch (e) {
|
|
337
|
-
return
|
|
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
|
|
354
|
-
?
|
|
355
|
-
:
|
|
356
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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 '
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
479
|
-
label: z.string().optional().describe('Short label
|
|
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
|
|
482
|
-
|
|
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
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
511
|
-
? `
|
|
512
|
-
: `
|
|
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
|
|
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
|
}
|