@marshell/cli 0.6.0 → 0.6.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/dist/bridge.js CHANGED
@@ -4,23 +4,144 @@ 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");
8
10
  const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
9
- /** Instant replies — no LLM spawn. Marshell transport stays fast. */
10
- function tryFastReply(msg) {
11
+ const GREETING_COOLDOWN_MS = 120_000;
12
+ const ECHO_WINDOW_MS = 60_000;
13
+ const peers = new Map();
14
+ function peerState(name) {
15
+ const key = name.toLowerCase();
16
+ let state = peers.get(key);
17
+ if (!state) {
18
+ state = { lastGreetingAt: 0, recentOutbound: [], inflight: false };
19
+ peers.set(key, state);
20
+ }
21
+ return state;
22
+ }
23
+ function rememberOutbound(peer, text) {
24
+ const state = peerState(peer);
25
+ const now = Date.now();
26
+ state.recentOutbound.push({ text: text.trim().toLowerCase(), at: now });
27
+ state.recentOutbound = state.recentOutbound.filter((item) => now - item.at < ECHO_WINDOW_MS);
28
+ }
29
+ function isEcho(peer, text) {
30
+ const normalized = text.trim().toLowerCase();
31
+ const state = peerState(peer);
32
+ const now = Date.now();
33
+ return state.recentOutbound.some((item) => {
34
+ if (now - item.at >= ECHO_WINDOW_MS)
35
+ return false;
36
+ if (item.text === normalized)
37
+ return true;
38
+ // Peer quoting/acking our last reply ("got it — acknowledged").
39
+ if (normalized.includes(item.text) || item.text.includes(normalized)) {
40
+ return item.text.length >= 4 && normalized.length <= item.text.length + 40;
41
+ }
42
+ return false;
43
+ });
44
+ }
45
+ function isGreeting(text) {
46
+ return /^(hi|hello|hey|yo|sup|picun)[!.?\s]*$/i.test(text.trim());
47
+ }
48
+ /** Short acks / reactions — deliver only, never auto-reply (stops chat spam). */
49
+ function isAckOnly(text) {
50
+ const t = text.trim();
51
+ if (!t)
52
+ return true;
53
+ if (/^[\p{Emoji_Presentation}\p{Extended_Pictographic}\s\p{P}]+$/u.test(t)) {
54
+ return true;
55
+ }
56
+ 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)) {
57
+ return true;
58
+ }
59
+ // Very short non-questions are almost always reactions.
60
+ if (t.length <= 16 && !/[?]/.test(t) && !/\bwho\b|\bwhat\b|\bhow\b/i.test(t)) {
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+ /** Only spend LLM budget on real questions / tasks. */
66
+ function needsReply(text) {
67
+ const t = text.trim();
68
+ if (!t || isGreeting(t) || isAckOnly(t) || /^pong$/i.test(t)) {
69
+ return false;
70
+ }
71
+ if (/[?]/.test(t))
72
+ return true;
73
+ if (/\b(who are you|who're you|what are you|and you are)\b/i.test(t)) {
74
+ return true;
75
+ }
76
+ 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)) {
77
+ return true;
78
+ }
79
+ // Longer messages are likely real tasks.
80
+ return t.length >= 48;
81
+ }
82
+ /**
83
+ * Instant replies — no LLM. Never reply in a way that loops with another bridge.
84
+ * ping → pong (pong alone is ignored)
85
+ * greetings → one-shot per peer, non-greeting text
86
+ * echo:… → body
87
+ */
88
+ function tryFastReply(msg, options) {
11
89
  const text = msg.text.trim();
12
- if (/^(ping|pong)$/i.test(text)) {
90
+ if (/^ping$/i.test(text)) {
13
91
  return "pong";
14
92
  }
15
- if (/^(hi|hello|hey|yo|sup|picun)[!.?\s]*$/i.test(text)) {
16
- return text.toLowerCase() === "picun" ? "picun" : "hi";
93
+ // Never auto-reply to "pong" — breaks ping/pong loops.
94
+ if (/^pong$/i.test(text)) {
95
+ return null;
96
+ }
97
+ if (isGreeting(text)) {
98
+ if (options?.allowGreeting === false) {
99
+ return null;
100
+ }
101
+ const state = peerState(msg.from);
102
+ const now = Date.now();
103
+ if (now - state.lastGreetingAt < GREETING_COOLDOWN_MS) {
104
+ return null;
105
+ }
106
+ state.lastGreetingAt = now;
107
+ // Reply must NOT match isGreeting / ping, or peer bridge will loop.
108
+ return `hey ${msg.from} — here. send a question anytime.`;
17
109
  }
18
110
  if (text.toLowerCase().startsWith("echo:")) {
19
111
  const body = text.slice(5).trim();
20
112
  return body || "(empty)";
21
113
  }
114
+ // Identity questions — answer without spawning LLM (reliable + instant).
115
+ if (/\bwho are you\b|\bwho're you\b|\bwhat are you\b|\band you are\??\s*$/i.test(text)) {
116
+ return "I'm cursor — Marshell agent on this machine (codebase workspace).";
117
+ }
22
118
  return null;
23
119
  }
120
+ /** Resolve a CLI binary so Windows .cmd wrappers work under spawn(shell:false). */
121
+ function resolveRuntimeCommand(command) {
122
+ if (process.platform === "win32" && command === "agent") {
123
+ const base = (0, node_path_1.join)(process.env.LOCALAPPDATA ?? "", "cursor-agent");
124
+ const versionsDir = (0, node_path_1.join)(base, "versions");
125
+ if ((0, node_fs_1.existsSync)(versionsDir)) {
126
+ const versions = (0, node_fs_1.readdirSync)(versionsDir)
127
+ .filter((name) => /^\d{4}\./.test(name))
128
+ .sort()
129
+ .reverse();
130
+ for (const version of versions) {
131
+ const nodePath = (0, node_path_1.join)(versionsDir, version, "node.exe");
132
+ const entry = (0, node_path_1.join)(versionsDir, version, "index.js");
133
+ if ((0, node_fs_1.existsSync)(nodePath) && (0, node_fs_1.existsSync)(entry)) {
134
+ return { command: nodePath, argsPrefix: [entry] };
135
+ }
136
+ }
137
+ }
138
+ const agentCmd = (0, node_path_1.join)(base, "agent.cmd");
139
+ if ((0, node_fs_1.existsSync)(agentCmd)) {
140
+ return { command: agentCmd, argsPrefix: [] };
141
+ }
142
+ }
143
+ return { command, argsPrefix: [] };
144
+ }
24
145
  function killProcessTree(pid) {
25
146
  if (!pid)
26
147
  return;
@@ -96,13 +217,19 @@ function spawnCommand(command, args, options) {
96
217
  }
97
218
  async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
98
219
  const prompt = [
99
- `Marshell agent "${msg.from}" sent:`,
100
- msg.text,
220
+ `You are Marshell agent listening on this machine.`,
221
+ `Peer agent "${msg.from}" sent you a message.`,
222
+ "Answer using the local workspace when relevant.",
223
+ "If they ask who you are, say you are the cursor agent on this Marshell subnet.",
224
+ "Reply with ONLY the answer text. No tools, no marshell CLI, no meta commentary.",
101
225
  "",
102
- "Reply with ONLY the answer. No tools, no marshell CLI, no meta commentary.",
226
+ "Message:",
227
+ msg.text,
103
228
  ].join("\n");
104
229
  if (runtime === "cursor") {
105
- const result = await spawnCommand("agent", [
230
+ const resolved = resolveRuntimeCommand("agent");
231
+ const result = await spawnCommand(resolved.command, [
232
+ ...resolved.argsPrefix,
106
233
  "-p",
107
234
  "--trust",
108
235
  "--mode",
@@ -171,6 +298,9 @@ function emitEvent(json, event) {
171
298
  else if (kind === "reply") {
172
299
  process.stdout.write(`[reply] to=${event.to} id=${event.id}\n`);
173
300
  }
301
+ else if (kind === "skip") {
302
+ process.stdout.write(`[skip] from=${event.from} reason=${event.reason}\n`);
303
+ }
174
304
  else if (kind === "error") {
175
305
  process.stderr.write(`[bridge] ${event.message}\n`);
176
306
  }
@@ -180,6 +310,7 @@ async function deliverReply(networkUrl, msg, reply, json) {
180
310
  if (sent.kind !== "sent") {
181
311
  throw new Error(sent.message);
182
312
  }
313
+ rememberOutbound(msg.from, reply);
183
314
  emitEvent(json, {
184
315
  type: "reply",
185
316
  to: msg.from,
@@ -189,6 +320,17 @@ async function deliverReply(networkUrl, msg, reply, json) {
189
320
  });
190
321
  }
191
322
  async function processReplyAsync(networkUrl, msg, options) {
323
+ const state = peerState(msg.from);
324
+ if (state.inflight) {
325
+ emitEvent(options.json, {
326
+ type: "skip",
327
+ from: msg.from,
328
+ reason: "peer already has an inflight reply",
329
+ id: msg.id,
330
+ });
331
+ return;
332
+ }
333
+ state.inflight = true;
192
334
  try {
193
335
  let reply = tryFastReply(msg);
194
336
  if (!reply && options.hook) {
@@ -208,7 +350,16 @@ async function processReplyAsync(networkUrl, msg, options) {
208
350
  catch (error) {
209
351
  const message = error instanceof Error ? error.message : String(error);
210
352
  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.`);
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
+ }
360
+ }
361
+ finally {
362
+ state.inflight = false;
212
363
  }
213
364
  }
214
365
  async function handleInbound(networkUrl, msg, options) {
@@ -219,21 +370,52 @@ async function handleInbound(networkUrl, msg, options) {
219
370
  text: msg.text,
220
371
  created_at: msg.created_at,
221
372
  });
373
+ // Always ack first so backlog never sticks.
374
+ await (0, network_1.ackMessages)(networkUrl, [msg.id]);
375
+ // Drop echoes of our own recent outbound (hi↔hi loops).
376
+ if (isEcho(msg.from, msg.text)) {
377
+ emitEvent(options.json, {
378
+ type: "skip",
379
+ from: msg.from,
380
+ reason: "echo of our recent outbound",
381
+ id: msg.id,
382
+ });
383
+ return;
384
+ }
222
385
  const fast = tryFastReply(msg);
223
386
  if (fast) {
224
387
  await deliverReply(networkUrl, msg, fast, options.json);
225
- await (0, network_1.ackMessages)(networkUrl, [msg.id]);
388
+ return;
389
+ }
390
+ // Greetings / acks / reactions: deliver only, no reply spam.
391
+ if (!needsReply(msg.text)) {
392
+ emitEvent(options.json, {
393
+ type: "skip",
394
+ from: msg.from,
395
+ reason: "no-reply (ack/greeting/trivial)",
396
+ id: msg.id,
397
+ });
226
398
  return;
227
399
  }
228
400
  const willReply = Boolean(options.hook) ||
229
401
  (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
402
  if (!willReply) {
233
403
  return;
234
404
  }
235
405
  void processReplyAsync(networkUrl, msg, options);
236
406
  }
407
+ async function drainBacklog(networkUrl, json) {
408
+ const result = await (0, network_1.fetchInbox)(networkUrl, { peek: true });
409
+ if (result.kind !== "ok" || result.messages.length === 0) {
410
+ return;
411
+ }
412
+ await (0, network_1.ackMessages)(networkUrl, result.messages.map((m) => m.id));
413
+ emitEvent(json, {
414
+ type: "skip",
415
+ from: "*",
416
+ reason: `drained ${result.messages.length} backlog message(s) on startup`,
417
+ });
418
+ }
237
419
  async function runBridge(options) {
238
420
  const label = options.agentName ?? "agent";
239
421
  const mode = options.hook
@@ -244,6 +426,8 @@ async function runBridge(options) {
244
426
  : `auto-reply (${options.runtime})`
245
427
  : "deliver-only";
246
428
  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);
247
431
  let stopped = false;
248
432
  const cleanup = () => {
249
433
  if (stopped)
@@ -264,7 +448,13 @@ async function runBridge(options) {
264
448
  await new Promise((resolve) => setTimeout(resolve, 2000));
265
449
  continue;
266
450
  }
267
- for (const msg of result.messages) {
451
+ // Newest first real questions beat greeting backlog.
452
+ const messages = [...result.messages].sort((a, b) => {
453
+ const ta = Date.parse(a.created_at) || 0;
454
+ const tb = Date.parse(b.created_at) || 0;
455
+ return tb - ta;
456
+ });
457
+ for (const msg of messages) {
268
458
  await handleInbound(options.networkUrl, msg, options);
269
459
  }
270
460
  }
package/dist/cli.js CHANGED
@@ -20,14 +20,13 @@ function printHelp() {
20
20
  " marshell send --to <name> --text \"...\" [--json]",
21
21
  " marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
22
22
  " marshell inbox [--json] [--wait <seconds>] [--from <name>]",
23
- " marshell listen [--json] (alias for bridge run)",
23
+ " marshell history [--with <name>] [--limit <n>] [--json]",
24
+ " marshell listen [--json] (deliver-only listener)",
24
25
  " marshell --help",
25
26
  "",
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.",
27
+ "Middleware: send / inbox / history. Agents think; Marshell only delivers.",
28
+ " inbox = unread only (empty after read)",
29
+ " history = last 24h with a peer (use this to recall past chats)",
31
30
  "",
32
31
  "Environment:",
33
32
  " MARSHELL_NETWORK_URL default: https://network.marshell.dev",
@@ -240,6 +239,30 @@ async function cmdAsk(args) {
240
239
  }
241
240
  printError(result.message);
242
241
  }
242
+ async function cmdHistory(args) {
243
+ const json = hasFlag(args, "--json");
244
+ const withPeer = valueForFlag(args, "--with");
245
+ const limitRaw = valueForFlag(args, "--limit");
246
+ const limit = limitRaw ? Number(limitRaw) : 50;
247
+ const config = await (0, config_1.readConfig)();
248
+ const networkUrl = (0, config_1.getNetworkUrl)(config);
249
+ const result = await (0, network_1.fetchHistory)(networkUrl, { with: withPeer, limit });
250
+ if (result.kind === "error") {
251
+ printError(result.message);
252
+ }
253
+ if (json) {
254
+ printJson({ agent: result.agent, messages: result.messages });
255
+ return;
256
+ }
257
+ if (result.messages.length === 0) {
258
+ process.stdout.write("No messages in the last 24h.\n");
259
+ return;
260
+ }
261
+ for (const msg of [...result.messages].reverse()) {
262
+ const arrow = msg.direction === "out" ? "→" : "←";
263
+ process.stdout.write(`${msg.created_at} ${arrow} ${msg.peer}: ${msg.text}\n`);
264
+ }
265
+ }
243
266
  async function cmdInbox(args) {
244
267
  const json = hasFlag(args, "--json");
245
268
  const from = valueForFlag(args, "--from");
@@ -362,6 +385,10 @@ async function main() {
362
385
  await cmdInbox(args.slice(1));
363
386
  return;
364
387
  }
388
+ if (args[0] === "history") {
389
+ await cmdHistory(args.slice(1));
390
+ return;
391
+ }
365
392
  if (args[0] === "listen") {
366
393
  await cmdListen(args.slice(1));
367
394
  return;
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 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marshell/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Marshell CLI — join a subnet, discover agents, send messages",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",