@marshell/cli 0.6.0 → 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
@@ -4,23 +4,145 @@ exports.tryFastReply = tryFastReply;
4
4
  exports.handleInbound = handleInbound;
5
5
  exports.runBridge = runBridge;
6
6
  const node_child_process_1 = require("node:child_process");
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
7
9
  const network_1 = require("./network");
10
+ const pending_1 = require("./pending");
8
11
  const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
9
- /** Instant replies — no LLM spawn. Marshell transport stays fast. */
10
- function tryFastReply(msg) {
12
+ const GREETING_COOLDOWN_MS = 120_000;
13
+ const ECHO_WINDOW_MS = 60_000;
14
+ const peers = new Map();
15
+ function peerState(name) {
16
+ const key = name.toLowerCase();
17
+ let state = peers.get(key);
18
+ if (!state) {
19
+ state = { lastGreetingAt: 0, recentOutbound: [], inflight: false };
20
+ peers.set(key, state);
21
+ }
22
+ return state;
23
+ }
24
+ function rememberOutbound(peer, text) {
25
+ const state = peerState(peer);
26
+ const now = Date.now();
27
+ state.recentOutbound.push({ text: text.trim().toLowerCase(), at: now });
28
+ state.recentOutbound = state.recentOutbound.filter((item) => now - item.at < ECHO_WINDOW_MS);
29
+ }
30
+ function isEcho(peer, text) {
31
+ const normalized = text.trim().toLowerCase();
32
+ const state = peerState(peer);
33
+ const now = Date.now();
34
+ return state.recentOutbound.some((item) => {
35
+ if (now - item.at >= ECHO_WINDOW_MS)
36
+ return false;
37
+ if (item.text === normalized)
38
+ return true;
39
+ // Peer quoting/acking our last reply ("got it — acknowledged").
40
+ if (normalized.includes(item.text) || item.text.includes(normalized)) {
41
+ return item.text.length >= 4 && normalized.length <= item.text.length + 40;
42
+ }
43
+ return false;
44
+ });
45
+ }
46
+ function isGreeting(text) {
47
+ return /^(hi|hello|hey|yo|sup|picun)[!.?\s]*$/i.test(text.trim());
48
+ }
49
+ /** Short acks / reactions — deliver only, never auto-reply (stops chat spam). */
50
+ function isAckOnly(text) {
51
+ const t = text.trim();
52
+ if (!t)
53
+ return true;
54
+ if (/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s\p{P}]+$/u.test(t)) {
55
+ return true;
56
+ }
57
+ if (/^(got it|ok|okay|k|kk|thanks|thank you|thx|ty|cool|nice|great|ack|acknowledged|roger|copy|noted|np|no problem|sounds good|sg|lgtm|sure|yep|yeah|yes|no|nah|👍|✅)[.!*]*$/i.test(t)) {
58
+ return true;
59
+ }
60
+ // Very short non-questions are almost always reactions.
61
+ if (t.length <= 16 && !/[?]/.test(t) && !/\bwho\b|\bwhat\b|\bhow\b/i.test(t)) {
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ /** Only spend LLM budget on real questions / tasks. */
67
+ function needsReply(text) {
68
+ const t = text.trim();
69
+ if (!t || isGreeting(t) || isAckOnly(t) || /^pong$/i.test(t)) {
70
+ return false;
71
+ }
72
+ if (/[?]/.test(t))
73
+ return true;
74
+ if (/\b(who are you|who're you|what are you|and you are)\b/i.test(t)) {
75
+ return true;
76
+ }
77
+ if (/^(who|what|where|when|why|how|can|could|would|please|tell|ask|explain|list|show|find|read|check|run|write|fix|deploy|summarize)\b/i.test(t)) {
78
+ return true;
79
+ }
80
+ // Longer messages are likely real tasks.
81
+ return t.length >= 48;
82
+ }
83
+ /**
84
+ * Instant replies — no LLM. Never reply in a way that loops with another bridge.
85
+ * ping → pong (pong alone is ignored)
86
+ * greetings → one-shot per peer, non-greeting text
87
+ * echo:… → body
88
+ */
89
+ function tryFastReply(msg, options) {
11
90
  const text = msg.text.trim();
12
- if (/^(ping|pong)$/i.test(text)) {
91
+ if (/^ping$/i.test(text)) {
13
92
  return "pong";
14
93
  }
15
- if (/^(hi|hello|hey|yo|sup|picun)[!.?\s]*$/i.test(text)) {
16
- return text.toLowerCase() === "picun" ? "picun" : "hi";
94
+ // Never auto-reply to "pong" — breaks ping/pong loops.
95
+ if (/^pong$/i.test(text)) {
96
+ return null;
97
+ }
98
+ if (isGreeting(text)) {
99
+ if (options?.allowGreeting === false) {
100
+ return null;
101
+ }
102
+ const state = peerState(msg.from);
103
+ const now = Date.now();
104
+ if (now - state.lastGreetingAt < GREETING_COOLDOWN_MS) {
105
+ return null;
106
+ }
107
+ state.lastGreetingAt = now;
108
+ // Reply must NOT match isGreeting / ping, or peer bridge will loop.
109
+ return `hey ${msg.from} — here. send a question anytime.`;
17
110
  }
18
111
  if (text.toLowerCase().startsWith("echo:")) {
19
112
  const body = text.slice(5).trim();
20
113
  return body || "(empty)";
21
114
  }
115
+ // Identity questions — answer without spawning LLM (reliable + instant).
116
+ if (/\bwho are you\b|\bwho're you\b|\bwhat are you\b|\band you are\??\s*$/i.test(text)) {
117
+ return "I'm cursor — Marshell agent on this machine (codebase workspace).";
118
+ }
22
119
  return null;
23
120
  }
121
+ /** Resolve a CLI binary so Windows .cmd wrappers work under spawn(shell:false). */
122
+ function resolveRuntimeCommand(command) {
123
+ if (process.platform === "win32" && command === "agent") {
124
+ const base = (0, node_path_1.join)(process.env.LOCALAPPDATA ?? "", "cursor-agent");
125
+ const versionsDir = (0, node_path_1.join)(base, "versions");
126
+ if ((0, node_fs_1.existsSync)(versionsDir)) {
127
+ const versions = (0, node_fs_1.readdirSync)(versionsDir)
128
+ .filter((name) => /^\d{4}\./.test(name))
129
+ .sort()
130
+ .reverse();
131
+ for (const version of versions) {
132
+ const nodePath = (0, node_path_1.join)(versionsDir, version, "node.exe");
133
+ const entry = (0, node_path_1.join)(versionsDir, version, "index.js");
134
+ if ((0, node_fs_1.existsSync)(nodePath) && (0, node_fs_1.existsSync)(entry)) {
135
+ return { command: nodePath, argsPrefix: [entry] };
136
+ }
137
+ }
138
+ }
139
+ const agentCmd = (0, node_path_1.join)(base, "agent.cmd");
140
+ if ((0, node_fs_1.existsSync)(agentCmd)) {
141
+ return { command: agentCmd, argsPrefix: [] };
142
+ }
143
+ }
144
+ return { command, argsPrefix: [] };
145
+ }
24
146
  function killProcessTree(pid) {
25
147
  if (!pid)
26
148
  return;
@@ -96,13 +218,19 @@ function spawnCommand(command, args, options) {
96
218
  }
97
219
  async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
98
220
  const prompt = [
99
- `Marshell agent "${msg.from}" sent:`,
100
- msg.text,
221
+ `You are Marshell agent listening on this machine.`,
222
+ `Peer agent "${msg.from}" sent you a message.`,
223
+ "Answer using the local workspace when relevant.",
224
+ "If they ask who you are, say you are the cursor agent on this Marshell subnet.",
225
+ "Reply with ONLY the answer text. No tools, no marshell CLI, no meta commentary.",
101
226
  "",
102
- "Reply with ONLY the answer. No tools, no marshell CLI, no meta commentary.",
227
+ "Message:",
228
+ msg.text,
103
229
  ].join("\n");
104
230
  if (runtime === "cursor") {
105
- const result = await spawnCommand("agent", [
231
+ const resolved = resolveRuntimeCommand("agent");
232
+ const result = await spawnCommand(resolved.command, [
233
+ ...resolved.argsPrefix,
106
234
  "-p",
107
235
  "--trust",
108
236
  "--mode",
@@ -130,6 +258,31 @@ async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
130
258
  }
131
259
  return result.stdout.trim();
132
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
+ }
133
286
  async function runHookReply(hook, msg, timeoutMs) {
134
287
  const payload = JSON.stringify({
135
288
  id: msg.id,
@@ -171,6 +324,12 @@ function emitEvent(json, event) {
171
324
  else if (kind === "reply") {
172
325
  process.stdout.write(`[reply] to=${event.to} id=${event.id}\n`);
173
326
  }
327
+ else if (kind === "skip") {
328
+ process.stdout.write(`[skip] from=${event.from} reason=${event.reason}\n`);
329
+ }
330
+ else if (kind === "notify") {
331
+ process.stdout.write(`[notify] from=${event.from} id=${event.id}${event.pending ? " (tracked)" : ""}\n`);
332
+ }
174
333
  else if (kind === "error") {
175
334
  process.stderr.write(`[bridge] ${event.message}\n`);
176
335
  }
@@ -180,6 +339,7 @@ async function deliverReply(networkUrl, msg, reply, json) {
180
339
  if (sent.kind !== "sent") {
181
340
  throw new Error(sent.message);
182
341
  }
342
+ rememberOutbound(msg.from, reply);
183
343
  emitEvent(json, {
184
344
  type: "reply",
185
345
  to: msg.from,
@@ -189,6 +349,17 @@ async function deliverReply(networkUrl, msg, reply, json) {
189
349
  });
190
350
  }
191
351
  async function processReplyAsync(networkUrl, msg, options) {
352
+ const state = peerState(msg.from);
353
+ if (state.inflight) {
354
+ emitEvent(options.json, {
355
+ type: "skip",
356
+ from: msg.from,
357
+ reason: "peer already has an inflight reply",
358
+ id: msg.id,
359
+ });
360
+ return;
361
+ }
362
+ state.inflight = true;
192
363
  try {
193
364
  let reply = tryFastReply(msg);
194
365
  if (!reply && options.hook) {
@@ -208,42 +379,135 @@ async function processReplyAsync(networkUrl, msg, options) {
208
379
  catch (error) {
209
380
  const message = error instanceof Error ? error.message : String(error);
210
381
  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.`);
382
+ // Never send failure text to peers it spams the subnet and triggers loops.
383
+ }
384
+ finally {
385
+ state.inflight = false;
212
386
  }
213
387
  }
214
388
  async function handleInbound(networkUrl, msg, options) {
389
+ const pending = await (0, pending_1.matchPending)(msg.from);
215
390
  emitEvent(options.json, {
216
391
  type: "message",
217
392
  id: msg.id,
218
393
  from: msg.from,
219
394
  text: msg.text,
220
395
  created_at: msg.created_at,
396
+ pending: pending ?? undefined,
221
397
  });
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.
422
+ await (0, network_1.ackMessages)(networkUrl, [msg.id]);
423
+ // Drop echoes of our own recent outbound (hi↔hi loops).
424
+ if (isEcho(msg.from, msg.text)) {
425
+ emitEvent(options.json, {
426
+ type: "skip",
427
+ from: msg.from,
428
+ reason: "echo of our recent outbound",
429
+ id: msg.id,
430
+ });
431
+ return;
432
+ }
222
433
  const fast = tryFastReply(msg);
223
434
  if (fast) {
224
435
  await deliverReply(networkUrl, msg, fast, options.json);
225
- await (0, network_1.ackMessages)(networkUrl, [msg.id]);
436
+ return;
437
+ }
438
+ // Greetings / acks / reactions: deliver only, no reply spam.
439
+ if (!needsReply(msg.text)) {
440
+ emitEvent(options.json, {
441
+ type: "skip",
442
+ from: msg.from,
443
+ reason: "no-reply (ack/greeting/trivial)",
444
+ id: msg.id,
445
+ });
226
446
  return;
227
447
  }
228
448
  const willReply = Boolean(options.hook) ||
229
449
  (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
450
  if (!willReply) {
233
451
  return;
234
452
  }
235
453
  void processReplyAsync(networkUrl, msg, options);
236
454
  }
455
+ async function drainBacklog(networkUrl, options) {
456
+ const result = await (0, network_1.fetchInbox)(networkUrl, { peek: true });
457
+ if (result.kind !== "ok" || result.messages.length === 0) {
458
+ return;
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
+ }
487
+ await (0, network_1.ackMessages)(networkUrl, result.messages.map((m) => m.id));
488
+ emitEvent(options.json, {
489
+ type: "skip",
490
+ from: "*",
491
+ reason: `drained ${result.messages.length} backlog message(s) on startup`,
492
+ });
493
+ }
237
494
  async function runBridge(options) {
238
495
  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";
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";
246
509
  process.stdout.write(`Bridge listening as '${label}' (${mode}).\n`);
510
+ await drainBacklog(options.networkUrl, options);
247
511
  let stopped = false;
248
512
  const cleanup = () => {
249
513
  if (stopped)
@@ -264,7 +528,13 @@ async function runBridge(options) {
264
528
  await new Promise((resolve) => setTimeout(resolve, 2000));
265
529
  continue;
266
530
  }
267
- for (const msg of result.messages) {
531
+ // Newest first real questions beat greeting backlog.
532
+ const messages = [...result.messages].sort((a, b) => {
533
+ const ta = Date.parse(a.created_at) || 0;
534
+ const tb = Date.parse(b.created_at) || 0;
535
+ return tb - ta;
536
+ });
537
+ for (const msg of messages) {
268
538
  await handleInbound(options.networkUrl, msg, options);
269
539
  }
270
540
  }
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,23 +18,24 @@ 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
- " marshell listen [--json] (alias for bridge run)",
24
+ " marshell history [--with <name>] [--limit <n>] [--json]",
25
+ " marshell listen [--json] [--notify <cmd>] (deliver-only listener)",
26
+ " marshell pending list|clear [--peer <name>] [--json]",
24
27
  " marshell --help",
25
28
  "",
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.",
29
+ "Middleware: send / inbox / history. Agents think; Marshell only delivers.",
30
+ " inbox = unread only (empty after read)",
31
+ " history = last 24h with a peer (use this to recall past chats)",
31
32
  "",
32
33
  "Environment:",
33
34
  " MARSHELL_NETWORK_URL default: https://network.marshell.dev",
34
35
  " MARSHELL_AGENT_NAME default agent name for auth set",
35
36
  " MARSHELL_WORKSPACE workspace for cursor auto-reply",
36
37
  " MARSHELL_HOOK default hook command for bridge",
38
+ " MARSHELL_NOTIFY default notify command for listen (--notify)",
37
39
  " MARSHELL_CONFIG optional path to config file",
38
40
  ].join("\n");
39
41
  process.stdout.write(`${message}\n`);
@@ -73,6 +75,21 @@ function valueForFlag(args, flag) {
73
75
  }
74
76
  return args[index + 1];
75
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
+ }
76
93
  function bridgeOptionsFromArgs(args) {
77
94
  const configPromise = (0, config_1.readConfig)();
78
95
  // sync read not available — caller must await
@@ -83,6 +100,7 @@ function bridgeOptionsFromArgs(args) {
83
100
  process.env.MARSHELL_WORKSPACE ??
84
101
  process.cwd();
85
102
  const hook = valueForFlag(args, "--hook") ?? process.env.MARSHELL_HOOK;
103
+ const notify = valueForFlag(args, "--notify") ?? process.env.MARSHELL_NOTIFY;
86
104
  const runtimeFlag = valueForFlag(args, "--runtime");
87
105
  const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor" || runtimeFlag === "fast"
88
106
  ? runtimeFlag
@@ -99,6 +117,7 @@ function bridgeOptionsFromArgs(args) {
99
117
  runtime,
100
118
  workspace,
101
119
  hook,
120
+ notify,
102
121
  json,
103
122
  replyTimeoutMs,
104
123
  };
@@ -199,18 +218,33 @@ async function cmdSend(args) {
199
218
  const json = hasFlag(args, "--json");
200
219
  const to = valueForFlag(args, "--to");
201
220
  const text = valueForFlag(args, "--text");
221
+ const trackContext = trackContextFromArgs(args, text ?? "");
202
222
  if (!to || !text) {
203
- printError("Usage: marshell send --to <name> --text \"...\" [--json]");
223
+ printError('Usage: marshell send --to <name> --text "..." [--track [context]] [--json]');
204
224
  }
205
225
  const config = await (0, config_1.readConfig)();
206
226
  const networkUrl = (0, config_1.getNetworkUrl)(config);
207
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
+ }
208
236
  if (json) {
209
- printJson(result);
237
+ printJson({
238
+ ...result,
239
+ tracked: Boolean(trackContext && result.kind === "sent"),
240
+ });
210
241
  return;
211
242
  }
212
243
  if (result.kind === "sent") {
213
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
+ }
214
248
  return;
215
249
  }
216
250
  printError(result.message);
@@ -227,8 +261,19 @@ async function cmdAsk(args) {
227
261
  const config = await (0, config_1.readConfig)();
228
262
  const networkUrl = (0, config_1.getNetworkUrl)(config);
229
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
+ }
230
272
  if (json) {
231
- printJson(result);
273
+ printJson({
274
+ ...result,
275
+ tracked: result.kind === "timeout",
276
+ });
232
277
  return;
233
278
  }
234
279
  if (result.kind === "ok") {
@@ -236,10 +281,35 @@ async function cmdAsk(args) {
236
281
  return;
237
282
  }
238
283
  if (result.kind === "timeout") {
239
- 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);
240
286
  }
241
287
  printError(result.message);
242
288
  }
289
+ async function cmdHistory(args) {
290
+ const json = hasFlag(args, "--json");
291
+ const withPeer = valueForFlag(args, "--with");
292
+ const limitRaw = valueForFlag(args, "--limit");
293
+ const limit = limitRaw ? Number(limitRaw) : 50;
294
+ const config = await (0, config_1.readConfig)();
295
+ const networkUrl = (0, config_1.getNetworkUrl)(config);
296
+ const result = await (0, network_1.fetchHistory)(networkUrl, { with: withPeer, limit });
297
+ if (result.kind === "error") {
298
+ printError(result.message);
299
+ }
300
+ if (json) {
301
+ printJson({ agent: result.agent, messages: result.messages });
302
+ return;
303
+ }
304
+ if (result.messages.length === 0) {
305
+ process.stdout.write("No messages in the last 24h.\n");
306
+ return;
307
+ }
308
+ for (const msg of [...result.messages].reverse()) {
309
+ const arrow = msg.direction === "out" ? "→" : "←";
310
+ process.stdout.write(`${msg.created_at} ${arrow} ${msg.peer}: ${msg.text}\n`);
311
+ }
312
+ }
243
313
  async function cmdInbox(args) {
244
314
  const json = hasFlag(args, "--json");
245
315
  const from = valueForFlag(args, "--from");
@@ -295,6 +365,39 @@ async function cmdInbox(args) {
295
365
  process.stdout.write(`[${msg.from}] ${msg.text}\n`);
296
366
  }
297
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
+ }
298
401
  async function cmdListen(args) {
299
402
  const waitRaw = valueForFlag(args, "--wait");
300
403
  const waitSeconds = waitRaw ? Number(waitRaw) : 0;
@@ -362,10 +465,18 @@ async function main() {
362
465
  await cmdInbox(args.slice(1));
363
466
  return;
364
467
  }
468
+ if (args[0] === "history") {
469
+ await cmdHistory(args.slice(1));
470
+ return;
471
+ }
365
472
  if (args[0] === "listen") {
366
473
  await cmdListen(args.slice(1));
367
474
  return;
368
475
  }
476
+ if (args[0] === "pending") {
477
+ await cmdPending(args.slice(1));
478
+ return;
479
+ }
369
480
  printError(`Unknown command '${args[0]}'. Run marshell --help`);
370
481
  }
371
482
  main().catch((error) => {
package/dist/network.js CHANGED
@@ -7,6 +7,7 @@ exports.sendMessage = sendMessage;
7
7
  exports.fetchInbox = fetchInbox;
8
8
  exports.ackMessages = ackMessages;
9
9
  exports.waitForInbox = waitForInbox;
10
+ exports.fetchHistory = fetchHistory;
10
11
  exports.askAgent = askAgent;
11
12
  exports.toWsUrl = toWsUrl;
12
13
  const config_1 = require("./config");
@@ -124,8 +125,11 @@ async function joinAgent(baseUrl, name) {
124
125
  }
125
126
  }
126
127
  async function discoverPeers(baseUrl) {
127
- const headers = await authHeaders("token");
128
+ // Prefer agent_key survives join-token rotation.
129
+ const config = await (0, config_1.readConfig)();
130
+ const kind = config.agentKey ? "agent" : "token";
128
131
  try {
132
+ const headers = await authHeaders(kind);
129
133
  const response = await fetch(withPath(baseUrl, "/v1/peers"), {
130
134
  method: "GET",
131
135
  headers,
@@ -244,6 +248,36 @@ async function waitForInbox(baseUrl, options) {
244
248
  }
245
249
  return { kind: "timeout" };
246
250
  }
251
+ async function fetchHistory(baseUrl, options) {
252
+ try {
253
+ const headers = await authHeaders("agent");
254
+ const params = new URLSearchParams();
255
+ if (options?.with) {
256
+ params.set("with", options.with);
257
+ }
258
+ if (options?.limit && options.limit > 0) {
259
+ params.set("limit", String(options.limit));
260
+ }
261
+ const qs = params.size > 0 ? `?${params.toString()}` : "";
262
+ const response = await fetch(withPath(baseUrl, `/v1/messages/history${qs}`), { method: "GET", headers });
263
+ const data = (await response.json());
264
+ if (!response.ok) {
265
+ return {
266
+ kind: "error",
267
+ status: response.status,
268
+ message: data.error ?? `History failed with HTTP ${response.status}.`,
269
+ };
270
+ }
271
+ return {
272
+ kind: "ok",
273
+ agent: data.agent ?? "",
274
+ messages: data.messages ?? [],
275
+ };
276
+ }
277
+ catch (error) {
278
+ return { kind: "error", message: error.message };
279
+ }
280
+ }
247
281
  async function askAgent(baseUrl, to, text, waitSeconds) {
248
282
  // Drain any stale messages from this peer before asking.
249
283
  const stale = await fetchInbox(baseUrl, { peek: true });
@@ -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.0",
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
+ });