@marshell/cli 0.6.1 → 0.7.1

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,7 @@ 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");
10
11
  const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
11
12
  const GREETING_COOLDOWN_MS = 120_000;
12
13
  const ECHO_WINDOW_MS = 60_000;
@@ -257,6 +258,31 @@ async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
257
258
  }
258
259
  return result.stdout.trim();
259
260
  }
261
+ async function runNotifyCommand(notify, msg, pending) {
262
+ const payload = JSON.stringify({
263
+ event: "message",
264
+ id: msg.id,
265
+ from: msg.from,
266
+ text: msg.text,
267
+ created_at: msg.created_at,
268
+ pending: pending ?? undefined,
269
+ });
270
+ const timeoutMs = 30_000;
271
+ if (process.platform === "win32") {
272
+ const result = await spawnCommand("cmd.exe", ["/d", "/s", "/c", notify], { input: payload, timeoutMs });
273
+ if (result.code !== 0) {
274
+ throw new Error(result.stderr || result.stdout || "notify failed");
275
+ }
276
+ return;
277
+ }
278
+ const result = await spawnCommand("sh", ["-c", notify], {
279
+ input: payload,
280
+ timeoutMs,
281
+ });
282
+ if (result.code !== 0) {
283
+ throw new Error(result.stderr || result.stdout || "notify failed");
284
+ }
285
+ }
260
286
  async function runHookReply(hook, msg, timeoutMs) {
261
287
  const payload = JSON.stringify({
262
288
  id: msg.id,
@@ -301,6 +327,9 @@ function emitEvent(json, event) {
301
327
  else if (kind === "skip") {
302
328
  process.stdout.write(`[skip] from=${event.from} reason=${event.reason}\n`);
303
329
  }
330
+ else if (kind === "notify") {
331
+ process.stdout.write(`[notify] from=${event.from} id=${event.id}${event.pending ? " (tracked)" : ""}\n`);
332
+ }
304
333
  else if (kind === "error") {
305
334
  process.stderr.write(`[bridge] ${event.message}\n`);
306
335
  }
@@ -350,27 +379,46 @@ async function processReplyAsync(networkUrl, msg, options) {
350
379
  catch (error) {
351
380
  const message = error instanceof Error ? error.message : String(error);
352
381
  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
- }
382
+ // Never send failure text to peers it spams the subnet and triggers loops.
360
383
  }
361
384
  finally {
362
385
  state.inflight = false;
363
386
  }
364
387
  }
365
388
  async function handleInbound(networkUrl, msg, options) {
389
+ const pending = await (0, pending_1.matchPending)(msg.from);
366
390
  emitEvent(options.json, {
367
391
  type: "message",
368
392
  id: msg.id,
369
393
  from: msg.from,
370
394
  text: msg.text,
371
395
  created_at: msg.created_at,
396
+ pending: pending ?? undefined,
372
397
  });
373
- // Always ack first so backlog never sticks.
398
+ // Push to human channel (Telegram, webhook, etc.) — side effect only.
399
+ if (options.notify && !isEcho(msg.from, msg.text)) {
400
+ try {
401
+ await runNotifyCommand(options.notify, msg, pending);
402
+ emitEvent(options.json, {
403
+ type: "notify",
404
+ from: msg.from,
405
+ id: msg.id,
406
+ pending: pending?.context,
407
+ });
408
+ if (pending) {
409
+ await (0, pending_1.clearPending)(msg.from);
410
+ }
411
+ }
412
+ catch (error) {
413
+ const message = error instanceof Error ? error.message : String(error);
414
+ emitEvent(options.json, {
415
+ type: "error",
416
+ message: `notify: ${message}`,
417
+ in_reply_to: msg.id,
418
+ });
419
+ }
420
+ }
421
+ // Always ack so backlog never sticks.
374
422
  await (0, network_1.ackMessages)(networkUrl, [msg.id]);
375
423
  // Drop echoes of our own recent outbound (hi↔hi loops).
376
424
  if (isEcho(msg.from, msg.text)) {
@@ -404,13 +452,40 @@ async function handleInbound(networkUrl, msg, options) {
404
452
  }
405
453
  void processReplyAsync(networkUrl, msg, options);
406
454
  }
407
- async function drainBacklog(networkUrl, json) {
455
+ async function drainBacklog(networkUrl, options) {
408
456
  const result = await (0, network_1.fetchInbox)(networkUrl, { peek: true });
409
457
  if (result.kind !== "ok" || result.messages.length === 0) {
410
458
  return;
411
459
  }
460
+ // With --notify, deliver backlog for tracked peers only (avoid hi storms).
461
+ if (options.notify) {
462
+ const pendingPeers = new Set((await (0, pending_1.listPending)()).map((p) => p.peer.toLowerCase()));
463
+ const messages = [...result.messages].sort((a, b) => {
464
+ const ta = Date.parse(a.created_at) || 0;
465
+ const tb = Date.parse(b.created_at) || 0;
466
+ return ta - tb;
467
+ });
468
+ let drained = 0;
469
+ for (const msg of messages) {
470
+ if (pendingPeers.has(msg.from.toLowerCase())) {
471
+ await handleInbound(networkUrl, msg, options);
472
+ }
473
+ else {
474
+ await (0, network_1.ackMessages)(networkUrl, [msg.id]);
475
+ drained += 1;
476
+ }
477
+ }
478
+ if (drained > 0) {
479
+ emitEvent(options.json, {
480
+ type: "skip",
481
+ from: "*",
482
+ reason: `drained ${drained} untracked backlog message(s) on startup`,
483
+ });
484
+ }
485
+ return;
486
+ }
412
487
  await (0, network_1.ackMessages)(networkUrl, result.messages.map((m) => m.id));
413
- emitEvent(json, {
488
+ emitEvent(options.json, {
414
489
  type: "skip",
415
490
  from: "*",
416
491
  reason: `drained ${result.messages.length} backlog message(s) on startup`,
@@ -418,16 +493,21 @@ async function drainBacklog(networkUrl, json) {
418
493
  }
419
494
  async function runBridge(options) {
420
495
  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";
496
+ const mode = options.notify
497
+ ? options.hook
498
+ ? "hook+notify"
499
+ : options.autoReply
500
+ ? `auto-reply (${options.runtime})+notify`
501
+ : "deliver+notify"
502
+ : options.hook
503
+ ? "hook"
504
+ : options.autoReply
505
+ ? options.runtime === "fast"
506
+ ? "fast-only"
507
+ : `auto-reply (${options.runtime})`
508
+ : "deliver-only";
428
509
  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);
510
+ await drainBacklog(options.networkUrl, options);
431
511
  let stopped = false;
432
512
  const cleanup = () => {
433
513
  if (stopped)
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ 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");
8
9
  function printHelp() {
9
10
  const message = [
10
11
  "marshell - agent messaging bridge",
@@ -17,11 +18,12 @@ function printHelp() {
17
18
  " marshell bridge run --auto-reply [--runtime cursor|hermes|fast] [--workspace <path>]",
18
19
  " marshell agent run (alias for bridge run)",
19
20
  " marshell discover [--json]",
20
- " marshell send --to <name> --text \"...\" [--json]",
21
+ " marshell send --to <name> --text \"...\" [--track [context]] [--json]",
21
22
  " marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
22
23
  " marshell inbox [--json] [--wait <seconds>] [--from <name>]",
23
24
  " marshell history [--with <name>] [--limit <n>] [--json]",
24
- " marshell listen [--json] (deliver-only listener)",
25
+ " marshell listen [--json] [--notify <cmd>] (deliver-only listener)",
26
+ " marshell pending list|clear [--peer <name>] [--json]",
25
27
  " marshell --help",
26
28
  "",
27
29
  "Middleware: send / inbox / history. Agents think; Marshell only delivers.",
@@ -33,6 +35,7 @@ function printHelp() {
33
35
  " MARSHELL_AGENT_NAME default agent name for auth set",
34
36
  " MARSHELL_WORKSPACE workspace for cursor auto-reply",
35
37
  " MARSHELL_HOOK default hook command for bridge",
38
+ " MARSHELL_NOTIFY default notify command for listen (--notify)",
36
39
  " MARSHELL_CONFIG optional path to config file",
37
40
  ].join("\n");
38
41
  process.stdout.write(`${message}\n`);
@@ -72,6 +75,21 @@ function valueForFlag(args, flag) {
72
75
  }
73
76
  return args[index + 1];
74
77
  }
78
+ function trackContextFromArgs(args, fallback) {
79
+ if (!hasFlag(args, "--track")) {
80
+ return undefined;
81
+ }
82
+ const explicit = valueForFlag(args, "--track");
83
+ if (explicit !== undefined) {
84
+ return explicit;
85
+ }
86
+ const trackIndex = args.indexOf("--track");
87
+ const next = args[trackIndex + 1];
88
+ if (next && !next.startsWith("--")) {
89
+ return next;
90
+ }
91
+ return fallback;
92
+ }
75
93
  function bridgeOptionsFromArgs(args) {
76
94
  const configPromise = (0, config_1.readConfig)();
77
95
  // sync read not available — caller must await
@@ -82,6 +100,7 @@ function bridgeOptionsFromArgs(args) {
82
100
  process.env.MARSHELL_WORKSPACE ??
83
101
  process.cwd();
84
102
  const hook = valueForFlag(args, "--hook") ?? process.env.MARSHELL_HOOK;
103
+ const notify = valueForFlag(args, "--notify") ?? process.env.MARSHELL_NOTIFY;
85
104
  const runtimeFlag = valueForFlag(args, "--runtime");
86
105
  const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor" || runtimeFlag === "fast"
87
106
  ? runtimeFlag
@@ -98,6 +117,7 @@ function bridgeOptionsFromArgs(args) {
98
117
  runtime,
99
118
  workspace,
100
119
  hook,
120
+ notify,
101
121
  json,
102
122
  replyTimeoutMs,
103
123
  };
@@ -198,18 +218,33 @@ async function cmdSend(args) {
198
218
  const json = hasFlag(args, "--json");
199
219
  const to = valueForFlag(args, "--to");
200
220
  const text = valueForFlag(args, "--text");
221
+ const trackContext = trackContextFromArgs(args, text ?? "");
201
222
  if (!to || !text) {
202
- printError("Usage: marshell send --to <name> --text \"...\" [--json]");
223
+ printError('Usage: marshell send --to <name> --text "..." [--track [context]] [--json]');
203
224
  }
204
225
  const config = await (0, config_1.readConfig)();
205
226
  const networkUrl = (0, config_1.getNetworkUrl)(config);
206
227
  const result = await (0, network_1.sendMessage)(networkUrl, to, text);
228
+ if (result.kind === "sent" && trackContext) {
229
+ await (0, pending_1.trackPending)(to, {
230
+ sentMessageId: result.id,
231
+ sentText: text,
232
+ context: trackContext,
233
+ sentAt: new Date().toISOString(),
234
+ });
235
+ }
207
236
  if (json) {
208
- printJson(result);
237
+ printJson({
238
+ ...result,
239
+ tracked: Boolean(trackContext && result.kind === "sent"),
240
+ });
209
241
  return;
210
242
  }
211
243
  if (result.kind === "sent") {
212
244
  process.stdout.write(`Sent message to '${to}' (id: ${result.id}, status: ${result.status}).\n`);
245
+ if (trackContext) {
246
+ process.stdout.write(`Tracking reply from '${to}' (${trackContext}).\n`);
247
+ }
213
248
  return;
214
249
  }
215
250
  printError(result.message);
@@ -226,8 +261,19 @@ async function cmdAsk(args) {
226
261
  const config = await (0, config_1.readConfig)();
227
262
  const networkUrl = (0, config_1.getNetworkUrl)(config);
228
263
  const result = await (0, network_1.askAgent)(networkUrl, to, text, waitSeconds);
264
+ if (result.kind === "timeout") {
265
+ await (0, pending_1.trackPending)(to, {
266
+ sentMessageId: result.sent_id,
267
+ sentText: text,
268
+ context: text,
269
+ sentAt: new Date().toISOString(),
270
+ });
271
+ }
229
272
  if (json) {
230
- printJson(result);
273
+ printJson({
274
+ ...result,
275
+ tracked: result.kind === "timeout",
276
+ });
231
277
  return;
232
278
  }
233
279
  if (result.kind === "ok") {
@@ -235,7 +281,8 @@ async function cmdAsk(args) {
235
281
  return;
236
282
  }
237
283
  if (result.kind === "timeout") {
238
- printError(`No reply from '${to}' within ${waitSeconds}s (sent id: ${result.sent_id}). Is their bridge running?`);
284
+ process.stderr.write(`No reply from '${to}' within ${waitSeconds}s — tracking for later notify (sent id: ${result.sent_id}).\n`);
285
+ process.exit(1);
239
286
  }
240
287
  printError(result.message);
241
288
  }
@@ -318,6 +365,39 @@ async function cmdInbox(args) {
318
365
  process.stdout.write(`[${msg.from}] ${msg.text}\n`);
319
366
  }
320
367
  }
368
+ async function cmdPending(args) {
369
+ const json = hasFlag(args, "--json");
370
+ const sub = args[0];
371
+ if (sub === "list") {
372
+ const entries = await (0, pending_1.listPending)();
373
+ if (json) {
374
+ printJson({ pending: entries });
375
+ return;
376
+ }
377
+ if (entries.length === 0) {
378
+ process.stdout.write("No pending replies.\n");
379
+ return;
380
+ }
381
+ for (const entry of entries) {
382
+ process.stdout.write(`- ${entry.peer}: ${entry.context} (since ${entry.sentAt})\n`);
383
+ }
384
+ return;
385
+ }
386
+ if (sub === "clear") {
387
+ const peer = valueForFlag(args, "--peer");
388
+ if (!peer) {
389
+ printError("Usage: marshell pending clear --peer <name>");
390
+ }
391
+ const cleared = await (0, pending_1.clearPending)(peer);
392
+ if (json) {
393
+ printJson({ peer, cleared });
394
+ return;
395
+ }
396
+ process.stdout.write(cleared ? `Cleared pending for '${peer}'.\n` : `No pending for '${peer}'.\n`);
397
+ return;
398
+ }
399
+ printError("Usage: marshell pending list|clear [--peer <name>]");
400
+ }
321
401
  async function cmdListen(args) {
322
402
  const waitRaw = valueForFlag(args, "--wait");
323
403
  const waitSeconds = waitRaw ? Number(waitRaw) : 0;
@@ -393,6 +473,10 @@ async function main() {
393
473
  await cmdListen(args.slice(1));
394
474
  return;
395
475
  }
476
+ if (args[0] === "pending") {
477
+ await cmdPending(args.slice(1));
478
+ return;
479
+ }
396
480
  printError(`Unknown command '${args[0]}'. Run marshell --help`);
397
481
  }
398
482
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshell/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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 = `Message 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
+ });