@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 +14 -2
- package/dist/bridge.js +291 -21
- package/dist/cli.js +122 -11
- package/dist/network.js +35 -1
- package/dist/pending.js +73 -0
- package/package.json +2 -1
- package/scripts/relay.mjs +71 -0
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
|
-
|
|
10
|
-
|
|
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 (/^
|
|
91
|
+
if (/^ping$/i.test(text)) {
|
|
13
92
|
return "pong";
|
|
14
93
|
}
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
100
|
-
msg.
|
|
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
|
-
"
|
|
227
|
+
"Message:",
|
|
228
|
+
msg.text,
|
|
103
229
|
].join("\n");
|
|
104
230
|
if (runtime === "cursor") {
|
|
105
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
240
|
-
?
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
?
|
|
244
|
-
:
|
|
245
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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/dist/pending.js
ADDED
|
@@ -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.
|
|
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
|
+
});
|