@marshell/cli 0.5.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 +461 -0
- package/dist/cli.js +130 -131
- package/dist/network.js +91 -4
- package/package.json +1 -2
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tryFastReply = tryFastReply;
|
|
4
|
+
exports.handleInbound = handleInbound;
|
|
5
|
+
exports.runBridge = runBridge;
|
|
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");
|
|
9
|
+
const network_1 = require("./network");
|
|
10
|
+
const DEFAULT_REPLY_TIMEOUT_MS = 120_000;
|
|
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) {
|
|
89
|
+
const text = msg.text.trim();
|
|
90
|
+
if (/^ping$/i.test(text)) {
|
|
91
|
+
return "pong";
|
|
92
|
+
}
|
|
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.`;
|
|
109
|
+
}
|
|
110
|
+
if (text.toLowerCase().startsWith("echo:")) {
|
|
111
|
+
const body = text.slice(5).trim();
|
|
112
|
+
return body || "(empty)";
|
|
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
|
+
}
|
|
118
|
+
return null;
|
|
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
|
+
}
|
|
145
|
+
function killProcessTree(pid) {
|
|
146
|
+
if (!pid)
|
|
147
|
+
return;
|
|
148
|
+
if (process.platform === "win32") {
|
|
149
|
+
(0, node_child_process_1.spawn)("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
|
150
|
+
stdio: "ignore",
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
process.kill(-pid, "SIGKILL");
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
try {
|
|
160
|
+
process.kill(pid, "SIGKILL");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// ignore
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function spawnCommand(command, args, options) {
|
|
168
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_REPLY_TIMEOUT_MS;
|
|
169
|
+
return new Promise((resolve) => {
|
|
170
|
+
const child = process.platform === "win32"
|
|
171
|
+
? (0, node_child_process_1.spawn)(command, args, {
|
|
172
|
+
cwd: options?.cwd,
|
|
173
|
+
env: process.env,
|
|
174
|
+
windowsHide: true,
|
|
175
|
+
shell: false,
|
|
176
|
+
})
|
|
177
|
+
: (0, node_child_process_1.spawn)(command, args, {
|
|
178
|
+
cwd: options?.cwd,
|
|
179
|
+
env: process.env,
|
|
180
|
+
detached: true,
|
|
181
|
+
});
|
|
182
|
+
let stdout = "";
|
|
183
|
+
let stderr = "";
|
|
184
|
+
let settled = false;
|
|
185
|
+
const finish = (code, errText) => {
|
|
186
|
+
if (settled)
|
|
187
|
+
return;
|
|
188
|
+
settled = true;
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
resolve({
|
|
191
|
+
code,
|
|
192
|
+
stdout,
|
|
193
|
+
stderr: errText ? `${stderr}\n${errText}`.trim() : stderr,
|
|
194
|
+
});
|
|
195
|
+
};
|
|
196
|
+
const timer = setTimeout(() => {
|
|
197
|
+
killProcessTree(child.pid);
|
|
198
|
+
finish(124, `timeout after ${Math.round(timeoutMs / 1000)}s`);
|
|
199
|
+
}, timeoutMs);
|
|
200
|
+
if (options?.input) {
|
|
201
|
+
child.stdin.write(options.input);
|
|
202
|
+
child.stdin.end();
|
|
203
|
+
}
|
|
204
|
+
child.stdout.on("data", (chunk) => {
|
|
205
|
+
stdout += chunk.toString();
|
|
206
|
+
});
|
|
207
|
+
child.stderr.on("data", (chunk) => {
|
|
208
|
+
stderr += chunk.toString();
|
|
209
|
+
});
|
|
210
|
+
child.on("close", (code) => {
|
|
211
|
+
finish(code ?? 1);
|
|
212
|
+
});
|
|
213
|
+
child.on("error", (error) => {
|
|
214
|
+
finish(1, error.message);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
async function generateRuntimeReply(msg, runtime, workspace, timeoutMs) {
|
|
219
|
+
const prompt = [
|
|
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.",
|
|
225
|
+
"",
|
|
226
|
+
"Message:",
|
|
227
|
+
msg.text,
|
|
228
|
+
].join("\n");
|
|
229
|
+
if (runtime === "cursor") {
|
|
230
|
+
const resolved = resolveRuntimeCommand("agent");
|
|
231
|
+
const result = await spawnCommand(resolved.command, [
|
|
232
|
+
...resolved.argsPrefix,
|
|
233
|
+
"-p",
|
|
234
|
+
"--trust",
|
|
235
|
+
"--mode",
|
|
236
|
+
"ask",
|
|
237
|
+
"--workspace",
|
|
238
|
+
workspace,
|
|
239
|
+
"--output-format",
|
|
240
|
+
"text",
|
|
241
|
+
prompt,
|
|
242
|
+
], { timeoutMs });
|
|
243
|
+
if (result.code === 124) {
|
|
244
|
+
throw new Error(`auto-reply timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
245
|
+
}
|
|
246
|
+
if (result.code !== 0) {
|
|
247
|
+
throw new Error(result.stderr || result.stdout || "cursor agent failed");
|
|
248
|
+
}
|
|
249
|
+
return result.stdout.trim();
|
|
250
|
+
}
|
|
251
|
+
const result = await spawnCommand("hermes", ["-z", prompt], { timeoutMs });
|
|
252
|
+
if (result.code === 124) {
|
|
253
|
+
throw new Error(`auto-reply timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
254
|
+
}
|
|
255
|
+
if (result.code !== 0) {
|
|
256
|
+
throw new Error(result.stderr || result.stdout || "hermes failed");
|
|
257
|
+
}
|
|
258
|
+
return result.stdout.trim();
|
|
259
|
+
}
|
|
260
|
+
async function runHookReply(hook, msg, timeoutMs) {
|
|
261
|
+
const payload = JSON.stringify({
|
|
262
|
+
id: msg.id,
|
|
263
|
+
from: msg.from,
|
|
264
|
+
text: msg.text,
|
|
265
|
+
created_at: msg.created_at,
|
|
266
|
+
});
|
|
267
|
+
if (process.platform === "win32") {
|
|
268
|
+
const result = await spawnCommand("cmd.exe", ["/d", "/s", "/c", hook], { input: payload, timeoutMs });
|
|
269
|
+
if (result.code === 124) {
|
|
270
|
+
throw new Error(`hook timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
271
|
+
}
|
|
272
|
+
if (result.code !== 0) {
|
|
273
|
+
throw new Error(result.stderr || result.stdout || "hook failed");
|
|
274
|
+
}
|
|
275
|
+
return result.stdout.trim();
|
|
276
|
+
}
|
|
277
|
+
const result = await spawnCommand("sh", ["-c", hook], {
|
|
278
|
+
input: payload,
|
|
279
|
+
timeoutMs,
|
|
280
|
+
});
|
|
281
|
+
if (result.code === 124) {
|
|
282
|
+
throw new Error(`hook timed out (${Math.round(timeoutMs / 1000)}s)`);
|
|
283
|
+
}
|
|
284
|
+
if (result.code !== 0) {
|
|
285
|
+
throw new Error(result.stderr || result.stdout || "hook failed");
|
|
286
|
+
}
|
|
287
|
+
return result.stdout.trim();
|
|
288
|
+
}
|
|
289
|
+
function emitEvent(json, event) {
|
|
290
|
+
if (json) {
|
|
291
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const kind = event.type;
|
|
295
|
+
if (kind === "message") {
|
|
296
|
+
process.stdout.write(`[message] from=${event.from} id=${event.id}\n${event.text}\n`);
|
|
297
|
+
}
|
|
298
|
+
else if (kind === "reply") {
|
|
299
|
+
process.stdout.write(`[reply] to=${event.to} id=${event.id}\n`);
|
|
300
|
+
}
|
|
301
|
+
else if (kind === "skip") {
|
|
302
|
+
process.stdout.write(`[skip] from=${event.from} reason=${event.reason}\n`);
|
|
303
|
+
}
|
|
304
|
+
else if (kind === "error") {
|
|
305
|
+
process.stderr.write(`[bridge] ${event.message}\n`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function deliverReply(networkUrl, msg, reply, json) {
|
|
309
|
+
const sent = await (0, network_1.sendMessage)(networkUrl, msg.from, reply);
|
|
310
|
+
if (sent.kind !== "sent") {
|
|
311
|
+
throw new Error(sent.message);
|
|
312
|
+
}
|
|
313
|
+
rememberOutbound(msg.from, reply);
|
|
314
|
+
emitEvent(json, {
|
|
315
|
+
type: "reply",
|
|
316
|
+
to: msg.from,
|
|
317
|
+
id: sent.id,
|
|
318
|
+
in_reply_to: msg.id,
|
|
319
|
+
text: reply,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
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;
|
|
334
|
+
try {
|
|
335
|
+
let reply = tryFastReply(msg);
|
|
336
|
+
if (!reply && options.hook) {
|
|
337
|
+
reply = await runHookReply(options.hook, msg, options.replyTimeoutMs);
|
|
338
|
+
}
|
|
339
|
+
else if (!reply && options.autoReply && options.runtime !== "fast") {
|
|
340
|
+
reply = await generateRuntimeReply(msg, options.runtime, options.workspace, options.replyTimeoutMs);
|
|
341
|
+
}
|
|
342
|
+
if (!reply) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (!reply.trim()) {
|
|
346
|
+
throw new Error("empty reply");
|
|
347
|
+
}
|
|
348
|
+
await deliverReply(networkUrl, msg, reply.trim(), options.json);
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
352
|
+
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
|
+
}
|
|
360
|
+
}
|
|
361
|
+
finally {
|
|
362
|
+
state.inflight = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
async function handleInbound(networkUrl, msg, options) {
|
|
366
|
+
emitEvent(options.json, {
|
|
367
|
+
type: "message",
|
|
368
|
+
id: msg.id,
|
|
369
|
+
from: msg.from,
|
|
370
|
+
text: msg.text,
|
|
371
|
+
created_at: msg.created_at,
|
|
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
|
+
}
|
|
385
|
+
const fast = tryFastReply(msg);
|
|
386
|
+
if (fast) {
|
|
387
|
+
await deliverReply(networkUrl, msg, fast, options.json);
|
|
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
|
+
});
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const willReply = Boolean(options.hook) ||
|
|
401
|
+
(options.autoReply && options.runtime !== "fast");
|
|
402
|
+
if (!willReply) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
void processReplyAsync(networkUrl, msg, options);
|
|
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
|
+
}
|
|
419
|
+
async function runBridge(options) {
|
|
420
|
+
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";
|
|
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);
|
|
431
|
+
let stopped = false;
|
|
432
|
+
const cleanup = () => {
|
|
433
|
+
if (stopped)
|
|
434
|
+
return;
|
|
435
|
+
stopped = true;
|
|
436
|
+
process.stdout.write("Bridge stopped.\n");
|
|
437
|
+
process.exit(0);
|
|
438
|
+
};
|
|
439
|
+
process.on("SIGINT", cleanup);
|
|
440
|
+
process.on("SIGTERM", cleanup);
|
|
441
|
+
while (!stopped) {
|
|
442
|
+
const result = await (0, network_1.fetchInbox)(options.networkUrl, {
|
|
443
|
+
peek: true,
|
|
444
|
+
waitSeconds: 30,
|
|
445
|
+
});
|
|
446
|
+
if (result.kind === "error") {
|
|
447
|
+
process.stderr.write(`inbox error: ${result.message}\n`);
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
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) {
|
|
458
|
+
await handleInbound(options.networkUrl, msg, options);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
const node_child_process_1 = require("node:child_process");
|
|
5
4
|
const node_os_1 = require("node:os");
|
|
5
|
+
const bridge_1 = require("./bridge");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
7
|
const network_1 = require("./network");
|
|
8
8
|
function printHelp() {
|
|
9
9
|
const message = [
|
|
10
|
-
"marshell -
|
|
10
|
+
"marshell - agent messaging bridge",
|
|
11
11
|
"",
|
|
12
12
|
"Usage:",
|
|
13
13
|
" marshell auth set <token> [--name <name>]",
|
|
14
14
|
" marshell auth status [--json]",
|
|
15
15
|
" marshell agent join --name <name>",
|
|
16
|
-
" marshell
|
|
16
|
+
" marshell bridge run [--json] [--hook <cmd>]",
|
|
17
|
+
" marshell bridge run --auto-reply [--runtime cursor|hermes|fast] [--workspace <path>]",
|
|
18
|
+
" marshell agent run (alias for bridge run)",
|
|
17
19
|
" marshell discover [--json]",
|
|
18
20
|
" marshell send --to <name> --text \"...\" [--json]",
|
|
21
|
+
" marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]",
|
|
19
22
|
" marshell inbox [--json] [--wait <seconds>] [--from <name>]",
|
|
20
|
-
" marshell
|
|
23
|
+
" marshell history [--with <name>] [--limit <n>] [--json]",
|
|
24
|
+
" marshell listen [--json] (deliver-only listener)",
|
|
21
25
|
" marshell --help",
|
|
22
26
|
"",
|
|
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)",
|
|
30
|
+
"",
|
|
23
31
|
"Environment:",
|
|
24
32
|
" MARSHELL_NETWORK_URL default: https://network.marshell.dev",
|
|
25
33
|
" MARSHELL_AGENT_NAME default agent name for auth set",
|
|
26
|
-
" MARSHELL_WORKSPACE workspace for
|
|
34
|
+
" MARSHELL_WORKSPACE workspace for cursor auto-reply",
|
|
35
|
+
" MARSHELL_HOOK default hook command for bridge",
|
|
27
36
|
" MARSHELL_CONFIG optional path to config file",
|
|
28
37
|
].join("\n");
|
|
29
38
|
process.stdout.write(`${message}\n`);
|
|
@@ -63,6 +72,36 @@ function valueForFlag(args, flag) {
|
|
|
63
72
|
}
|
|
64
73
|
return args[index + 1];
|
|
65
74
|
}
|
|
75
|
+
function bridgeOptionsFromArgs(args) {
|
|
76
|
+
const configPromise = (0, config_1.readConfig)();
|
|
77
|
+
// sync read not available — caller must await
|
|
78
|
+
void configPromise;
|
|
79
|
+
const autoReply = hasFlag(args, "--auto-reply");
|
|
80
|
+
const json = hasFlag(args, "--json");
|
|
81
|
+
const workspace = valueForFlag(args, "--workspace") ??
|
|
82
|
+
process.env.MARSHELL_WORKSPACE ??
|
|
83
|
+
process.cwd();
|
|
84
|
+
const hook = valueForFlag(args, "--hook") ?? process.env.MARSHELL_HOOK;
|
|
85
|
+
const runtimeFlag = valueForFlag(args, "--runtime");
|
|
86
|
+
const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor" || runtimeFlag === "fast"
|
|
87
|
+
? runtimeFlag
|
|
88
|
+
: process.platform === "win32"
|
|
89
|
+
? "cursor"
|
|
90
|
+
: "hermes";
|
|
91
|
+
const timeoutRaw = valueForFlag(args, "--reply-timeout");
|
|
92
|
+
const replyTimeoutMs = timeoutRaw
|
|
93
|
+
? Math.max(5, Number(timeoutRaw)) * 1000
|
|
94
|
+
: 120_000;
|
|
95
|
+
return {
|
|
96
|
+
networkUrl: "",
|
|
97
|
+
autoReply,
|
|
98
|
+
runtime,
|
|
99
|
+
workspace,
|
|
100
|
+
hook,
|
|
101
|
+
json,
|
|
102
|
+
replyTimeoutMs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
66
105
|
async function cmdAuthSet(args) {
|
|
67
106
|
const named = valueForFlag(args, "--name");
|
|
68
107
|
const tokenArg = args.filter((a, i) => {
|
|
@@ -99,6 +138,7 @@ async function cmdAuthStatus(args) {
|
|
|
99
138
|
const payload = {
|
|
100
139
|
hasToken: Boolean(config.token),
|
|
101
140
|
hasAgentKey: Boolean(config.agentKey),
|
|
141
|
+
agentName: config.agentName ?? null,
|
|
102
142
|
networkUrl,
|
|
103
143
|
health,
|
|
104
144
|
};
|
|
@@ -128,140 +168,30 @@ async function cmdAgentJoin(args) {
|
|
|
128
168
|
}
|
|
129
169
|
printError(result.message);
|
|
130
170
|
}
|
|
131
|
-
function
|
|
132
|
-
return new Promise((resolve) => {
|
|
133
|
-
const child = (0, node_child_process_1.spawn)(command, args, {
|
|
134
|
-
cwd: options?.cwd,
|
|
135
|
-
env: process.env,
|
|
136
|
-
shell: process.platform === "win32",
|
|
137
|
-
});
|
|
138
|
-
let stdout = "";
|
|
139
|
-
let stderr = "";
|
|
140
|
-
child.stdout.on("data", (chunk) => {
|
|
141
|
-
stdout += chunk.toString();
|
|
142
|
-
});
|
|
143
|
-
child.stderr.on("data", (chunk) => {
|
|
144
|
-
stderr += chunk.toString();
|
|
145
|
-
});
|
|
146
|
-
child.on("close", (code) => {
|
|
147
|
-
resolve({ code: code ?? 1, stdout, stderr });
|
|
148
|
-
});
|
|
149
|
-
child.on("error", (error) => {
|
|
150
|
-
resolve({ code: 1, stdout, stderr: error.message });
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
async function generateAutoReply(msg, runtime, workspace) {
|
|
155
|
-
const prompt = [
|
|
156
|
-
`Another Marshell agent named "${msg.from}" sent you this request.`,
|
|
157
|
-
"Answer it using the local workspace/codebase.",
|
|
158
|
-
"Do NOT run marshell CLI commands — your stdout is delivered automatically as the reply.",
|
|
159
|
-
"Do NOT mention Ask mode, Agent mode, tools, or that you are an AI.",
|
|
160
|
-
"Reply with ONLY the answer text. No preamble.",
|
|
161
|
-
"",
|
|
162
|
-
"Request:",
|
|
163
|
-
msg.text,
|
|
164
|
-
].join("\n");
|
|
165
|
-
if (runtime === "cursor") {
|
|
166
|
-
const result = await runCommandCapture("agent", [
|
|
167
|
-
"-p",
|
|
168
|
-
"--trust",
|
|
169
|
-
"--force",
|
|
170
|
-
"--workspace",
|
|
171
|
-
workspace,
|
|
172
|
-
"--output-format",
|
|
173
|
-
"text",
|
|
174
|
-
prompt,
|
|
175
|
-
]);
|
|
176
|
-
if (result.code !== 0) {
|
|
177
|
-
throw new Error(result.stderr || result.stdout || "cursor agent failed");
|
|
178
|
-
}
|
|
179
|
-
return result.stdout.trim();
|
|
180
|
-
}
|
|
181
|
-
const result = await runCommandCapture("hermes", ["-z", prompt]);
|
|
182
|
-
if (result.code !== 0) {
|
|
183
|
-
throw new Error(result.stderr || result.stdout || "hermes failed");
|
|
184
|
-
}
|
|
185
|
-
return result.stdout.trim();
|
|
186
|
-
}
|
|
187
|
-
async function handleInboundMessage(networkUrl, msg, autoReply, runtime, workspace) {
|
|
188
|
-
process.stdout.write(`[message] from=${msg.from} id=${msg.id}\n${msg.text}\n`);
|
|
189
|
-
if (!autoReply) {
|
|
190
|
-
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
try {
|
|
194
|
-
process.stdout.write(`[auto-reply] generating response via ${runtime}…\n`);
|
|
195
|
-
const reply = await generateAutoReply(msg, runtime, workspace);
|
|
196
|
-
if (!reply) {
|
|
197
|
-
throw new Error("empty reply from runtime");
|
|
198
|
-
}
|
|
199
|
-
const sent = await (0, network_1.sendMessage)(networkUrl, msg.from, reply);
|
|
200
|
-
if (sent.kind !== "sent") {
|
|
201
|
-
throw new Error(sent.message);
|
|
202
|
-
}
|
|
203
|
-
process.stdout.write(`[auto-reply] sent to ${msg.from} (id: ${sent.id})\n`);
|
|
204
|
-
await (0, network_1.ackMessages)(networkUrl, [msg.id]);
|
|
205
|
-
}
|
|
206
|
-
catch (error) {
|
|
207
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
208
|
-
process.stderr.write(`[auto-reply] failed: ${message}\n`);
|
|
209
|
-
// leave message in inbox for retry
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
async function cmdAgentRun(args = []) {
|
|
213
|
-
const autoReply = hasFlag(args, "--auto-reply");
|
|
214
|
-
const workspace = valueForFlag(args, "--workspace") ??
|
|
215
|
-
process.env.MARSHELL_WORKSPACE ??
|
|
216
|
-
process.cwd();
|
|
217
|
-
const runtimeFlag = valueForFlag(args, "--runtime");
|
|
218
|
-
const runtime = runtimeFlag === "hermes" || runtimeFlag === "cursor"
|
|
219
|
-
? runtimeFlag
|
|
220
|
-
: process.platform === "win32"
|
|
221
|
-
? "cursor"
|
|
222
|
-
: "hermes";
|
|
171
|
+
async function cmdBridgeRun(args = []) {
|
|
223
172
|
const config = await (0, config_1.readConfig)();
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (stopped)
|
|
229
|
-
return;
|
|
230
|
-
stopped = true;
|
|
231
|
-
process.stdout.write("Exiting agent loop.\n");
|
|
232
|
-
process.exit(0);
|
|
233
|
-
};
|
|
234
|
-
process.on("SIGINT", cleanup);
|
|
235
|
-
process.on("SIGTERM", cleanup);
|
|
236
|
-
while (!stopped) {
|
|
237
|
-
const result = await (0, network_1.fetchInbox)(networkUrl, { peek: true });
|
|
238
|
-
if (result.kind === "error") {
|
|
239
|
-
process.stderr.write(`inbox error: ${result.message}\n`);
|
|
240
|
-
}
|
|
241
|
-
else {
|
|
242
|
-
for (const msg of result.messages) {
|
|
243
|
-
await handleInboundMessage(networkUrl, msg, autoReply, runtime, workspace);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
247
|
-
}
|
|
173
|
+
const opts = bridgeOptionsFromArgs(args);
|
|
174
|
+
opts.networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
175
|
+
opts.agentName = config.agentName;
|
|
176
|
+
await (0, bridge_1.runBridge)(opts);
|
|
248
177
|
}
|
|
249
178
|
async function cmdDiscover(args) {
|
|
250
179
|
const json = hasFlag(args, "--json");
|
|
251
180
|
const config = await (0, config_1.readConfig)();
|
|
252
181
|
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
253
|
-
const
|
|
182
|
+
const { peers } = await (0, network_1.discoverPeers)(networkUrl);
|
|
254
183
|
if (json) {
|
|
255
|
-
printJson({ peers
|
|
184
|
+
printJson({ peers });
|
|
256
185
|
return;
|
|
257
186
|
}
|
|
258
|
-
if (
|
|
259
|
-
process.stdout.write("No peers
|
|
187
|
+
if (peers.length === 0) {
|
|
188
|
+
process.stdout.write("No peers found.\n");
|
|
260
189
|
return;
|
|
261
190
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
191
|
+
for (const peer of peers) {
|
|
192
|
+
const name = typeof peer.name === "string" ? peer.name : "?";
|
|
193
|
+
const status = typeof peer.status === "string" ? peer.status : "unknown";
|
|
194
|
+
process.stdout.write(`- ${name} (${status})\n`);
|
|
265
195
|
}
|
|
266
196
|
}
|
|
267
197
|
async function cmdSend(args) {
|
|
@@ -284,6 +214,55 @@ async function cmdSend(args) {
|
|
|
284
214
|
}
|
|
285
215
|
printError(result.message);
|
|
286
216
|
}
|
|
217
|
+
async function cmdAsk(args) {
|
|
218
|
+
const json = hasFlag(args, "--json");
|
|
219
|
+
const to = valueForFlag(args, "--to");
|
|
220
|
+
const text = valueForFlag(args, "--text");
|
|
221
|
+
const waitRaw = valueForFlag(args, "--wait");
|
|
222
|
+
const waitSeconds = waitRaw ? Number(waitRaw) : 120;
|
|
223
|
+
if (!to || !text) {
|
|
224
|
+
printError("Usage: marshell ask --to <name> --text \"...\" [--wait <seconds>] [--json]");
|
|
225
|
+
}
|
|
226
|
+
const config = await (0, config_1.readConfig)();
|
|
227
|
+
const networkUrl = (0, config_1.getNetworkUrl)(config);
|
|
228
|
+
const result = await (0, network_1.askAgent)(networkUrl, to, text, waitSeconds);
|
|
229
|
+
if (json) {
|
|
230
|
+
printJson(result);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (result.kind === "ok") {
|
|
234
|
+
process.stdout.write(`${result.reply}\n`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (result.kind === "timeout") {
|
|
238
|
+
printError(`No reply from '${to}' within ${waitSeconds}s (sent id: ${result.sent_id}). Is their bridge running?`);
|
|
239
|
+
}
|
|
240
|
+
printError(result.message);
|
|
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
|
+
}
|
|
287
266
|
async function cmdInbox(args) {
|
|
288
267
|
const json = hasFlag(args, "--json");
|
|
289
268
|
const from = valueForFlag(args, "--from");
|
|
@@ -324,6 +303,9 @@ async function cmdInbox(args) {
|
|
|
324
303
|
const messages = from
|
|
325
304
|
? result.messages.filter((m) => m.from.toLowerCase() === from.toLowerCase())
|
|
326
305
|
: result.messages;
|
|
306
|
+
if (messages.length > 0) {
|
|
307
|
+
await (0, network_1.ackMessages)(networkUrl, messages.map((m) => m.id));
|
|
308
|
+
}
|
|
327
309
|
if (json) {
|
|
328
310
|
printJson({ messages, agent: result.agent });
|
|
329
311
|
return;
|
|
@@ -337,14 +319,14 @@ async function cmdInbox(args) {
|
|
|
337
319
|
}
|
|
338
320
|
}
|
|
339
321
|
async function cmdListen(args) {
|
|
340
|
-
const json = hasFlag(args, "--json");
|
|
341
322
|
const waitRaw = valueForFlag(args, "--wait");
|
|
342
323
|
const waitSeconds = waitRaw ? Number(waitRaw) : 0;
|
|
324
|
+
const json = hasFlag(args, "--json");
|
|
343
325
|
if (waitSeconds > 0) {
|
|
344
326
|
await cmdInbox(["--wait", String(waitSeconds), ...(json ? ["--json"] : [])]);
|
|
345
327
|
return;
|
|
346
328
|
}
|
|
347
|
-
await
|
|
329
|
+
await cmdBridgeRun(args);
|
|
348
330
|
}
|
|
349
331
|
async function main() {
|
|
350
332
|
const args = process.argv.slice(2);
|
|
@@ -373,11 +355,20 @@ async function main() {
|
|
|
373
355
|
return;
|
|
374
356
|
}
|
|
375
357
|
if (sub === "run") {
|
|
376
|
-
await
|
|
358
|
+
await cmdBridgeRun(rest);
|
|
377
359
|
return;
|
|
378
360
|
}
|
|
379
361
|
printError("Unknown agent command. Try: marshell agent join|run");
|
|
380
362
|
}
|
|
363
|
+
if (args[0] === "bridge") {
|
|
364
|
+
const sub = args[1];
|
|
365
|
+
const rest = args.slice(2);
|
|
366
|
+
if (sub === "run") {
|
|
367
|
+
await cmdBridgeRun(rest);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
printError("Unknown bridge command. Try: marshell bridge run");
|
|
371
|
+
}
|
|
381
372
|
if (args[0] === "discover") {
|
|
382
373
|
await cmdDiscover(args.slice(1));
|
|
383
374
|
return;
|
|
@@ -386,10 +377,18 @@ async function main() {
|
|
|
386
377
|
await cmdSend(args.slice(1));
|
|
387
378
|
return;
|
|
388
379
|
}
|
|
380
|
+
if (args[0] === "ask") {
|
|
381
|
+
await cmdAsk(args.slice(1));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
389
384
|
if (args[0] === "inbox") {
|
|
390
385
|
await cmdInbox(args.slice(1));
|
|
391
386
|
return;
|
|
392
387
|
}
|
|
388
|
+
if (args[0] === "history") {
|
|
389
|
+
await cmdHistory(args.slice(1));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
393
392
|
if (args[0] === "listen") {
|
|
394
393
|
await cmdListen(args.slice(1));
|
|
395
394
|
return;
|
package/dist/network.js
CHANGED
|
@@ -7,6 +7,8 @@ exports.sendMessage = sendMessage;
|
|
|
7
7
|
exports.fetchInbox = fetchInbox;
|
|
8
8
|
exports.ackMessages = ackMessages;
|
|
9
9
|
exports.waitForInbox = waitForInbox;
|
|
10
|
+
exports.fetchHistory = fetchHistory;
|
|
11
|
+
exports.askAgent = askAgent;
|
|
10
12
|
exports.toWsUrl = toWsUrl;
|
|
11
13
|
const config_1 = require("./config");
|
|
12
14
|
function normalizeBaseUrl(raw) {
|
|
@@ -123,8 +125,11 @@ async function joinAgent(baseUrl, name) {
|
|
|
123
125
|
}
|
|
124
126
|
}
|
|
125
127
|
async function discoverPeers(baseUrl) {
|
|
126
|
-
|
|
128
|
+
// Prefer agent_key — survives join-token rotation.
|
|
129
|
+
const config = await (0, config_1.readConfig)();
|
|
130
|
+
const kind = config.agentKey ? "agent" : "token";
|
|
127
131
|
try {
|
|
132
|
+
const headers = await authHeaders(kind);
|
|
128
133
|
const response = await fetch(withPath(baseUrl, "/v1/peers"), {
|
|
129
134
|
method: "GET",
|
|
130
135
|
headers,
|
|
@@ -169,7 +174,14 @@ async function sendMessage(baseUrl, to, text) {
|
|
|
169
174
|
async function fetchInbox(baseUrl, options) {
|
|
170
175
|
try {
|
|
171
176
|
const headers = await authHeaders("agent");
|
|
172
|
-
const
|
|
177
|
+
const params = new URLSearchParams();
|
|
178
|
+
if (options?.peek) {
|
|
179
|
+
params.set("peek", "1");
|
|
180
|
+
}
|
|
181
|
+
if (options?.waitSeconds && options.waitSeconds > 0) {
|
|
182
|
+
params.set("wait", String(Math.min(120, options.waitSeconds)));
|
|
183
|
+
}
|
|
184
|
+
const qs = params.size > 0 ? `?${params.toString()}` : "";
|
|
173
185
|
const response = await fetch(withPath(baseUrl, `/v1/messages/inbox${qs}`), {
|
|
174
186
|
method: "GET",
|
|
175
187
|
headers,
|
|
@@ -218,7 +230,11 @@ async function waitForInbox(baseUrl, options) {
|
|
|
218
230
|
const deadline = Date.now() + options.waitSeconds * 1000;
|
|
219
231
|
const from = options.from?.toLowerCase();
|
|
220
232
|
while (Date.now() < deadline) {
|
|
221
|
-
const
|
|
233
|
+
const remaining = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
|
|
234
|
+
const result = await fetchInbox(baseUrl, {
|
|
235
|
+
peek: true,
|
|
236
|
+
waitSeconds: remaining,
|
|
237
|
+
});
|
|
222
238
|
if (result.kind === "error") {
|
|
223
239
|
return result;
|
|
224
240
|
}
|
|
@@ -229,10 +245,81 @@ async function waitForInbox(baseUrl, options) {
|
|
|
229
245
|
await ackMessages(baseUrl, messages.map((m) => m.id));
|
|
230
246
|
return { kind: "ok", messages, agent: result.agent };
|
|
231
247
|
}
|
|
232
|
-
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
233
248
|
}
|
|
234
249
|
return { kind: "timeout" };
|
|
235
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
|
+
}
|
|
281
|
+
async function askAgent(baseUrl, to, text, waitSeconds) {
|
|
282
|
+
// Drain any stale messages from this peer before asking.
|
|
283
|
+
const stale = await fetchInbox(baseUrl, { peek: true });
|
|
284
|
+
if (stale.kind === "ok") {
|
|
285
|
+
const fromPeer = stale.messages.filter((m) => m.from.toLowerCase() === to.toLowerCase());
|
|
286
|
+
if (fromPeer.length > 0) {
|
|
287
|
+
await ackMessages(baseUrl, fromPeer.map((m) => m.id));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const sentAt = Date.now() - 2000;
|
|
291
|
+
const sent = await sendMessage(baseUrl, to, text);
|
|
292
|
+
if (sent.kind !== "sent") {
|
|
293
|
+
return { kind: "error", message: sent.message };
|
|
294
|
+
}
|
|
295
|
+
const deadline = Date.now() + waitSeconds * 1000;
|
|
296
|
+
const peer = to.toLowerCase();
|
|
297
|
+
while (Date.now() < deadline) {
|
|
298
|
+
const remaining = Math.max(1, Math.min(30, Math.ceil((deadline - Date.now()) / 1000)));
|
|
299
|
+
const result = await fetchInbox(baseUrl, {
|
|
300
|
+
peek: true,
|
|
301
|
+
waitSeconds: remaining,
|
|
302
|
+
});
|
|
303
|
+
if (result.kind === "error") {
|
|
304
|
+
return { kind: "error", message: result.message };
|
|
305
|
+
}
|
|
306
|
+
const messages = result.messages.filter((m) => {
|
|
307
|
+
if (m.from.toLowerCase() !== peer)
|
|
308
|
+
return false;
|
|
309
|
+
const created = Date.parse(m.created_at);
|
|
310
|
+
return Number.isNaN(created) || created >= sentAt;
|
|
311
|
+
});
|
|
312
|
+
if (messages.length > 0) {
|
|
313
|
+
await ackMessages(baseUrl, messages.map((m) => m.id));
|
|
314
|
+
return {
|
|
315
|
+
kind: "ok",
|
|
316
|
+
reply: messages.map((m) => m.text.trim()).join("\n\n"),
|
|
317
|
+
message_id: messages[0]?.id ?? sent.id,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { kind: "timeout", sent_id: sent.id };
|
|
322
|
+
}
|
|
236
323
|
function toWsUrl(httpBase) {
|
|
237
324
|
const url = new URL(httpBase);
|
|
238
325
|
if (url.protocol === "https:") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marshell/cli",
|
|
3
|
-
"version": "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",
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc -p tsconfig.json",
|
|
17
17
|
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
18
|
-
"prepare": "npm run build",
|
|
19
18
|
"prepublishOnly": "npm run build"
|
|
20
19
|
},
|
|
21
20
|
"engines": {
|