meshingly-mcp 0.0.0-stage → 0.2.0

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/watch.js ADDED
@@ -0,0 +1,328 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { BEAT_MS, clearBeat, killTree, stateFile, takeCurrentRun, writeBeat } from "./background.js";
6
+ import { Meshingly, ToolError } from "./connection.js";
7
+ import { LEASE_MS } from "./queue.js";
8
+ import { questionPolicy, resolveSettings, } from "./settings.js";
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+ const HELP = `meshingly-mcp watch: wakes up an AI session when work is waiting in the Meshingly queue.
33
+
34
+ Usage: npx -y meshingly-mcp watch [--cmd "claude -p"] [--interval 60] [--once] [--from-peers] [--on-question stop|recommended] [--dir <project>]
35
+
36
+ It checks the queue; when a handoff is open and its "limit expected back" time has passed (a handoff without one
37
+ waits a few minutes, in case its session is still working), it claims the handoff and starts the command with the
38
+ handoff as the prompt, in the project directory. If the command succeeds the handoff is marked finished. If it fails
39
+ (for example the usage limit is still out) the handoff goes back to the queue and is retried later, up to
40
+ --max-attempts times. Handoffs marked as a question for a person are never run.
41
+
42
+ --cmd Command that reads the prompt from stdin. Default: claude -p
43
+ Add the permissions the work needs yourself, e.g. "claude -p --permission-mode acceptEdits".
44
+ --interval Seconds between checks. Default 60.
45
+ --once Check once and exit, so cron or Task Scheduler can run it.
46
+ --from-peers Also run handoffs written by OTHER sessions. By default only this project's own session is
47
+ trusted, because a handoff is text that starts an AI agent on your computer: enable this
48
+ only for a collaboration whose members you trust.
49
+ --on-question What a run does when it would ask you something: stop (default) or recommended.
50
+ --max-attempts Default 3.
51
+ --settle-minutes Default 5.
52
+ --dir Project directory holding .meshingly/. Default: the current directory.
53
+
54
+ Every option has a setting that is used when the flag is absent: npx -y meshingly-mcp settings
55
+ `;
56
+
57
+ function sleep(ms , signal ) {
58
+ return new Promise((resolve) => {
59
+ if (signal.aborted) return resolve();
60
+ const done = () => {
61
+ clearTimeout(timer);
62
+ signal.removeEventListener("abort", done);
63
+ resolve();
64
+ };
65
+ const timer = setTimeout(done, ms);
66
+ signal.addEventListener("abort", done, { once: true });
67
+ });
68
+ }
69
+
70
+ /** Runs the command with the prompt on stdin and resolves with its exit code (124 when it was stopped for running too long). */
71
+ function runCommand(command , prompt , cwd , env , maxRunMs , signal ) {
72
+ return new Promise((resolve, reject) => {
73
+ // Detached on POSIX: its own process group, so a timeout can end the agent and not just the shell around it.
74
+ const child = spawn(command, { shell: true, cwd, env, stdio: ["pipe", "inherit", "inherit"], detached: process.platform !== "win32" });
75
+ let timedOut = false;
76
+ const stop = () => {
77
+ if (child.pid) killTree(child.pid);
78
+ };
79
+ const timer = setTimeout(() => {
80
+ timedOut = true;
81
+ stop();
82
+ }, maxRunMs);
83
+ signal.addEventListener("abort", stop, { once: true });
84
+ child.on("error", (err) => {
85
+ clearTimeout(timer);
86
+ reject(err);
87
+ });
88
+ child.on("exit", (code) => {
89
+ clearTimeout(timer);
90
+ signal.removeEventListener("abort", stop);
91
+ resolve(timedOut ? 124 : (code ?? 1));
92
+ });
93
+ // A command that exits before reading its input closes the pipe: that is a failed run, not a crash of the watcher.
94
+ child.stdin.on("error", () => {});
95
+ child.stdin.end(prompt);
96
+ });
97
+ }
98
+
99
+ /** Failed attempts and retry times per handoff, kept on disk: `--once` under cron and every restart would otherwise start from zero. */
100
+
101
+
102
+
103
+
104
+
105
+
106
+
107
+ function loadState(root ) {
108
+ const st = { attempts: new Map(), retryAt: new Map() };
109
+ try {
110
+ if (!existsSync(stateFile(root))) return st;
111
+ const raw = JSON.parse(readFileSync(stateFile(root), "utf8")) ;
112
+ for (const [id, n] of Object.entries(raw.attempts ?? {})) if (typeof n === "number") st.attempts.set(id, n);
113
+ for (const [id, n] of Object.entries(raw.retryAt ?? {})) if (typeof n === "number") st.retryAt.set(id, n);
114
+ } catch {
115
+ /* a damaged file means a fresh start, not a crash */
116
+ }
117
+ return st;
118
+ }
119
+
120
+ /** Writes the state, keeping only handoffs that still exist. */
121
+ function saveState(root , st , live ) {
122
+ const pick = (m ) => Object.fromEntries([...m].filter(([id]) => live.has(id)));
123
+ try {
124
+ mkdirSync(join(root, ".meshingly"), { recursive: true });
125
+ const tmp = `${stateFile(root)}.tmp`;
126
+ writeFileSync(tmp, JSON.stringify({ attempts: pick(st.attempts), retryAt: pick(st.retryAt), ...(st.current ? { current: st.current } : {}) }));
127
+ renameSync(tmp, stateFile(root));
128
+ } catch {
129
+ /* losing the counters is better than stopping the watcher */
130
+ }
131
+ }
132
+
133
+ /** Looks at the queue once and runs at most one handoff. Returns whether it ran one. */
134
+ async function pass(m , o , st , root ) {
135
+ const { items, self } = await m.openHandoffs();
136
+ const now = Date.now();
137
+ const known = new Set(items.map((h) => h.id));
138
+ for (const h of items) {
139
+ if (o.signal.aborted) return false;
140
+ if (h.status !== "open") continue; // someone is on it (a live session of this project included)
141
+ if (h.needsPerson) continue; // a question for a human, not work for an agent
142
+ if (!o.fromPeers && h.fromSessionId !== self) continue;
143
+ if ((st.attempts.get(h.id) ?? 0) >= o.maxAttempts) continue;
144
+ if ((st.retryAt.get(h.id) ?? 0) > now) continue;
145
+ if (h.resetsAt) {
146
+ if (Date.parse(h.resetsAt) > now) continue; // the limit is not back yet
147
+ } else if (now - h.createdAt < o.settleMs) {
148
+ continue; // no reset time: the session that parked it may still be working
149
+ }
150
+
151
+ let prompt ;
152
+ try {
153
+ prompt = await m.claim({ id: h.id });
154
+ } catch (err) {
155
+ if (err instanceof ToolError) {
156
+ o.log(`"${h.title}": ${err.message}`);
157
+ continue;
158
+ }
159
+ throw err;
160
+ }
161
+ const attempt = (st.attempts.get(h.id) ?? 0) + 1;
162
+ st.attempts.set(h.id, attempt);
163
+ st.current = h.id;
164
+ saveState(root, st, known);
165
+ o.log(`"${h.title}": claimed, starting the command (attempt ${attempt}/${o.maxAttempts})`);
166
+
167
+ const full = `${prompt}\n\nContinue this work in the current project directory. When it is finished, call meshingly_done with handoff id ${h.id}. If you are about to run out of usage again, call meshingly_handoff with the new state and stop.\n\n${questionPolicy(o.onQuestion)}`;
168
+ let code ;
169
+ try {
170
+ code = await runCommand(o.command, full, root, { ...process.env, MESHINGLY_DIR: root }, o.maxRunMs, o.signal);
171
+ } catch (err) {
172
+ o.log(`"${h.title}": could not start the command: ${err instanceof Error ? err.message : String(err)}`);
173
+ code = 127;
174
+ }
175
+ st.current = undefined;
176
+
177
+ // The run may have parked its own continuation (usage ran out again, or it has a question). That new handoff
178
+ // replaces this one, and it inherits the attempt count and waits like a failure would: otherwise it would be
179
+ // picked up at once, with fresh counters, in an endless loop. (A handoff parked meanwhile by the project's own live
180
+ // session looks the same from here: it is only delayed, never lost.)
181
+ const after = await m.openHandoffs();
182
+ const parked = after.items.filter((n) => !known.has(n.id) && n.fromSessionId === self && n.status === "open");
183
+ const wait = o.backoffMs * attempt;
184
+ for (const n of parked) {
185
+ st.attempts.set(n.id, attempt);
186
+ st.retryAt.set(n.id, Date.now() + wait);
187
+ o.log(`"${n.title}": parked by the run; it will not be started before ${new Date(Date.now() + wait).toISOString()}`);
188
+ }
189
+ if (parked.length > 0) {
190
+ // Whatever the exit code, the continuation carries the work forward: handing the original back too would leave two copies.
191
+ await m.done({ id: h.id, summary: "continued in a new handoff" });
192
+ o.log(`"${h.title}": replaced by the handoff the run parked`);
193
+ } else if (code === 0) {
194
+ // The agent normally reports "done" itself; this makes it so when it did not (the call is idempotent).
195
+ await m.done({ id: h.id, summary: "finished by the watcher's run" });
196
+ o.log(`"${h.title}": finished`);
197
+ } else {
198
+ await m.release({ id: h.id });
199
+ st.retryAt.set(h.id, Date.now() + wait);
200
+ o.log(
201
+ attempt >= o.maxAttempts
202
+ ? `"${h.title}": failed (exit ${code}); giving up after ${attempt} attempts, it stays in the queue for a person or another session`
203
+ : `"${h.title}": failed (exit ${code}); back in the queue, next try in ${Math.round(wait / 60000)} min`,
204
+ );
205
+ }
206
+ saveState(root, st, new Set([...known, ...after.items.map((n) => n.id)]));
207
+ return true;
208
+ }
209
+ saveState(root, st, known);
210
+ return false;
211
+ }
212
+
213
+ export async function watch(m , o , root ) {
214
+ // A watcher that was killed in the middle of a run left its claim behind: give it back instead of making everyone wait for the lease.
215
+ const stale = takeCurrentRun(root);
216
+ if (stale) {
217
+ try {
218
+ await m.release({ id: stale });
219
+ o.log(`gave back a handoff that a previous watcher was working on when it stopped`);
220
+ } catch (err) {
221
+ o.log(`could not give back an interrupted handoff: ${err instanceof Error ? err.message : String(err)}`);
222
+ }
223
+ }
224
+ const st = loadState(root);
225
+ while (!o.signal.aborted) {
226
+ try {
227
+ // Drain what is ready; a failed item waits for its retry time, so this cannot spin.
228
+ while (await pass(m, o, st, root)) {
229
+ /* next item */
230
+ }
231
+ } catch (err) {
232
+ o.log(`check failed: ${err instanceof Error ? err.message : String(err)}`);
233
+ }
234
+ if (o.once) return;
235
+ await sleep(o.intervalMs, o.signal);
236
+ }
237
+ }
238
+
239
+ /** Entry point for `meshingly-mcp watch ...`. Returns the process exit code. */
240
+ export async function runWatch(argv ) {
241
+ let values;
242
+ try {
243
+ ({ values } = parseArgs({
244
+ args: argv,
245
+ options: {
246
+ cmd: { type: "string" },
247
+ interval: { type: "string" },
248
+ once: { type: "boolean", default: false },
249
+ "from-peers": { type: "boolean" },
250
+ "on-question": { type: "string" },
251
+ "max-attempts": { type: "string" },
252
+ "settle-minutes": { type: "string" },
253
+ dir: { type: "string" },
254
+ help: { type: "boolean", default: false },
255
+ },
256
+ }));
257
+ } catch (err) {
258
+ console.error(`${err instanceof Error ? err.message : String(err)}\n\n${HELP}`);
259
+ return 2;
260
+ }
261
+ if (values.help) {
262
+ console.log(HELP);
263
+ return 0;
264
+ }
265
+ const root = values.dir ?? process.env.MESHINGLY_DIR ?? process.cwd();
266
+ // A flag wins; otherwise the setting (which an environment variable in the MCP configuration overrides).
267
+ const saved = resolveSettings(root);
268
+ const interval = values.interval === undefined ? saved.values.checkIntervalSeconds : Number(values.interval);
269
+ const maxAttempts = values["max-attempts"] === undefined ? saved.values.maxAttempts : Number(values["max-attempts"]);
270
+ const settleMinutes = values["settle-minutes"] === undefined ? saved.values.settleMinutes : Number(values["settle-minutes"]);
271
+ const onQuestion = values["on-question"] ?? saved.values.onQuestion;
272
+ const command = values.cmd ?? saved.values.resumeCommand;
273
+ const fromPeers = values["from-peers"] ?? saved.values.trustPeers;
274
+ if (
275
+ !(interval >= 5) ||
276
+ !Number.isInteger(maxAttempts) ||
277
+ maxAttempts < 1 ||
278
+ !(settleMinutes >= 0) ||
279
+ (onQuestion !== "stop" && onQuestion !== "recommended")
280
+ ) {
281
+ console.error(`--interval must be at least 5 seconds, --max-attempts a whole number of 1 or more, --settle-minutes 0 or more and --on-question stop or recommended.\n\n${HELP}`);
282
+ return 2;
283
+ }
284
+ const m = new Meshingly(root);
285
+ const stamp = (line ) => console.error(`${new Date().toISOString()} meshingly watch: ${line}`);
286
+
287
+ if (!m.connected()) {
288
+ console.error("Connect this project first (ask your AI session to set up Meshingly; if .meshingly/connection.json is damaged, delete it), then run the watcher again.");
289
+ return 1;
290
+ }
291
+ console.error(m.status());
292
+ if (saved.problem) stamp(`warning: ${saved.problem}`);
293
+ const abort = new AbortController();
294
+ process.once("SIGINT", () => abort.abort());
295
+ process.once("SIGTERM", () => abort.abort());
296
+
297
+ // Proof of life for meshingly_watch_status and stop: a pid alone could be a stranger's after a reboot.
298
+ let beat ;
299
+ if (!values.once) {
300
+ writeBeat(root);
301
+ beat = setInterval(() => writeBeat(root), BEAT_MS);
302
+ beat.unref();
303
+ }
304
+ stamp(`watching the queue every ${interval}s with "${command}"${fromPeers ? " (also handoffs from peers)" : " (own handoffs only)"}, on a question: ${onQuestion}`);
305
+ try {
306
+ await watch(
307
+ m,
308
+ {
309
+ command,
310
+ intervalMs: interval * 1000,
311
+ once: values.once === true,
312
+ fromPeers,
313
+ onQuestion,
314
+ maxAttempts,
315
+ settleMs: settleMinutes * 60_000,
316
+ maxRunMs: LEASE_MS - 10 * 60_000,
317
+ backoffMs: 30 * 60_000,
318
+ signal: abort.signal,
319
+ log: stamp,
320
+ },
321
+ root,
322
+ );
323
+ } finally {
324
+ if (beat) clearInterval(beat);
325
+ if (!values.once) clearBeat(root);
326
+ }
327
+ return 0;
328
+ }
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "meshingly-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "End-to-end encrypted relay that lets AI coding sessions on different machines message each other.",
5
+ "mcpName": "com.meshingly/meshingly",
6
+ "type": "module",
7
+ "bin": {
8
+ "meshingly-mcp": "dist/server.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=22.18"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Burak Uysal",
18
+ "homepage": "https://meshingly.com",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/DrBurakUysal/meshingly-mcp.git"
22
+ },
23
+ "keywords": [
24
+ "mcp",
25
+ "model-context-protocol",
26
+ "ai-agents",
27
+ "claude-code",
28
+ "codex",
29
+ "opencode",
30
+ "encryption",
31
+ "collaboration"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }