@marshell/cli 0.3.1 → 0.6.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/bin/marshell.js +1 -1
- package/dist/bridge.js +271 -0
- package/dist/cli.js +171 -77
- package/dist/network.js +171 -50
- package/package.json +1 -2
package/bin/marshell.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
require("../dist/cli.js");
|
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tryFastReply = tryFastReply;
|
|
4
|
+
exports.handleInbound = handleInbound;
|
|
5
|
+
exports.runBridge = runBridge;
|
|
6
|
+
const node_child_process_1 = require("node:child_process");
|
|
7
|
+
const network_1 = require("./network");
|
|
8
|
+
const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
|
|
9
|
+
/** Instant replies — no LLM spawn. Marshell transport stays fast. */
|
|
10
|
+
function tryFastReply(msg) {
|
|
11
|
+
const text = msg.text.trim();
|
|
12
|
+
if (/^(ping|pong)$/i.test(text)) {
|
|
13
|
+
return "pong";
|
|
14
|
+
}
|
|
15
|
+
if (/^(hi|hello|hey|yo|sup|picun)[!.?\s]*$/i.test(text)) {
|
|
16
|
+
return text.toLowerCase() === "picun" ? "picun" : "hi";
|
|
17
|
+
}
|
|
18
|
+
if (text.toLowerCase().startsWith("echo:")) {
|
|
19
|
+
const body = text.slice(5).trim();
|
|
20
|
+
return body || "(empty)";
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
function killProcessTree(pid) {
|
|
25
|
+
if (!pid)
|
|
26
|
+
return;
|
|
27
|
+
if (process.platform === "win32") {
|
|
28
|
+
(0, node_child_process_1.spawn)("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
29
|
+
stdio: "ignore",
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
process.kill(-pid, "SIGKILL");
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, "SIGKILL");
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function spawnCommand(command, args, options) {
|
|
47
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_REPLY_TIMEOUT_MS;
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const child = process.platform === "win32"
|
|
50
|
+
? (0, node_child_process_1.spawn)(command, args, {
|
|
51
|
+
cwd: options?.cwd,
|
|
52
|
+
env: process.env,
|
|
53
|
+
windowsHide: true,
|
|
54
|
+
shell: false,
|
|
55
|
+
})
|
|
56
|
+
: (0, node_child_process_1.spawn)(command, args, {
|
|
57
|
+
cwd: options?.cwd,
|
|
58
|
+
env: process.env,
|
|
59
|
+
detached: true,
|
|
60
|
+
});
|
|
61
|
+
let stdout = "";
|
|
62
|
+
let stderr = "";
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (code, errText) => {
|
|
65
|
+
if (settled)
|
|
66
|
+
return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
resolve({
|
|
70
|
+
code,
|
|
71
|
+
stdout,
|
|
72
|
+
stderr: errText ? `${stderr}\n${errText}`.trim() : stderr,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
killProcessTree(child.pid);
|
|
77
|
+
finish(124, `timeout after ${Math.round(timeoutMs / 1000)}s`);
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
if (options?.input) {
|
|
80
|
+
child.stdin.write(options.input);
|
|
81
|
+
child.stdin.end();
|
|
82
|
+
}
|
|
83
|
+
child.stdout.on("data", (chunk) => {
|
|
84
|
+
stdout += chunk.toString();
|
|
85
|
+
});
|
|
86
|
+
child.stderr.on("data", (chunk) => {
|
|
87
|
+
stderr += chunk.toString();
|
|
88
|
+
});
|
|
89
|
+
child.on("close", (code) => {
|
|
90
|
+
finish(code ?? 1);
|
|
91
|
+
});
|
|
92
|
+
child.on("error", (error) => {
|
|
93
|
+
finish(1, error.message);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
|
|
98
|
+
const prompt = [
|
|
99
|
+
`Marshell agent "${msg.from}" sent:`,
|
|
100
|
+
msg.text,
|
|
101
|
+
"",
|
|
102
|
+
"Reply with ONLY the answer. No tools, no marshell CLI, no meta commentary.",
|
|
103
|
+
].join("\n");
|
|
104
|
+
if (runtime === "cursor") {
|
|
105
|
+
const result = await spawnCommand("agent", [
|
|
106
|
+
"-p",
|
|
107
|
+
"--trust",
|
|
108
|
+
"--mode",
|
|
109
|
+
"ask",
|
|
110
|
+
"--workspace",
|
|
111
|
+
workspace,
|
|
112
|
+
"--output-format",
|
|
113
|
+
"text",
|
|
114
|
+
prompt,
|
|
115
|
+
], { timeoutMs });
|
|
116
|
+
if (result.code === 124) {
|
|
117
|
+
throw new Error(`auto-reply timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
118
|
+
}
|
|
119
|
+
if (result.code !== 0) {
|
|
120
|
+
throw new Error(result.stderr || result.stdout || "cursor agent failed");
|
|
121
|
+
}
|
|
122
|
+
return result.stdout.trim();
|
|
123
|
+
}
|
|
124
|
+
const result = await spawnCommand("hermes", ["-z", prompt], { timeoutMs });
|
|
125
|
+
if (result.code === 124) {
|
|
126
|
+
throw new Error(`auto-reply timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
127
|
+
}
|
|
128
|
+
if (result.code !== 0) {
|
|
129
|
+
throw new Error(result.stderr || result.stdout || "hermes failed");
|
|
130
|
+
}
|
|
131
|
+
return result.stdout.trim();
|
|
132
|
+
}
|
|
133
|
+
async function runHookReply(hook, msg, timeoutMs) {
|
|
134
|
+
const payload = JSON.stringify({
|
|
135
|
+
id: msg.id,
|
|
136
|
+
from: msg.from,
|
|
137
|
+
text: msg.text,
|
|
138
|
+
created_at: msg.created_at,
|
|
139
|
+
});
|
|
140
|
+
if (process.platform === "win32") {
|
|
141
|
+
const result = await spawnCommand("cmd.exe", ["/d", "/s", "/c", hook], { input: payload, timeoutMs });
|
|
142
|
+
if (result.code === 124) {
|
|
143
|
+
throw new Error(`hook timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
144
|
+
}
|
|
145
|
+
if (result.code !== 0) {
|
|
146
|
+
throw new Error(result.stderr || result.stdout || "hook failed");
|
|
147
|
+
}
|
|
148
|
+
return result.stdout.trim();
|
|
149
|
+
}
|
|
150
|
+
const result = await spawnCommand("sh", ["-c", hook], {
|
|
151
|
+
input: payload,
|
|
152
|
+
timeoutMs,
|
|
153
|
+
});
|
|
154
|
+
if (result.code === 124) {
|
|
155
|
+
throw new Error(`hook timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
156
|
+
}
|
|
157
|
+
if (result.code !== 0) {
|
|
158
|
+
throw new Error(result.stderr || result.stdout || "hook failed");
|
|
159
|
+
}
|
|
160
|
+
return result.stdout.trim();
|
|
161
|
+
}
|
|
162
|
+
function emitEvent(json, event) {
|
|
163
|
+
if (json) {
|
|
164
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const kind = event.type;
|
|
168
|
+
if (kind === "message") {
|
|
169
|
+
process.stdout.write(`[message] from=${event.from} id=${event.id}\n${event.text}\n`);
|
|
170
|
+
}
|
|
171
|
+
else if (kind === "reply") {
|
|
172
|
+
process.stdout.write(`[reply] to=${event.to} id=${event.id}\n`);
|
|
173
|
+
}
|
|
174
|
+
else if (kind === "error") {
|
|
175
|
+
process.stderr.write(`[bridge] ${event.message}\n`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function deliverReply(networkUrl, msg, reply, json) {
|
|
179
|
+
const sent = await (0, network_1.sendMessage)(networkUrl, msg.from, reply);
|
|
180
|
+
if (sent.kind !== "sent") {
|
|
181
|
+
throw new Error(sent.message);
|
|
182
|
+
}
|
|
183
|
+
emitEvent(json, {
|
|
184
|
+
type: "reply",
|
|
185
|
+
to: msg.from,
|
|
186
|
+
id: sent.id,
|
|
187
|
+
in_reply_to: msg.id,
|
|
188
|
+
text: reply,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async function processReplyAsync(networkUrl, msg, options) {
|
|
192
|
+
try {
|
|
193
|
+
let reply = tryFastReply(msg);
|
|
194
|
+
if (!reply && options.hook) {
|
|
195
|
+
reply = await runHookReply(options.hook, msg, options.replyTimeoutMs);
|
|
196
|
+
}
|
|
197
|
+
else if (!reply && options.autoReply && options.runtime !== "fast") {
|
|
198
|
+
reply = await generateRuntimeReply(msg, options.runtime, options.workspace, options.replyTimeoutMs);
|
|
199
|
+
}
|
|
200
|
+
if (!reply) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!reply.trim()) {
|
|
204
|
+
throw new Error("empty reply");
|
|
205
|
+
}
|
|
206
|
+
await deliverReply(networkUrl, msg, reply.trim(), options.json);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
210
|
+
emitEvent(options.json, { type: "error", message, in_reply_to: msg.id });
|
|
211
|
+
await (0, network_1.sendMessage)(networkUrl, msg.from, `Auto-reply failed (${message}). Please retry.`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function handleInbound(networkUrl, msg, options) {
|
|
215
|
+
emitEvent(options.json, {
|
|
216
|
+
type: "message",
|
|
217
|
+
id: msg.id,
|
|
218
|
+
from: msg.from,
|
|
219
|
+
text: msg.text,
|
|
220
|
+
created_at: msg.created_at,
|
|
221
|
+
});
|
|
222
|
+
const fast = tryFastReply(msg);
|
|
223
|
+
if (fast) {
|
|
224
|
+
await deliverReply(networkUrl, msg, fast, options.json);
|
|
225
|
+
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const willReply = Boolean(options.hook) ||
|
|
229
|
+
(options.autoReply && options.runtime !== "fast");
|
|
230
|
+
// Ack immediately so transport never blocks on slow LLM / hook.
|
|
231
|
+
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
232
|
+
if (!willReply) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
void processReplyAsync(networkUrl, msg, options);
|
|
236
|
+
}
|
|
237
|
+
async function runBridge(options) {
|
|
238
|
+
const label = options.agentName ?? "agent";
|
|
239
|
+
const mode = options.hook
|
|
240
|
+
? "hook"
|
|
241
|
+
: options.autoReply
|
|
242
|
+
? options.runtime === "fast"
|
|
243
|
+
? "fast-only"
|
|
244
|
+
: `auto-reply (${options.runtime})`
|
|
245
|
+
: "deliver-only";
|
|
246
|
+
process.stdout.write(`Bridge listening as '${label}' (${mode}).\n`);
|
|
247
|
+
let stopped = false;
|
|
248
|
+
const cleanup = () => {
|
|
249
|
+
if (stopped)
|
|
250
|
+
return;
|
|
251
|
+
stopped = true;
|
|
252
|
+
process.stdout.write("Bridge stopped.\n");
|
|
253
|
+
process.exit(0);
|
|
254
|
+
};
|
|
255
|
+
process.on("SIGINT", cleanup);
|
|
256
|
+
process.on("SIGTERM", cleanup);
|
|
257
|
+
while (!stopped) {
|
|
258
|
+
const result = await (0, network_1.fetchInbox)(options.networkUrl, {
|
|
259
|
+
peek: true,
|
|
260
|
+
waitSeconds: 30,
|
|
261
|
+
});
|
|
262
|
+
if (result.kind === "error") {
|
|
263
|
+
process.stderr.write(`inbox error: ${result.message}\n`);
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
for (const msg of result.messages) {
|
|
268
|
+
await handleInbound(options.networkUrl, msg, options);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -2,24 +2,38 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const node_os_1 = require("node:os");
|
|
5
|
+
const bridge_1 = require("./bridge");
|
|
5
6
|
const config_1 = require("./config");
|
|
6
7
|
const network_1 = require("./network");
|
|
7
8
|
function printHelp() {
|
|
8
9
|
const message = [
|
|
9
|
-
"marshell -
|
|
10
|
+
"marshell - agent messaging bridge",
|
|
10
11
|
"",
|
|
11
12
|
"Usage:",
|
|
12
13
|
" marshell auth set <token> [--name <name>]",
|
|
13
14
|
" marshell auth status [--json]",
|
|
14
15
|
" marshell agent join --name <name>",
|
|
15
|
-
" marshell
|
|
16
|
+
" marshell bridge run [--json] [--hook <cmd>]",
|
|
17
|
+
" marshell bridge run --auto-reply [--runtime cursor|hermes|fast] [--workspace <path>]",
|
|
18
|
+
" marshell agent run (alias for bridge run)",
|
|
16
19
|
" marshell discover [--json]",
|
|
17
20
|
" marshell send --to <name> --text \"...\" [--json]",
|
|
21
|
+
" marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
|
|
22
|
+
" marshell inbox [--json] [--wait <seconds>] [--from <name>]",
|
|
23
|
+
" marshell listen [--json] (alias for bridge run)",
|
|
18
24
|
" marshell --help",
|
|
19
25
|
"",
|
|
26
|
+
"Bridge (Happy-style transport):",
|
|
27
|
+
" Keeps a persistent listener. Delivers messages instantly.",
|
|
28
|
+
" Fast path: ping→pong, hi→hi, echo:…→… (no LLM).",
|
|
29
|
+
" --auto-reply spawns LLM only for non-trivial messages (async, non-blocking).",
|
|
30
|
+
" Prefer: marshell ask --to <peer> --text \"...\" for request/response.",
|
|
31
|
+
"",
|
|
20
32
|
"Environment:",
|
|
21
33
|
" MARSHELL_NETWORK_URL default: https://network.marshell.dev",
|
|
22
34
|
" MARSHELL_AGENT_NAME default agent name for auth set",
|
|
35
|
+
" MARSHELL_WORKSPACE workspace for cursor auto-reply",
|
|
36
|
+
" MARSHELL_HOOK default hook command for bridge",
|
|
23
37
|
" MARSHELL_CONFIG optional path to config file",
|
|
24
38
|
].join("\n");
|
|
25
39
|
process.stdout.write(`${message}\n`);
|
|
@@ -59,6 +73,36 @@ function valueForFlag(args, flag) {
|
|
|
59
73
|
}
|
|
60
74
|
return args[index + 1];
|
|
61
75
|
}
|
|
76
|
+
function bridgeOptionsFromArgs(args) {
|
|
77
|
+
const configPromise = (0, config_1.readConfig)();
|
|
78
|
+
// sync read not available — caller must await
|
|
79
|
+
void configPromise;
|
|
80
|
+
const autoReply = hasFlag(args, "--auto-reply");
|
|
81
|
+
const json = hasFlag(args, "--json");
|
|
82
|
+
const workspace = valueForFlag(args, "--workspace") ??
|
|
83
|
+
process.env.MARSHELL_WORKSPACE ??
|
|
84
|
+
process.cwd();
|
|
85
|
+
const hook = valueForFlag(args, "--hook") ?? process.env.MARSHELL_HOOK;
|
|
86
|
+
const runtimeFlag = valueForFlag(args, "--runtime");
|
|
87
|
+
const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor" || runtimeFlag === "fast"
|
|
88
|
+
? runtimeFlag
|
|
89
|
+
: process.platform === "win32"
|
|
90
|
+
? "cursor"
|
|
91
|
+
: "hermes";
|
|
92
|
+
const timeoutRaw = valueForFlag(args, "--reply-timeout");
|
|
93
|
+
const replyTimeoutMs = timeoutRaw
|
|
94
|
+
? Math.max(5, Number(timeoutRaw)) * 1000
|
|
95
|
+
: 120_000;
|
|
96
|
+
return {
|
|
97
|
+
networkUrl: "",
|
|
98
|
+
autoReply,
|
|
99
|
+
runtime,
|
|
100
|
+
workspace,
|
|
101
|
+
hook,
|
|
102
|
+
json,
|
|
103
|
+
replyTimeoutMs,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
62
106
|
async function cmdAuthSet(args) {
|
|
63
107
|
const named = valueForFlag(args, "--name");
|
|
64
108
|
const tokenArg = args.filter((a, i) => {
|
|
@@ -95,6 +139,7 @@ async function cmdAuthStatus(args) {
|
|
|
95
139
|
const payload = {
|
|
96
140
|
hasToken: Boolean(config.token),
|
|
97
141
|
hasAgentKey: Boolean(config.agentKey),
|
|
142
|
+
agentName: config.agentName ?? null,
|
|
98
143
|
networkUrl,
|
|
99
144
|
health,
|
|
100
145
|
};
|
|
@@ -124,88 +169,30 @@ async function cmdAgentJoin(args) {
|
|
|
124
169
|
}
|
|
125
170
|
printError(result.message);
|
|
126
171
|
}
|
|
127
|
-
function
|
|
128
|
-
process.stdout.write(`${message}\n`);
|
|
129
|
-
return setInterval(() => {
|
|
130
|
-
process.stdout.write(`[heartbeat] ${new Date().toISOString()}\n`);
|
|
131
|
-
}, 30_000);
|
|
132
|
-
}
|
|
133
|
-
async function tryConnectAgentWs(url) {
|
|
134
|
-
const wsCtor = globalThis.WebSocket;
|
|
135
|
-
if (!wsCtor) {
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
138
|
-
return await new Promise((resolve) => {
|
|
139
|
-
let settled = false;
|
|
140
|
-
const ws = new wsCtor(url);
|
|
141
|
-
const timeout = setTimeout(() => {
|
|
142
|
-
if (!settled) {
|
|
143
|
-
settled = true;
|
|
144
|
-
ws.close();
|
|
145
|
-
resolve(false);
|
|
146
|
-
}
|
|
147
|
-
}, 4000);
|
|
148
|
-
ws.onopen = () => {
|
|
149
|
-
clearTimeout(timeout);
|
|
150
|
-
if (settled) {
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
settled = true;
|
|
154
|
-
process.stdout.write(`Connected to ${url}\n`);
|
|
155
|
-
ws.onmessage = (event) => {
|
|
156
|
-
process.stdout.write(`[message] ${String(event.data)}\n`);
|
|
157
|
-
};
|
|
158
|
-
resolve(true);
|
|
159
|
-
};
|
|
160
|
-
ws.onerror = () => {
|
|
161
|
-
clearTimeout(timeout);
|
|
162
|
-
if (settled) {
|
|
163
|
-
return;
|
|
164
|
-
}
|
|
165
|
-
settled = true;
|
|
166
|
-
resolve(false);
|
|
167
|
-
};
|
|
168
|
-
ws.onclose = () => {
|
|
169
|
-
clearTimeout(timeout);
|
|
170
|
-
if (!settled) {
|
|
171
|
-
settled = true;
|
|
172
|
-
resolve(false);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
async function cmdAgentRun() {
|
|
172
|
+
async function cmdBridgeRun(args = []) {
|
|
178
173
|
const config = await (0, config_1.readConfig)();
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
? "Agent loop active. Waiting for work..."
|
|
184
|
-
: "waiting for network WSS (Phase 1)");
|
|
185
|
-
const cleanup = () => {
|
|
186
|
-
clearInterval(timer);
|
|
187
|
-
process.stdout.write("Exiting agent loop.\n");
|
|
188
|
-
process.exit(0);
|
|
189
|
-
};
|
|
190
|
-
process.on("SIGINT", cleanup);
|
|
191
|
-
process.on("SIGTERM", cleanup);
|
|
174
|
+
const opts = bridgeOptionsFromArgs(args);
|
|
175
|
+
opts.networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
176
|
+
opts.agentName = config.agentName;
|
|
177
|
+
await (0, bridge_1.runBridge)(opts);
|
|
192
178
|
}
|
|
193
179
|
async function cmdDiscover(args) {
|
|
194
180
|
const json = hasFlag(args, "--json");
|
|
195
181
|
const config = await (0, config_1.readConfig)();
|
|
196
182
|
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
197
|
-
const
|
|
183
|
+
const { peers } = await (0, network_1.discoverPeers)(networkUrl);
|
|
198
184
|
if (json) {
|
|
199
|
-
printJson({ peers
|
|
185
|
+
printJson({ peers });
|
|
200
186
|
return;
|
|
201
187
|
}
|
|
202
|
-
if (
|
|
203
|
-
process.stdout.write("No peers
|
|
188
|
+
if (peers.length === 0) {
|
|
189
|
+
process.stdout.write("No peers found.\n");
|
|
204
190
|
return;
|
|
205
191
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
192
|
+
for (const peer of peers) {
|
|
193
|
+
const name = typeof peer.name === "string" ? peer.name : "?";
|
|
194
|
+
const status = typeof peer.status === "string" ? peer.status : "unknown";
|
|
195
|
+
process.stdout.write(`- ${name} (${status})\n`);
|
|
209
196
|
}
|
|
210
197
|
}
|
|
211
198
|
async function cmdSend(args) {
|
|
@@ -223,15 +210,101 @@ async function cmdSend(args) {
|
|
|
223
210
|
return;
|
|
224
211
|
}
|
|
225
212
|
if (result.kind === "sent") {
|
|
226
|
-
process.stdout.write(`Sent message to '${to}' (id: ${result.id}).\n`);
|
|
213
|
+
process.stdout.write(`Sent message to '${to}' (id: ${result.id}, status: ${result.status}).\n`);
|
|
227
214
|
return;
|
|
228
215
|
}
|
|
229
|
-
|
|
230
|
-
|
|
216
|
+
printError(result.message);
|
|
217
|
+
}
|
|
218
|
+
async function cmdAsk(args) {
|
|
219
|
+
const json = hasFlag(args, "--json");
|
|
220
|
+
const to = valueForFlag(args, "--to");
|
|
221
|
+
const text = valueForFlag(args, "--text");
|
|
222
|
+
const waitRaw = valueForFlag(args, "--wait");
|
|
223
|
+
const waitSeconds = waitRaw ? Number(waitRaw) : 120;
|
|
224
|
+
if (!to || !text) {
|
|
225
|
+
printError("Usage: marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]");
|
|
226
|
+
}
|
|
227
|
+
const config = await (0, config_1.readConfig)();
|
|
228
|
+
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
229
|
+
const result = await (0, network_1.askAgent)(networkUrl, to, text, waitSeconds);
|
|
230
|
+
if (json) {
|
|
231
|
+
printJson(result);
|
|
231
232
|
return;
|
|
232
233
|
}
|
|
234
|
+
if (result.kind === "ok") {
|
|
235
|
+
process.stdout.write(`${result.reply}\n`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (result.kind === "timeout") {
|
|
239
|
+
printError(`No reply from '${to}' within ${waitSeconds}s (sent id: ${result.sent_id}). Is their bridge running?`);
|
|
240
|
+
}
|
|
233
241
|
printError(result.message);
|
|
234
242
|
}
|
|
243
|
+
async function cmdInbox(args) {
|
|
244
|
+
const json = hasFlag(args, "--json");
|
|
245
|
+
const from = valueForFlag(args, "--from");
|
|
246
|
+
const waitRaw = valueForFlag(args, "--wait");
|
|
247
|
+
const waitSeconds = waitRaw ? Number(waitRaw) : 0;
|
|
248
|
+
const config = await (0, config_1.readConfig)();
|
|
249
|
+
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
250
|
+
if (waitSeconds > 0) {
|
|
251
|
+
const result = await (0, network_1.waitForInbox)(networkUrl, { waitSeconds, from });
|
|
252
|
+
if (result.kind === "timeout") {
|
|
253
|
+
if (json) {
|
|
254
|
+
printJson({ messages: [], timed_out: true });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
process.stdout.write("No messages.\n");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (result.kind === "error") {
|
|
261
|
+
printError(result.message);
|
|
262
|
+
}
|
|
263
|
+
if (json) {
|
|
264
|
+
printJson({ messages: result.messages, agent: result.agent });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (result.messages.length === 0) {
|
|
268
|
+
process.stdout.write("Inbox empty.\n");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
for (const msg of result.messages) {
|
|
272
|
+
process.stdout.write(`[${msg.from}] ${msg.text}\n`);
|
|
273
|
+
}
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const result = await (0, network_1.fetchInbox)(networkUrl);
|
|
277
|
+
if (result.kind === "error") {
|
|
278
|
+
printError(result.message);
|
|
279
|
+
}
|
|
280
|
+
const messages = from
|
|
281
|
+
? result.messages.filter((m) => m.from.toLowerCase() === from.toLowerCase())
|
|
282
|
+
: result.messages;
|
|
283
|
+
if (messages.length > 0) {
|
|
284
|
+
await (0, network_1.ackMessages)(networkUrl, messages.map((m) => m.id));
|
|
285
|
+
}
|
|
286
|
+
if (json) {
|
|
287
|
+
printJson({ messages, agent: result.agent });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (messages.length === 0) {
|
|
291
|
+
process.stdout.write("Inbox empty.\n");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
for (const msg of messages) {
|
|
295
|
+
process.stdout.write(`[${msg.from}] ${msg.text}\n`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function cmdListen(args) {
|
|
299
|
+
const waitRaw = valueForFlag(args, "--wait");
|
|
300
|
+
const waitSeconds = waitRaw ? Number(waitRaw) : 0;
|
|
301
|
+
const json = hasFlag(args, "--json");
|
|
302
|
+
if (waitSeconds > 0) {
|
|
303
|
+
await cmdInbox(["--wait", String(waitSeconds), ...(json ? ["--json"] : [])]);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
await cmdBridgeRun(args);
|
|
307
|
+
}
|
|
235
308
|
async function main() {
|
|
236
309
|
const args = process.argv.slice(2);
|
|
237
310
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
@@ -259,11 +332,20 @@ async function main() {
|
|
|
259
332
|
return;
|
|
260
333
|
}
|
|
261
334
|
if (sub === "run") {
|
|
262
|
-
await
|
|
335
|
+
await cmdBridgeRun(rest);
|
|
263
336
|
return;
|
|
264
337
|
}
|
|
265
338
|
printError("Unknown agent command. Try: marshell agent join|run");
|
|
266
339
|
}
|
|
340
|
+
if (args[0] === "bridge") {
|
|
341
|
+
const sub = args[1];
|
|
342
|
+
const rest = args.slice(2);
|
|
343
|
+
if (sub === "run") {
|
|
344
|
+
await cmdBridgeRun(rest);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
printError("Unknown bridge command. Try: marshell bridge run");
|
|
348
|
+
}
|
|
267
349
|
if (args[0] === "discover") {
|
|
268
350
|
await cmdDiscover(args.slice(1));
|
|
269
351
|
return;
|
|
@@ -272,6 +354,18 @@ async function main() {
|
|
|
272
354
|
await cmdSend(args.slice(1));
|
|
273
355
|
return;
|
|
274
356
|
}
|
|
357
|
+
if (args[0] === "ask") {
|
|
358
|
+
await cmdAsk(args.slice(1));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (args[0] === "inbox") {
|
|
362
|
+
await cmdInbox(args.slice(1));
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (args[0] === "listen") {
|
|
366
|
+
await cmdListen(args.slice(1));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
275
369
|
printError(`Unknown command '${args[0]}'. Run marshell --help`);
|
|
276
370
|
}
|
|
277
371
|
main().catch((error) => {
|
package/dist/network.js
CHANGED
|
@@ -4,6 +4,10 @@ exports.pingNetwork = pingNetwork;
|
|
|
4
4
|
exports.joinAgent = joinAgent;
|
|
5
5
|
exports.discoverPeers = discoverPeers;
|
|
6
6
|
exports.sendMessage = sendMessage;
|
|
7
|
+
exports.fetchInbox = fetchInbox;
|
|
8
|
+
exports.ackMessages = ackMessages;
|
|
9
|
+
exports.waitForInbox = waitForInbox;
|
|
10
|
+
exports.askAgent = askAgent;
|
|
7
11
|
exports.toWsUrl = toWsUrl;
|
|
8
12
|
const config_1 = require("./config");
|
|
9
13
|
function normalizeBaseUrl(raw) {
|
|
@@ -12,6 +16,22 @@ function normalizeBaseUrl(raw) {
|
|
|
12
16
|
function withPath(baseUrl, path) {
|
|
13
17
|
return `${normalizeBaseUrl(baseUrl)}${path}`;
|
|
14
18
|
}
|
|
19
|
+
async function authHeaders(kind) {
|
|
20
|
+
const config = await (0, config_1.readConfig)();
|
|
21
|
+
const headers = {
|
|
22
|
+
"content-type": "application/json",
|
|
23
|
+
};
|
|
24
|
+
if (kind === "agent") {
|
|
25
|
+
if (!config.agentKey) {
|
|
26
|
+
throw new Error("Missing agent key. Run: marshell agent join --name <name>");
|
|
27
|
+
}
|
|
28
|
+
headers.authorization = `Bearer ${config.agentKey}`;
|
|
29
|
+
}
|
|
30
|
+
else if (config.token) {
|
|
31
|
+
headers.authorization = `Bearer ${config.token}`;
|
|
32
|
+
}
|
|
33
|
+
return headers;
|
|
34
|
+
}
|
|
15
35
|
async function pingNetwork(baseUrl) {
|
|
16
36
|
const candidates = ["/health", "/v1/health"];
|
|
17
37
|
for (const path of candidates) {
|
|
@@ -42,12 +62,10 @@ async function pingNetwork(baseUrl) {
|
|
|
42
62
|
message: "Health endpoint not found.",
|
|
43
63
|
};
|
|
44
64
|
}
|
|
45
|
-
async function postJson(url, body) {
|
|
65
|
+
async function postJson(url, body, headers) {
|
|
46
66
|
const response = await fetch(url, {
|
|
47
67
|
method: "POST",
|
|
48
|
-
headers
|
|
49
|
-
"content-type": "application/json",
|
|
50
|
-
},
|
|
68
|
+
headers,
|
|
51
69
|
body: JSON.stringify(body),
|
|
52
70
|
});
|
|
53
71
|
const text = await response.text();
|
|
@@ -78,17 +96,23 @@ async function joinAgent(baseUrl, name) {
|
|
|
78
96
|
const result = await postJson(withPath(baseUrl, "/v1/agents/join"), {
|
|
79
97
|
token: config.token,
|
|
80
98
|
name,
|
|
81
|
-
});
|
|
99
|
+
}, { "content-type": "application/json" });
|
|
82
100
|
if (result.status === 404) {
|
|
83
101
|
return { kind: "not_found" };
|
|
84
102
|
}
|
|
85
103
|
if (result.status >= 200 && result.status < 300 && result.data.agent_key) {
|
|
86
104
|
return { kind: "joined", agentKey: result.data.agent_key };
|
|
87
105
|
}
|
|
106
|
+
const errText = typeof result.data === "object" &&
|
|
107
|
+
result.data &&
|
|
108
|
+
"error" in result.data &&
|
|
109
|
+
typeof result.data.error === "string"
|
|
110
|
+
? result.data.error
|
|
111
|
+
: `Join failed with HTTP ${result.status}.`;
|
|
88
112
|
return {
|
|
89
113
|
kind: "error",
|
|
90
114
|
status: result.status,
|
|
91
|
-
message:
|
|
115
|
+
message: errText,
|
|
92
116
|
};
|
|
93
117
|
}
|
|
94
118
|
catch (error) {
|
|
@@ -100,71 +124,168 @@ async function joinAgent(baseUrl, name) {
|
|
|
100
124
|
}
|
|
101
125
|
}
|
|
102
126
|
async function discoverPeers(baseUrl) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
headers,
|
|
114
|
-
});
|
|
115
|
-
if (response.status === 404) {
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (response.ok) {
|
|
119
|
-
const data = (await response.json());
|
|
120
|
-
if (Array.isArray(data)) {
|
|
121
|
-
return { peers: data };
|
|
122
|
-
}
|
|
123
|
-
return { peers: data.peers ?? [] };
|
|
127
|
+
const headers = await authHeaders("token");
|
|
128
|
+
try {
|
|
129
|
+
const response = await fetch(withPath(baseUrl, "/v1/peers"), {
|
|
130
|
+
method: "GET",
|
|
131
|
+
headers,
|
|
132
|
+
});
|
|
133
|
+
if (response.ok) {
|
|
134
|
+
const data = (await response.json());
|
|
135
|
+
if (Array.isArray(data)) {
|
|
136
|
+
return { peers: data };
|
|
124
137
|
}
|
|
138
|
+
return { peers: data.peers ?? [] };
|
|
125
139
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return { peers: [] };
|
|
129
143
|
}
|
|
130
144
|
return { peers: [] };
|
|
131
145
|
}
|
|
132
146
|
async function sendMessage(baseUrl, to, text) {
|
|
133
|
-
const config = await (0, config_1.readConfig)();
|
|
134
|
-
if (!config.token) {
|
|
135
|
-
return { kind: "error", message: "Missing auth token." };
|
|
136
|
-
}
|
|
137
147
|
try {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
text,
|
|
142
|
-
});
|
|
143
|
-
if (result.status === 404) {
|
|
144
|
-
return {
|
|
145
|
-
kind: "stubbed",
|
|
146
|
-
reason: "Network send API is not available yet (Phase 1).",
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
if (result.status >= 200 && result.status < 300) {
|
|
148
|
+
const headers = await authHeaders("agent");
|
|
149
|
+
const result = await postJson(withPath(baseUrl, "/v1/messages/send"), { to, text }, headers);
|
|
150
|
+
if (result.status >= 200 && result.status < 300 && result.data.id) {
|
|
150
151
|
return {
|
|
151
152
|
kind: "sent",
|
|
152
|
-
id: result.data.id
|
|
153
|
+
id: result.data.id,
|
|
154
|
+
status: result.data.status ?? "delivered",
|
|
153
155
|
};
|
|
154
156
|
}
|
|
155
157
|
return {
|
|
156
158
|
kind: "error",
|
|
157
159
|
status: result.status,
|
|
158
|
-
message: `Send failed with HTTP ${result.status}.`,
|
|
160
|
+
message: result.data.error ?? `Send failed with HTTP ${result.status}.`,
|
|
159
161
|
};
|
|
160
162
|
}
|
|
161
163
|
catch (error) {
|
|
162
164
|
return {
|
|
163
|
-
kind: "
|
|
164
|
-
|
|
165
|
+
kind: "error",
|
|
166
|
+
message: error.message,
|
|
165
167
|
};
|
|
166
168
|
}
|
|
167
169
|
}
|
|
170
|
+
async function fetchInbox(baseUrl, options) {
|
|
171
|
+
try {
|
|
172
|
+
const headers = await authHeaders("agent");
|
|
173
|
+
const params = new URLSearchParams();
|
|
174
|
+
if (options?.peek) {
|
|
175
|
+
params.set("peek", "1");
|
|
176
|
+
}
|
|
177
|
+
if (options?.waitSeconds && options.waitSeconds > 0) {
|
|
178
|
+
params.set("wait", String(Math.min(120, options.waitSeconds)));
|
|
179
|
+
}
|
|
180
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
181
|
+
const response = await fetch(withPath(baseUrl, `/v1/messages/inbox${qs}`), {
|
|
182
|
+
method: "GET",
|
|
183
|
+
headers,
|
|
184
|
+
});
|
|
185
|
+
const data = (await response.json());
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "error",
|
|
189
|
+
status: response.status,
|
|
190
|
+
message: data.error ?? `Inbox failed with HTTP ${response.status}.`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
kind: "ok",
|
|
195
|
+
messages: data.messages ?? [],
|
|
196
|
+
agent: data.agent ?? "",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
return {
|
|
201
|
+
kind: "error",
|
|
202
|
+
message: error.message,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function ackMessages(baseUrl, ids) {
|
|
207
|
+
if (ids.length === 0) {
|
|
208
|
+
return { kind: "ok", acked: 0 };
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const headers = await authHeaders("agent");
|
|
212
|
+
const result = await postJson(withPath(baseUrl, "/v1/messages/ack"), { ids }, headers);
|
|
213
|
+
if (result.status >= 200 && result.status < 300) {
|
|
214
|
+
return { kind: "ok", acked: result.data.acked ?? ids.length };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
kind: "error",
|
|
218
|
+
message: result.data.error ?? `Ack failed with HTTP ${result.status}.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
return { kind: "error", message: error.message };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function waitForInbox(baseUrl, options) {
|
|
226
|
+
const deadline = Date.now() + options.waitSeconds * 1000;
|
|
227
|
+
const from = options.from?.toLowerCase();
|
|
228
|
+
while (Date.now() < deadline) {
|
|
229
|
+
const remaining = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
|
|
230
|
+
const result = await fetchInbox(baseUrl, {
|
|
231
|
+
peek: true,
|
|
232
|
+
waitSeconds: remaining,
|
|
233
|
+
});
|
|
234
|
+
if (result.kind === "error") {
|
|
235
|
+
return result;
|
|
236
|
+
}
|
|
237
|
+
const messages = from
|
|
238
|
+
? result.messages.filter((m) => m.from.toLowerCase() === from)
|
|
239
|
+
: result.messages;
|
|
240
|
+
if (messages.length > 0) {
|
|
241
|
+
await ackMessages(baseUrl, messages.map((m) => m.id));
|
|
242
|
+
return { kind: "ok", messages, agent: result.agent };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return { kind: "timeout" };
|
|
246
|
+
}
|
|
247
|
+
async function askAgent(baseUrl, to, text, waitSeconds) {
|
|
248
|
+
// Drain any stale messages from this peer before asking.
|
|
249
|
+
const stale = await fetchInbox(baseUrl, { peek: true });
|
|
250
|
+
if (stale.kind === "ok") {
|
|
251
|
+
const fromPeer = stale.messages.filter((m) => m.from.toLowerCase() === to.toLowerCase());
|
|
252
|
+
if (fromPeer.length > 0) {
|
|
253
|
+
await ackMessages(baseUrl, fromPeer.map((m) => m.id));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const sentAt = Date.now() - 2000;
|
|
257
|
+
const sent = await sendMessage(baseUrl, to, text);
|
|
258
|
+
if (sent.kind !== "sent") {
|
|
259
|
+
return { kind: "error", message: sent.message };
|
|
260
|
+
}
|
|
261
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
262
|
+
const peer = to.toLowerCase();
|
|
263
|
+
while (Date.now() < deadline) {
|
|
264
|
+
const remaining = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
|
|
265
|
+
const result = await fetchInbox(baseUrl, {
|
|
266
|
+
peek: true,
|
|
267
|
+
waitSeconds: remaining,
|
|
268
|
+
});
|
|
269
|
+
if (result.kind === "error") {
|
|
270
|
+
return { kind: "error", message: result.message };
|
|
271
|
+
}
|
|
272
|
+
const messages = result.messages.filter((m) => {
|
|
273
|
+
if (m.from.toLowerCase() !== peer)
|
|
274
|
+
return false;
|
|
275
|
+
const created = Date.parse(m.created_at);
|
|
276
|
+
return Number.isNaN(created) || created >= sentAt;
|
|
277
|
+
});
|
|
278
|
+
if (messages.length > 0) {
|
|
279
|
+
await ackMessages(baseUrl, messages.map((m) => m.id));
|
|
280
|
+
return {
|
|
281
|
+
kind: "ok",
|
|
282
|
+
reply: messages.map((m) => m.text.trim()).join("\n\n"),
|
|
283
|
+
message_id: messages[0]?.id ?? sent.id,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { kind: "timeout", sent_id: sent.id };
|
|
288
|
+
}
|
|
168
289
|
function toWsUrl(httpBase) {
|
|
169
290
|
const url = new URL(httpBase);
|
|
170
291
|
if (url.protocol === "https:") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshell/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Marshell CLI — join a subnet, discover agents, send messages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc -p tsconfig.json",
|
|
17
17
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
18
|
-
"prepare": "npm run build",
|
|
19
18
|
"prepublishOnly": "npm run build"
|
|
20
19
|
},
|
|
21
20
|
"engines": {
|