meshingly-mcp 0.1.0 → 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.
@@ -0,0 +1,200 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+
6
+ /**
7
+ * Per-project preferences of the person using Meshingly. They are changed from a terminal (`meshingly-mcp settings`),
8
+ * never through an MCP tool: the settings decide what runs unattended on this computer, so a model that a peer's
9
+ * message talked into something must not be able to change them. (An agent with shell access could still edit the
10
+ * file, exactly as it could edit its own permission settings: the guarantee is about the tool surface.)
11
+ *
12
+ * They live in the user's home directory, keyed by the project's path, and NOT in the project's own `.meshingly/`:
13
+ * a repository can ship a `.meshingly/` folder, and a command to run unattended must never come from there.
14
+ */
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+ export const SETTING_INFO = {
37
+ autoResume: {
38
+ kind: { type: "boolean" },
39
+ default: false,
40
+ env: "MESHINGLY_AUTOWATCH",
41
+ description: "Start the background watcher automatically, so queued work continues after the usage limit is back.",
42
+ },
43
+ resumeCommand: {
44
+ kind: { type: "string" },
45
+ default: "claude -p",
46
+ env: "MESHINGLY_WATCH_CMD",
47
+ description: "Command that receives the handoff on stdin and continues the work. Add the permissions it needs yourself.",
48
+ },
49
+ trustPeers: {
50
+ kind: { type: "boolean" },
51
+ default: false,
52
+ env: "MESHINGLY_WATCH_FROM_PEERS",
53
+ description: "Also run handoffs written by teammates. Their text starts an agent on this computer: only for people you trust.",
54
+ },
55
+ onQuestion: {
56
+ kind: { type: "enum", values: ["stop", "recommended"] },
57
+ default: "stop",
58
+ description:
59
+ "When an unattended run would ask a question: stop = park the question for a person; recommended = take the option it recommends, note the assumption and carry on (never for deleting, spending, publishing or pushing).",
60
+ },
61
+ modelMayStartWatcher: {
62
+ kind: { type: "boolean" },
63
+ default: false,
64
+ description: "Let an AI session start the background watcher with its meshingly_watch_start tool. Off: only you start it (watch command, or autoResume).",
65
+ },
66
+ settleMinutes: {
67
+ kind: { type: "int", min: 0, max: 240 },
68
+ default: 5,
69
+ description: "How long a handoff without a limit-reset time waits before the watcher runs it, in case the session that parked it is still working.",
70
+ },
71
+ maxAttempts: { kind: { type: "int", min: 1, max: 10 }, default: 3, description: "Failed runs per handoff before the watcher gives up on it." },
72
+ checkIntervalSeconds: { kind: { type: "int", min: 5, max: 3600 }, default: 60, description: "Seconds between queue checks." },
73
+ };
74
+
75
+ export const SETTING_KEYS = Object.keys(SETTING_INFO) ;
76
+
77
+ /** Where this project's settings are kept: under the user's home (MESHINGLY_HOME overrides it, for tests), keyed by the project's path. */
78
+ export function settingsPath(root ) {
79
+ // The same folder must give the same file however it was spelled (c:\dev vs C:\Dev, a symlink): otherwise a setting
80
+ // made from one shell would be silently ignored by a server started from another.
81
+ let real = resolve(root);
82
+ try {
83
+ real = realpathSync.native(real);
84
+ } catch {
85
+ /* the folder does not exist (yet): use the path as given */
86
+ }
87
+ const key = createHash("sha256").update(process.platform === "win32" ? real.toLowerCase() : real).digest("hex").slice(0, 16);
88
+ return join(process.env.MESHINGLY_HOME ?? join(homedir(), ".meshingly"), "projects", `${key}.json`);
89
+ }
90
+ const file = settingsPath;
91
+
92
+ /** Turns text into a valid value for the setting, or says why not. */
93
+ export function parseSetting(key , raw ) {
94
+ const { kind } = SETTING_INFO[key];
95
+ const text = typeof raw === "string" ? raw.trim() : raw;
96
+ switch (kind.type) {
97
+ case "boolean": {
98
+ if (typeof text === "boolean") return { ok: true, value: text };
99
+ const t = String(text).toLowerCase();
100
+ if (["true", "on", "yes", "1"].includes(t)) return { ok: true, value: true };
101
+ if (["false", "off", "no", "0"].includes(t)) return { ok: true, value: false };
102
+ return { ok: false, reason: "use true or false" };
103
+ }
104
+ case "enum":
105
+ return typeof text === "string" && kind.values.includes(text) ? { ok: true, value: text } : { ok: false, reason: `use one of: ${kind.values.join(", ")}` };
106
+ case "int": {
107
+ const n = typeof text === "number" ? text : Number(text);
108
+ return Number.isInteger(n) && n >= kind.min && n <= kind.max ? { ok: true, value: n } : { ok: false, reason: `use a whole number from ${kind.min} to ${kind.max}` };
109
+ }
110
+ case "string":
111
+ return typeof text === "string" && text.length > 0 && text.length <= 300 && !/[\r\n]/.test(text) ? { ok: true, value: text } : { ok: false, reason: "use a single line of 1 to 300 characters" };
112
+ }
113
+ }
114
+
115
+
116
+
117
+
118
+
119
+
120
+
121
+
122
+
123
+ export function resolveSettings(root , env = process.env) {
124
+ const values = Object.fromEntries(SETTING_KEYS.map((k) => [k, SETTING_INFO[k].default])) ;
125
+ const sources = Object.fromEntries(SETTING_KEYS.map((k) => [k, "default"])) ;
126
+ let problem ;
127
+
128
+ if (existsSync(file(root))) {
129
+ try {
130
+ const stored = JSON.parse(readFileSync(file(root), "utf8")) ;
131
+ for (const k of SETTING_KEYS) {
132
+ if (!(k in stored)) continue;
133
+ const parsed = parseSetting(k, stored[k]);
134
+ if (parsed.ok) {
135
+ (values )[k] = parsed.value;
136
+ sources[k] = "file";
137
+ } else {
138
+ problem = `${k} in the settings file is invalid (${parsed.reason}); the default is used`;
139
+ }
140
+ }
141
+ } catch (err) {
142
+ problem = `the settings file could not be read (${err instanceof Error ? err.message : String(err)}); the defaults are used`;
143
+ }
144
+ }
145
+ for (const k of SETTING_KEYS) {
146
+ const name = SETTING_INFO[k].env;
147
+ const raw = name ? env[name] : undefined;
148
+ if (!name || raw === undefined || raw === "") continue;
149
+ const parsed = parseSetting(k, raw);
150
+ if (parsed.ok) {
151
+ (values )[k] = parsed.value;
152
+ sources[k] = "environment";
153
+ }
154
+ }
155
+ return { values, sources, ...(problem ? { problem } : {}) };
156
+ }
157
+
158
+ /** Writes one setting to the project's settings file. Throws an Error whose message says what is wrong. */
159
+ export function writeSetting(root , key , raw ) {
160
+ if (!SETTING_KEYS.includes(key )) throw new Error(`Unknown setting "${key}". Settings: ${SETTING_KEYS.join(", ")}.`);
161
+ const k = key ;
162
+ const parsed = parseSetting(k, raw);
163
+ if (!parsed.ok) throw new Error(`${k}: ${parsed.reason}.`);
164
+ let stored = {};
165
+ try {
166
+ if (existsSync(file(root))) stored = JSON.parse(readFileSync(file(root), "utf8")) ;
167
+ } catch {
168
+ stored = {}; // a broken file is replaced, not extended
169
+ }
170
+ stored[k] = parsed.value;
171
+ mkdirSync(dirname(file(root)), { recursive: true });
172
+ const tmp = `${file(root)}.tmp`;
173
+ writeFileSync(tmp, JSON.stringify(stored, null, 2), { mode: 0o600 });
174
+ renameSync(tmp, file(root));
175
+ return resolveSettings(root).values;
176
+ }
177
+
178
+ export function resetSettings(root ) {
179
+ rmSync(file(root), { force: true });
180
+ }
181
+
182
+ export function describeSettings(root , env = process.env) {
183
+ const { values, sources, problem } = resolveSettings(root, env);
184
+ const lines = SETTING_KEYS.map((k) => `${k.padEnd(21)} ${String(values[k]).padEnd(12)} ${sources[k] === "default" ? "(default)" : `(${sources[k]})`} ${SETTING_INFO[k].description}`);
185
+ lines.push("", `File: ${settingsPath(root)}`);
186
+ if (problem) lines.push(`Note: ${problem}.`);
187
+ return lines.join("\n");
188
+ }
189
+
190
+ /** Appended to the prompt of an unattended run. */
191
+ export function questionPolicy(onQuestion ) {
192
+ if (onQuestion === "stop") {
193
+ return "Nobody is at the keyboard. If you need an answer from the user to go on, do not guess: call meshingly_handoff with needsPerson=true, the question and what you have done so far, then stop.";
194
+ }
195
+ return [
196
+ "Nobody is at the keyboard, and the user chose that you carry on instead of waiting.",
197
+ "When you would ask the user to choose, take the option you would recommend, write down the assumption you made, and continue. Put every such assumption in your final summary and in meshingly_done.",
198
+ "Exception: never decide for the user anything that deletes data, spends money, publishes, pushes, or contacts other people. For those, call meshingly_handoff with needsPerson=true, the question and what you have done so far, then stop.",
199
+ ].join(" ");
200
+ }
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,6 @@
1
1
  {
2
2
  "name": "meshingly-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "End-to-end encrypted relay that lets AI coding sessions on different machines message each other.",
5
5
  "mcpName": "com.meshingly/meshingly",
6
6
  "type": "module",