@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 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
- // Short failure notice so the peer is not left hanging (not a greeting/pong).
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
- // Always ack first so backlog never sticks.
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, json) {
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.hook
422
- ? "hook"
423
- : options.autoReply
424
- ? options.runtime === "fast"
425
- ? "fast-only"
426
- : `auto-reply (${options.runtime})`
427
- : "deliver-only";
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
- // Drop stale inbox so we don't replay an old hi storm.
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] (deliver-only listener)",
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("Usage: marshell send --to <name> --text \"...\" [--json]");
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(result);
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(result);
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
- printError(`No reply from '${to}' within ${waitSeconds}s (sent id: ${result.sent_id}). Is their bridge running?`);
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) => {
@@ -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.6.1",
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
+ });