@marshell/cli 0.7.1 → 0.7.4

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/dist/bridge.js CHANGED
@@ -8,6 +8,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
10
  const pending_1 = require("./pending");
11
+ const relay_state_1 = require("./relay-state");
11
12
  const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
12
13
  const GREETING_COOLDOWN_MS = 120_000;
13
14
  const ECHO_WINDOW_MS = 60_000;
@@ -395,7 +396,7 @@ async function handleInbound(networkUrl, msg, options) {
395
396
  created_at: msg.created_at,
396
397
  pending: pending ?? undefined,
397
398
  });
398
- // Push to human channel (Telegram, webhook, etc.) side effect only.
399
+ // Push to human channel. On failure, leave message in inbox for `relay cron`.
399
400
  if (options.notify && !isEcho(msg.from, msg.text)) {
400
401
  try {
401
402
  await runNotifyCommand(options.notify, msg, pending);
@@ -408,6 +409,7 @@ async function handleInbound(networkUrl, msg, options) {
408
409
  if (pending) {
409
410
  await (0, pending_1.clearPending)(msg.from);
410
411
  }
412
+ await (0, relay_state_1.markRelayed)([msg.id]);
411
413
  }
412
414
  catch (error) {
413
415
  const message = error instanceof Error ? error.message : String(error);
@@ -416,9 +418,10 @@ async function handleInbound(networkUrl, msg, options) {
416
418
  message: `notify: ${message}`,
417
419
  in_reply_to: msg.id,
418
420
  });
421
+ // Do not ack — cron will retry delivery to the human.
422
+ return;
419
423
  }
420
424
  }
421
- // Always ack so backlog never sticks.
422
425
  await (0, network_1.ackMessages)(networkUrl, [msg.id]);
423
426
  // Drop echoes of our own recent outbound (hi↔hi loops).
424
427
  if (isEcho(msg.from, msg.text)) {
package/dist/cli.js CHANGED
@@ -6,6 +6,7 @@ const bridge_1 = require("./bridge");
6
6
  const config_1 = require("./config");
7
7
  const network_1 = require("./network");
8
8
  const pending_1 = require("./pending");
9
+ const relay_cron_1 = require("./relay-cron");
9
10
  function printHelp() {
10
11
  const message = [
11
12
  "marshell - agent messaging bridge",
@@ -23,6 +24,7 @@ function printHelp() {
23
24
  " marshell inbox [--json] [--wait <seconds>] [--from <name>]",
24
25
  " marshell history [--with <name>] [--limit <n>] [--json]",
25
26
  " marshell listen [--json] [--notify <cmd>] (deliver-only listener)",
27
+ " marshell relay cron [--include-untracked] [--json]",
26
28
  " marshell pending list|clear [--peer <name>] [--json]",
27
29
  " marshell --help",
28
30
  "",
@@ -365,6 +367,32 @@ async function cmdInbox(args) {
365
367
  process.stdout.write(`[${msg.from}] ${msg.text}\n`);
366
368
  }
367
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.relayed.length > 0) {
388
+ if (result.pending_count === 0 && !pendingOnly) {
389
+ process.stdout.write(`${result.relayed.length} new inbound message(s):\n\n`);
390
+ }
391
+ process.stdout.write(`${(0, relay_cron_1.formatRelayOutput)(result.relayed)}\n`);
392
+ return;
393
+ }
394
+ process.stdout.write(result.message ?? "Nothing new to relay.\n");
395
+ }
368
396
  async function cmdPending(args) {
369
397
  const json = hasFlag(args, "--json");
370
398
  const sub = args[0];
@@ -473,6 +501,10 @@ async function main() {
473
501
  await cmdListen(args.slice(1));
474
502
  return;
475
503
  }
504
+ if (args[0] === "relay") {
505
+ await cmdRelay(args.slice(1));
506
+ return;
507
+ }
476
508
  if (args[0] === "pending") {
477
509
  await cmdPending(args.slice(1));
478
510
  return;
@@ -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.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "Marshell CLI — join a subnet, discover agents, send messages",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
package/scripts/relay.mjs CHANGED
@@ -37,7 +37,7 @@ async function main() {
37
37
  if (context) {
38
38
  message = `Reply from ${from} (re: ${context}):\n${text}`;
39
39
  } else {
40
- message = `Message from ${from}:\n${text}`;
40
+ message = `New from ${from}:\n${text}`;
41
41
  }
42
42
 
43
43
  if (webhook) {
@@ -58,11 +58,21 @@ async function main() {
58
58
  process.exit(0);
59
59
  }
60
60
 
61
- if (format === "json") {
62
- process.stdout.write(`${JSON.stringify({ ...payload, message })}\n`);
63
- } else {
64
- process.stdout.write(`${message}\n`);
61
+ // Without a webhook, stdout is only a log — not human delivery. Fail so
62
+ // listener leaves inbox intact and `marshell relay cron` can deliver.
63
+ if (process.env.MARSHELL_ALLOW_STDOUT_RELAY === "1") {
64
+ if (format === "json") {
65
+ process.stdout.write(`${JSON.stringify({ ...payload, message })}\n`);
66
+ } else {
67
+ process.stdout.write(`${message}\n`);
68
+ }
69
+ process.exit(0);
65
70
  }
71
+
72
+ process.stderr.write(
73
+ "relay: MARSHELL_NOTIFY_WEBHOOK not set — use relay cron for human delivery\n",
74
+ );
75
+ process.exit(1);
66
76
  }
67
77
 
68
78
  main().catch((err) => {