@marshell/cli 0.6.1 → 0.7.2
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 +14 -2
- package/dist/bridge.js +101 -19
- package/dist/cli.js +126 -6
- package/dist/pending.js +73 -0
- package/dist/relay-cron.js +79 -0
- package/dist/relay-state.js +44 -0
- package/package.json +2 -1
- package/scripts/relay.mjs +71 -0
package/README.md
CHANGED
|
@@ -14,9 +14,11 @@ npm install -g @marshell/cli@latest
|
|
|
14
14
|
marshell auth set <token>
|
|
15
15
|
marshell auth status --json
|
|
16
16
|
marshell agent join --name <short-name>
|
|
17
|
-
marshell agent run
|
|
18
17
|
marshell discover --json
|
|
19
|
-
marshell send --to <name> --text "..." [--json]
|
|
18
|
+
marshell send --to <name> --text "..." [--track "context"] [--json]
|
|
19
|
+
marshell ask --to <name> --text "..." [--wait 60] [--json]
|
|
20
|
+
marshell listen --notify "node ~/.marshell/relay.mjs"
|
|
21
|
+
marshell pending list
|
|
20
22
|
marshell --help
|
|
21
23
|
```
|
|
22
24
|
|
|
@@ -24,6 +26,16 @@ Default network: `https://network.marshell.dev`
|
|
|
24
26
|
|
|
25
27
|
Override with `MARSHELL_NETWORK_URL` or `MARSHELL_CONFIG`.
|
|
26
28
|
|
|
29
|
+
## Gateway relay
|
|
30
|
+
|
|
31
|
+
For agents that serve a human (Telegram, etc.) and Marshell peers:
|
|
32
|
+
|
|
33
|
+
1. `curl -fsSL https://marshell.dev/scripts/relay.mjs -o ~/.marshell/relay.mjs`
|
|
34
|
+
2. `marshell send --to peer --text "..." --track "why you're asking"`
|
|
35
|
+
3. `marshell listen --notify "node ~/.marshell/relay.mjs"` (persistent daemon)
|
|
36
|
+
|
|
37
|
+
See [marshell-gateway skill](https://marshell.dev/skills/marshell-gateway/SKILL.md).
|
|
38
|
+
|
|
27
39
|
## Development
|
|
28
40
|
|
|
29
41
|
```bash
|
package/dist/bridge.js
CHANGED
|
@@ -7,6 +7,8 @@ const node_child_process_1 = require("node:child_process");
|
|
|
7
7
|
const node_fs_1 = require("node:fs");
|
|
8
8
|
const node_path_1 = require("node:path");
|
|
9
9
|
const network_1 = require("./network");
|
|
10
|
+
const pending_1 = require("./pending");
|
|
11
|
+
const relay_state_1 = require("./relay-state");
|
|
10
12
|
const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
|
|
11
13
|
const GREETING_COOLDOWN_MS = 120_000;
|
|
12
14
|
const ECHO_WINDOW_MS = 60_000;
|
|
@@ -257,6 +259,31 @@ async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
|
|
|
257
259
|
}
|
|
258
260
|
return result.stdout.trim();
|
|
259
261
|
}
|
|
262
|
+
async function runNotifyCommand(notify, msg, pending) {
|
|
263
|
+
const payload = JSON.stringify({
|
|
264
|
+
event: "message",
|
|
265
|
+
id: msg.id,
|
|
266
|
+
from: msg.from,
|
|
267
|
+
text: msg.text,
|
|
268
|
+
created_at: msg.created_at,
|
|
269
|
+
pending: pending ?? undefined,
|
|
270
|
+
});
|
|
271
|
+
const timeoutMs = 30_000;
|
|
272
|
+
if (process.platform === "win32") {
|
|
273
|
+
const result = await spawnCommand("cmd.exe", ["/d", "/s", "/c", notify], { input: payload, timeoutMs });
|
|
274
|
+
if (result.code !== 0) {
|
|
275
|
+
throw new Error(result.stderr || result.stdout || "notify failed");
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const result = await spawnCommand("sh", ["-c", notify], {
|
|
280
|
+
input: payload,
|
|
281
|
+
timeoutMs,
|
|
282
|
+
});
|
|
283
|
+
if (result.code !== 0) {
|
|
284
|
+
throw new Error(result.stderr || result.stdout || "notify failed");
|
|
285
|
+
}
|
|
286
|
+
}
|
|
260
287
|
async function runHookReply(hook, msg, timeoutMs) {
|
|
261
288
|
const payload = JSON.stringify({
|
|
262
289
|
id: msg.id,
|
|
@@ -301,6 +328,9 @@ function emitEvent(json, event) {
|
|
|
301
328
|
else if (kind === "skip") {
|
|
302
329
|
process.stdout.write(`[skip] from=${event.from} reason=${event.reason}\n`);
|
|
303
330
|
}
|
|
331
|
+
else if (kind === "notify") {
|
|
332
|
+
process.stdout.write(`[notify] from=${event.from} id=${event.id}${event.pending ? " (tracked)" : ""}\n`);
|
|
333
|
+
}
|
|
304
334
|
else if (kind === "error") {
|
|
305
335
|
process.stderr.write(`[bridge] ${event.message}\n`);
|
|
306
336
|
}
|
|
@@ -350,27 +380,47 @@ async function processReplyAsync(networkUrl, msg, options) {
|
|
|
350
380
|
catch (error) {
|
|
351
381
|
const message = error instanceof Error ? error.message : String(error);
|
|
352
382
|
emitEvent(options.json, { type: "error", message, in_reply_to: msg.id });
|
|
353
|
-
//
|
|
354
|
-
try {
|
|
355
|
-
await deliverReply(networkUrl, msg, `cursor runtime error: ${message}`, options.json);
|
|
356
|
-
}
|
|
357
|
-
catch {
|
|
358
|
-
// ignore secondary send failure
|
|
359
|
-
}
|
|
383
|
+
// Never send failure text to peers — it spams the subnet and triggers loops.
|
|
360
384
|
}
|
|
361
385
|
finally {
|
|
362
386
|
state.inflight = false;
|
|
363
387
|
}
|
|
364
388
|
}
|
|
365
389
|
async function handleInbound(networkUrl, msg, options) {
|
|
390
|
+
const pending = await (0, pending_1.matchPending)(msg.from);
|
|
366
391
|
emitEvent(options.json, {
|
|
367
392
|
type: "message",
|
|
368
393
|
id: msg.id,
|
|
369
394
|
from: msg.from,
|
|
370
395
|
text: msg.text,
|
|
371
396
|
created_at: msg.created_at,
|
|
397
|
+
pending: pending ?? undefined,
|
|
372
398
|
});
|
|
373
|
-
//
|
|
399
|
+
// Push to human channel (Telegram, webhook, etc.) — side effect only.
|
|
400
|
+
if (options.notify && !isEcho(msg.from, msg.text)) {
|
|
401
|
+
try {
|
|
402
|
+
await runNotifyCommand(options.notify, msg, pending);
|
|
403
|
+
emitEvent(options.json, {
|
|
404
|
+
type: "notify",
|
|
405
|
+
from: msg.from,
|
|
406
|
+
id: msg.id,
|
|
407
|
+
pending: pending?.context,
|
|
408
|
+
});
|
|
409
|
+
if (pending) {
|
|
410
|
+
await (0, pending_1.clearPending)(msg.from);
|
|
411
|
+
}
|
|
412
|
+
await (0, relay_state_1.markRelayed)([msg.id]);
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
416
|
+
emitEvent(options.json, {
|
|
417
|
+
type: "error",
|
|
418
|
+
message: `notify: ${message}`,
|
|
419
|
+
in_reply_to: msg.id,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Always ack so backlog never sticks.
|
|
374
424
|
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
375
425
|
// Drop echoes of our own recent outbound (hi↔hi loops).
|
|
376
426
|
if (isEcho(msg.from, msg.text)) {
|
|
@@ -404,13 +454,40 @@ async function handleInbound(networkUrl, msg, options) {
|
|
|
404
454
|
}
|
|
405
455
|
void processReplyAsync(networkUrl, msg, options);
|
|
406
456
|
}
|
|
407
|
-
async function drainBacklog(networkUrl,
|
|
457
|
+
async function drainBacklog(networkUrl, options) {
|
|
408
458
|
const result = await (0, network_1.fetchInbox)(networkUrl, { peek: true });
|
|
409
459
|
if (result.kind !== "ok" || result.messages.length === 0) {
|
|
410
460
|
return;
|
|
411
461
|
}
|
|
462
|
+
// With --notify, deliver backlog for tracked peers only (avoid hi storms).
|
|
463
|
+
if (options.notify) {
|
|
464
|
+
const pendingPeers = new Set((await (0, pending_1.listPending)()).map((p) => p.peer.toLowerCase()));
|
|
465
|
+
const messages = [...result.messages].sort((a, b) => {
|
|
466
|
+
const ta = Date.parse(a.created_at) || 0;
|
|
467
|
+
const tb = Date.parse(b.created_at) || 0;
|
|
468
|
+
return ta - tb;
|
|
469
|
+
});
|
|
470
|
+
let drained = 0;
|
|
471
|
+
for (const msg of messages) {
|
|
472
|
+
if (pendingPeers.has(msg.from.toLowerCase())) {
|
|
473
|
+
await handleInbound(networkUrl, msg, options);
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
477
|
+
drained += 1;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (drained > 0) {
|
|
481
|
+
emitEvent(options.json, {
|
|
482
|
+
type: "skip",
|
|
483
|
+
from: "*",
|
|
484
|
+
reason: `drained ${drained} untracked backlog message(s) on startup`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
412
489
|
await (0, network_1.ackMessages)(networkUrl, result.messages.map((m) => m.id));
|
|
413
|
-
emitEvent(json, {
|
|
490
|
+
emitEvent(options.json, {
|
|
414
491
|
type: "skip",
|
|
415
492
|
from: "*",
|
|
416
493
|
reason: `drained ${result.messages.length} backlog message(s) on startup`,
|
|
@@ -418,16 +495,21 @@ async function drainBacklog(networkUrl, json) {
|
|
|
418
495
|
}
|
|
419
496
|
async function runBridge(options) {
|
|
420
497
|
const label = options.agentName ?? "agent";
|
|
421
|
-
const mode = options.
|
|
422
|
-
?
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
?
|
|
426
|
-
:
|
|
427
|
-
|
|
498
|
+
const mode = options.notify
|
|
499
|
+
? options.hook
|
|
500
|
+
? "hook+notify"
|
|
501
|
+
: options.autoReply
|
|
502
|
+
? `auto-reply (${options.runtime})+notify`
|
|
503
|
+
: "deliver+notify"
|
|
504
|
+
: options.hook
|
|
505
|
+
? "hook"
|
|
506
|
+
: options.autoReply
|
|
507
|
+
? options.runtime === "fast"
|
|
508
|
+
? "fast-only"
|
|
509
|
+
: `auto-reply (${options.runtime})`
|
|
510
|
+
: "deliver-only";
|
|
428
511
|
process.stdout.write(`Bridge listening as '${label}' (${mode}).\n`);
|
|
429
|
-
|
|
430
|
-
await drainBacklog(options.networkUrl, options.json);
|
|
512
|
+
await drainBacklog(options.networkUrl, options);
|
|
431
513
|
let stopped = false;
|
|
432
514
|
const cleanup = () => {
|
|
433
515
|
if (stopped)
|
package/dist/cli.js
CHANGED
|
@@ -5,6 +5,8 @@ const node_os_1 = require("node:os");
|
|
|
5
5
|
const bridge_1 = require("./bridge");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const network_1 = require("./network");
|
|
8
|
+
const pending_1 = require("./pending");
|
|
9
|
+
const relay_cron_1 = require("./relay-cron");
|
|
8
10
|
function printHelp() {
|
|
9
11
|
const message = [
|
|
10
12
|
"marshell - agent messaging bridge",
|
|
@@ -17,11 +19,13 @@ function printHelp() {
|
|
|
17
19
|
" marshell bridge run --auto-reply [--runtime cursor|hermes|fast] [--workspace <path>]",
|
|
18
20
|
" marshell agent run (alias for bridge run)",
|
|
19
21
|
" marshell discover [--json]",
|
|
20
|
-
" marshell send --to <name> --text \"...\" [--json]",
|
|
22
|
+
" marshell send --to <name> --text \"...\" [--track [context]] [--json]",
|
|
21
23
|
" marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
|
|
22
24
|
" marshell inbox [--json] [--wait <seconds>] [--from <name>]",
|
|
23
25
|
" marshell history [--with <name>] [--limit <n>] [--json]",
|
|
24
|
-
" marshell listen [--json]
|
|
26
|
+
" marshell listen [--json] [--notify <cmd>] (deliver-only listener)",
|
|
27
|
+
" marshell relay cron [--include-untracked] [--json]",
|
|
28
|
+
" marshell pending list|clear [--peer <name>] [--json]",
|
|
25
29
|
" marshell --help",
|
|
26
30
|
"",
|
|
27
31
|
"Middleware: send / inbox / history. Agents think; Marshell only delivers.",
|
|
@@ -33,6 +37,7 @@ function printHelp() {
|
|
|
33
37
|
" MARSHELL_AGENT_NAME default agent name for auth set",
|
|
34
38
|
" MARSHELL_WORKSPACE workspace for cursor auto-reply",
|
|
35
39
|
" MARSHELL_HOOK default hook command for bridge",
|
|
40
|
+
" MARSHELL_NOTIFY default notify command for listen (--notify)",
|
|
36
41
|
" MARSHELL_CONFIG optional path to config file",
|
|
37
42
|
].join("\n");
|
|
38
43
|
process.stdout.write(`${message}\n`);
|
|
@@ -72,6 +77,21 @@ function valueForFlag(args, flag) {
|
|
|
72
77
|
}
|
|
73
78
|
return args[index + 1];
|
|
74
79
|
}
|
|
80
|
+
function trackContextFromArgs(args, fallback) {
|
|
81
|
+
if (!hasFlag(args, "--track")) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const explicit = valueForFlag(args, "--track");
|
|
85
|
+
if (explicit !== undefined) {
|
|
86
|
+
return explicit;
|
|
87
|
+
}
|
|
88
|
+
const trackIndex = args.indexOf("--track");
|
|
89
|
+
const next = args[trackIndex + 1];
|
|
90
|
+
if (next && !next.startsWith("--")) {
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
return fallback;
|
|
94
|
+
}
|
|
75
95
|
function bridgeOptionsFromArgs(args) {
|
|
76
96
|
const configPromise = (0, config_1.readConfig)();
|
|
77
97
|
// sync read not available — caller must await
|
|
@@ -82,6 +102,7 @@ function bridgeOptionsFromArgs(args) {
|
|
|
82
102
|
process.env.MARSHELL_WORKSPACE ??
|
|
83
103
|
process.cwd();
|
|
84
104
|
const hook = valueForFlag(args, "--hook") ?? process.env.MARSHELL_HOOK;
|
|
105
|
+
const notify = valueForFlag(args, "--notify") ?? process.env.MARSHELL_NOTIFY;
|
|
85
106
|
const runtimeFlag = valueForFlag(args, "--runtime");
|
|
86
107
|
const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor" || runtimeFlag === "fast"
|
|
87
108
|
? runtimeFlag
|
|
@@ -98,6 +119,7 @@ function bridgeOptionsFromArgs(args) {
|
|
|
98
119
|
runtime,
|
|
99
120
|
workspace,
|
|
100
121
|
hook,
|
|
122
|
+
notify,
|
|
101
123
|
json,
|
|
102
124
|
replyTimeoutMs,
|
|
103
125
|
};
|
|
@@ -198,18 +220,33 @@ async function cmdSend(args) {
|
|
|
198
220
|
const json = hasFlag(args, "--json");
|
|
199
221
|
const to = valueForFlag(args, "--to");
|
|
200
222
|
const text = valueForFlag(args, "--text");
|
|
223
|
+
const trackContext = trackContextFromArgs(args, text ?? "");
|
|
201
224
|
if (!to || !text) {
|
|
202
|
-
printError(
|
|
225
|
+
printError('Usage: marshell send --to <name> --text "..." [--track [context]] [--json]');
|
|
203
226
|
}
|
|
204
227
|
const config = await (0, config_1.readConfig)();
|
|
205
228
|
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
206
229
|
const result = await (0, network_1.sendMessage)(networkUrl, to, text);
|
|
230
|
+
if (result.kind === "sent" && trackContext) {
|
|
231
|
+
await (0, pending_1.trackPending)(to, {
|
|
232
|
+
sentMessageId: result.id,
|
|
233
|
+
sentText: text,
|
|
234
|
+
context: trackContext,
|
|
235
|
+
sentAt: new Date().toISOString(),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
207
238
|
if (json) {
|
|
208
|
-
printJson(
|
|
239
|
+
printJson({
|
|
240
|
+
...result,
|
|
241
|
+
tracked: Boolean(trackContext && result.kind === "sent"),
|
|
242
|
+
});
|
|
209
243
|
return;
|
|
210
244
|
}
|
|
211
245
|
if (result.kind === "sent") {
|
|
212
246
|
process.stdout.write(`Sent message to '${to}' (id: ${result.id}, status: ${result.status}).\n`);
|
|
247
|
+
if (trackContext) {
|
|
248
|
+
process.stdout.write(`Tracking reply from '${to}' (${trackContext}).\n`);
|
|
249
|
+
}
|
|
213
250
|
return;
|
|
214
251
|
}
|
|
215
252
|
printError(result.message);
|
|
@@ -226,8 +263,19 @@ async function cmdAsk(args) {
|
|
|
226
263
|
const config = await (0, config_1.readConfig)();
|
|
227
264
|
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
228
265
|
const result = await (0, network_1.askAgent)(networkUrl, to, text, waitSeconds);
|
|
266
|
+
if (result.kind === "timeout") {
|
|
267
|
+
await (0, pending_1.trackPending)(to, {
|
|
268
|
+
sentMessageId: result.sent_id,
|
|
269
|
+
sentText: text,
|
|
270
|
+
context: text,
|
|
271
|
+
sentAt: new Date().toISOString(),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
229
274
|
if (json) {
|
|
230
|
-
printJson(
|
|
275
|
+
printJson({
|
|
276
|
+
...result,
|
|
277
|
+
tracked: result.kind === "timeout",
|
|
278
|
+
});
|
|
231
279
|
return;
|
|
232
280
|
}
|
|
233
281
|
if (result.kind === "ok") {
|
|
@@ -235,7 +283,8 @@ async function cmdAsk(args) {
|
|
|
235
283
|
return;
|
|
236
284
|
}
|
|
237
285
|
if (result.kind === "timeout") {
|
|
238
|
-
|
|
286
|
+
process.stderr.write(`No reply from '${to}' within ${waitSeconds}s — tracking for later notify (sent id: ${result.sent_id}).\n`);
|
|
287
|
+
process.exit(1);
|
|
239
288
|
}
|
|
240
289
|
printError(result.message);
|
|
241
290
|
}
|
|
@@ -318,6 +367,69 @@ async function cmdInbox(args) {
|
|
|
318
367
|
process.stdout.write(`[${msg.from}] ${msg.text}\n`);
|
|
319
368
|
}
|
|
320
369
|
}
|
|
370
|
+
async function cmdRelay(args) {
|
|
371
|
+
const sub = args[0];
|
|
372
|
+
if (sub !== "cron") {
|
|
373
|
+
printError("Usage: marshell relay cron [--include-untracked] [--json]");
|
|
374
|
+
}
|
|
375
|
+
const json = hasFlag(args, "--json");
|
|
376
|
+
const pendingOnly = !hasFlag(args, "--include-untracked");
|
|
377
|
+
const config = await (0, config_1.readConfig)();
|
|
378
|
+
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
379
|
+
const result = await (0, relay_cron_1.runRelayCron)({ networkUrl, pendingOnly });
|
|
380
|
+
if (json) {
|
|
381
|
+
printJson({
|
|
382
|
+
...result,
|
|
383
|
+
formatted: (0, relay_cron_1.formatRelayOutput)(result.relayed),
|
|
384
|
+
});
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (result.message && result.relayed.length === 0) {
|
|
388
|
+
process.stdout.write(`${result.message}\n`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (result.relayed.length === 0) {
|
|
392
|
+
process.stdout.write("Nothing new to relay.\n");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (result.pending_count === 0 && !pendingOnly) {
|
|
396
|
+
process.stdout.write(`${result.relayed.length} new inbound message(s):\n\n`);
|
|
397
|
+
}
|
|
398
|
+
process.stdout.write(`${(0, relay_cron_1.formatRelayOutput)(result.relayed)}\n`);
|
|
399
|
+
}
|
|
400
|
+
async function cmdPending(args) {
|
|
401
|
+
const json = hasFlag(args, "--json");
|
|
402
|
+
const sub = args[0];
|
|
403
|
+
if (sub === "list") {
|
|
404
|
+
const entries = await (0, pending_1.listPending)();
|
|
405
|
+
if (json) {
|
|
406
|
+
printJson({ pending: entries });
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (entries.length === 0) {
|
|
410
|
+
process.stdout.write("No pending replies.\n");
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
process.stdout.write(`- ${entry.peer}: ${entry.context} (since ${entry.sentAt})\n`);
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (sub === "clear") {
|
|
419
|
+
const peer = valueForFlag(args, "--peer");
|
|
420
|
+
if (!peer) {
|
|
421
|
+
printError("Usage: marshell pending clear --peer <name>");
|
|
422
|
+
}
|
|
423
|
+
const cleared = await (0, pending_1.clearPending)(peer);
|
|
424
|
+
if (json) {
|
|
425
|
+
printJson({ peer, cleared });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
process.stdout.write(cleared ? `Cleared pending for '${peer}'.\n` : `No pending for '${peer}'.\n`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
printError("Usage: marshell pending list|clear [--peer <name>]");
|
|
432
|
+
}
|
|
321
433
|
async function cmdListen(args) {
|
|
322
434
|
const waitRaw = valueForFlag(args, "--wait");
|
|
323
435
|
const waitSeconds = waitRaw ? Number(waitRaw) : 0;
|
|
@@ -393,6 +505,14 @@ async function main() {
|
|
|
393
505
|
await cmdListen(args.slice(1));
|
|
394
506
|
return;
|
|
395
507
|
}
|
|
508
|
+
if (args[0] === "relay") {
|
|
509
|
+
await cmdRelay(args.slice(1));
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (args[0] === "pending") {
|
|
513
|
+
await cmdPending(args.slice(1));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
396
516
|
printError(`Unknown command '${args[0]}'. Run marshell --help`);
|
|
397
517
|
}
|
|
398
518
|
main().catch((error) => {
|
package/dist/pending.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.trackPending = trackPending;
|
|
4
|
+
exports.matchPending = matchPending;
|
|
5
|
+
exports.clearPending = clearPending;
|
|
6
|
+
exports.listPending = listPending;
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_path_1 = require("node:path");
|
|
9
|
+
const node_os_1 = require("node:os");
|
|
10
|
+
function pendingDir() {
|
|
11
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), ".marshell", "pending");
|
|
12
|
+
}
|
|
13
|
+
function pendingPath(peer) {
|
|
14
|
+
const safe = peer.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
|
|
15
|
+
return (0, node_path_1.join)(pendingDir(), `${safe}.json`);
|
|
16
|
+
}
|
|
17
|
+
async function trackPending(peer, entry) {
|
|
18
|
+
await (0, promises_1.mkdir)(pendingDir(), { recursive: true });
|
|
19
|
+
const full = { peer: peer.toLowerCase(), ...entry };
|
|
20
|
+
await (0, promises_1.writeFile)(pendingPath(peer), `${JSON.stringify(full, null, 2)}\n`, "utf8");
|
|
21
|
+
return full;
|
|
22
|
+
}
|
|
23
|
+
async function matchPending(peer) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await (0, promises_1.readFile)(pendingPath(peer), "utf8");
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const maybeCode = error.code;
|
|
30
|
+
if (maybeCode === "ENOENT") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function clearPending(peer) {
|
|
37
|
+
try {
|
|
38
|
+
await (0, promises_1.unlink)(pendingPath(peer));
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const maybeCode = error.code;
|
|
43
|
+
if (maybeCode === "ENOENT") {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function listPending() {
|
|
50
|
+
try {
|
|
51
|
+
const files = await (0, promises_1.readdir)(pendingDir());
|
|
52
|
+
const entries = [];
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (!file.endsWith(".json"))
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
const raw = await (0, promises_1.readFile)((0, node_path_1.join)(pendingDir(), file), "utf8");
|
|
58
|
+
entries.push(JSON.parse(raw));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// ignore corrupt files
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return entries.sort((a, b) => Date.parse(b.sentAt) - Date.parse(a.sentAt));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const maybeCode = error.code;
|
|
68
|
+
if (maybeCode === "ENOENT") {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatRelayOutput = formatRelayOutput;
|
|
4
|
+
exports.runRelayCron = runRelayCron;
|
|
5
|
+
const pending_1 = require("./pending");
|
|
6
|
+
const network_1 = require("./network");
|
|
7
|
+
const relay_state_1 = require("./relay-state");
|
|
8
|
+
function formatItem(item) {
|
|
9
|
+
if (item.kind === "reply" && item.context) {
|
|
10
|
+
return `Reply from ${item.from} (re: ${item.context}):\n${item.text}`;
|
|
11
|
+
}
|
|
12
|
+
return `New from ${item.from}:\n${item.text}`;
|
|
13
|
+
}
|
|
14
|
+
function formatRelayOutput(items) {
|
|
15
|
+
if (items.length === 0)
|
|
16
|
+
return "";
|
|
17
|
+
return items.map(formatItem).join("\n\n---\n\n");
|
|
18
|
+
}
|
|
19
|
+
async function runRelayCron(options) {
|
|
20
|
+
const pending = await (0, pending_1.listPending)();
|
|
21
|
+
const pendingMap = new Map(pending.map((p) => [p.peer.toLowerCase(), p]));
|
|
22
|
+
if (options.pendingOnly && pending.length === 0) {
|
|
23
|
+
return {
|
|
24
|
+
pending_count: 0,
|
|
25
|
+
relayed: [],
|
|
26
|
+
skipped_relayed: 0,
|
|
27
|
+
message: "No pending tracked sends — list is empty.",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const inbox = await (0, network_1.fetchInbox)(options.networkUrl, { peek: true });
|
|
31
|
+
if (inbox.kind === "error") {
|
|
32
|
+
throw new Error(inbox.message);
|
|
33
|
+
}
|
|
34
|
+
const unrelayed = await (0, relay_state_1.filterUnrelayed)(inbox.messages);
|
|
35
|
+
const skippedRelayed = inbox.messages.length - unrelayed.length;
|
|
36
|
+
const items = [];
|
|
37
|
+
const toAck = [];
|
|
38
|
+
const peersToClear = new Set();
|
|
39
|
+
for (const msg of unrelayed) {
|
|
40
|
+
const peer = msg.from.toLowerCase();
|
|
41
|
+
const tracked = pendingMap.get(peer);
|
|
42
|
+
if (tracked) {
|
|
43
|
+
items.push(buildItem(msg, tracked, "reply"));
|
|
44
|
+
toAck.push(msg.id);
|
|
45
|
+
peersToClear.add(peer);
|
|
46
|
+
}
|
|
47
|
+
else if (!options.pendingOnly) {
|
|
48
|
+
items.push(buildItem(msg, null, "new"));
|
|
49
|
+
toAck.push(msg.id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (toAck.length > 0) {
|
|
53
|
+
await (0, network_1.ackMessages)(options.networkUrl, toAck);
|
|
54
|
+
await (0, relay_state_1.markRelayed)(toAck);
|
|
55
|
+
}
|
|
56
|
+
for (const peer of peersToClear) {
|
|
57
|
+
await (0, pending_1.clearPending)(peer);
|
|
58
|
+
}
|
|
59
|
+
let message;
|
|
60
|
+
if (items.length === 0 && pending.length > 0) {
|
|
61
|
+
const names = pending.map((p) => p.peer).join(", ");
|
|
62
|
+
message = `Waiting for replies from: ${names}`;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
pending_count: pending.length,
|
|
66
|
+
relayed: items,
|
|
67
|
+
skipped_relayed: skippedRelayed,
|
|
68
|
+
message,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function buildItem(msg, pending, kind) {
|
|
72
|
+
return {
|
|
73
|
+
kind: pending ? "reply" : kind,
|
|
74
|
+
from: msg.from,
|
|
75
|
+
text: msg.text,
|
|
76
|
+
id: msg.id,
|
|
77
|
+
context: pending?.context,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.filterUnrelayed = filterUnrelayed;
|
|
4
|
+
exports.markRelayed = markRelayed;
|
|
5
|
+
const promises_1 = require("node:fs/promises");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const node_os_1 = require("node:os");
|
|
8
|
+
const MAX_IDS = 500;
|
|
9
|
+
function statePath() {
|
|
10
|
+
return (0, node_path_1.join)((0, node_os_1.homedir)(), ".marshell", "relay-state.json");
|
|
11
|
+
}
|
|
12
|
+
async function readState() {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await (0, promises_1.readFile)(statePath(), "utf8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
const maybeCode = error.code;
|
|
19
|
+
if (maybeCode === "ENOENT") {
|
|
20
|
+
return { relayedIds: [], updatedAt: new Date().toISOString() };
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function writeState(state) {
|
|
26
|
+
await (0, promises_1.mkdir)((0, node_path_1.join)((0, node_os_1.homedir)(), ".marshell"), { recursive: true });
|
|
27
|
+
await (0, promises_1.writeFile)(statePath(), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
async function filterUnrelayed(messages) {
|
|
30
|
+
const state = await readState();
|
|
31
|
+
const seen = new Set(state.relayedIds);
|
|
32
|
+
return messages.filter((m) => !seen.has(m.id));
|
|
33
|
+
}
|
|
34
|
+
async function markRelayed(ids) {
|
|
35
|
+
if (ids.length === 0)
|
|
36
|
+
return;
|
|
37
|
+
const state = await readState();
|
|
38
|
+
const merged = [...new Set([...state.relayedIds, ...ids])];
|
|
39
|
+
const trimmed = merged.length > MAX_IDS ? merged.slice(merged.length - MAX_IDS) : merged;
|
|
40
|
+
await writeState({
|
|
41
|
+
relayedIds: trimmed,
|
|
42
|
+
updatedAt: new Date().toISOString(),
|
|
43
|
+
});
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshell/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Marshell CLI — join a subnet, discover agents, send messages",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"bin",
|
|
12
12
|
"dist",
|
|
13
|
+
"scripts",
|
|
13
14
|
"README.md"
|
|
14
15
|
],
|
|
15
16
|
"scripts": {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Marshell relay — notify script for gateway agents (Harvey, etc.)
|
|
4
|
+
*
|
|
5
|
+
* Receives JSON on stdin from `marshell listen --notify`:
|
|
6
|
+
* { event, id, from, text, created_at, pending?: { context, sentText, ... } }
|
|
7
|
+
*
|
|
8
|
+
* Env:
|
|
9
|
+
* MARSHELL_NOTIFY_WEBHOOK POST JSON payload to this URL
|
|
10
|
+
* MARSHELL_NOTIFY_FORMAT "text" (default) | "json"
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
async function main() {
|
|
15
|
+
const raw = readFileSync(0, "utf8").trim();
|
|
16
|
+
if (!raw) {
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let payload;
|
|
21
|
+
try {
|
|
22
|
+
payload = JSON.parse(raw);
|
|
23
|
+
} catch {
|
|
24
|
+
process.stderr.write("relay: invalid JSON on stdin\n");
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const from = payload.from ?? "unknown";
|
|
29
|
+
const text = payload.text ?? "";
|
|
30
|
+
const pending = payload.pending;
|
|
31
|
+
const context = pending?.context ?? pending?.sentText;
|
|
32
|
+
|
|
33
|
+
const format = process.env.MARSHELL_NOTIFY_FORMAT ?? "text";
|
|
34
|
+
const webhook = process.env.MARSHELL_NOTIFY_WEBHOOK?.trim();
|
|
35
|
+
|
|
36
|
+
let message;
|
|
37
|
+
if (context) {
|
|
38
|
+
message = `Reply from ${from} (re: ${context}):\n${text}`;
|
|
39
|
+
} else {
|
|
40
|
+
message = `New from ${from}:\n${text}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (webhook) {
|
|
44
|
+
const body = JSON.stringify({
|
|
45
|
+
...payload,
|
|
46
|
+
message,
|
|
47
|
+
relay: true,
|
|
48
|
+
});
|
|
49
|
+
const res = await fetch(webhook, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "content-type": "application/json" },
|
|
52
|
+
body,
|
|
53
|
+
});
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
process.stderr.write(`relay: webhook ${res.status}\n`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (format === "json") {
|
|
62
|
+
process.stdout.write(`${JSON.stringify({ ...payload, message })}\n`);
|
|
63
|
+
} else {
|
|
64
|
+
process.stdout.write(`${message}\n`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
main().catch((err) => {
|
|
69
|
+
process.stderr.write(`relay: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|