meshingly-mcp 0.1.0 → 0.3.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,73 @@
1
+ import { createInterface } from "node:readline";
2
+ import { describeSettings, resetSettings, SETTING_KEYS, writeSetting } from "./settings.js";
3
+
4
+ /** One line from the user. Resolves with "" when there is none (end of input). */
5
+ function ask(prompt ) {
6
+ return new Promise((resolve) => {
7
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
8
+ let answered = false;
9
+ rl.question(prompt, (answer) => {
10
+ answered = true;
11
+ rl.close();
12
+ resolve(answer);
13
+ });
14
+ rl.on("close", () => {
15
+ if (!answered) resolve("");
16
+ });
17
+ });
18
+ }
19
+
20
+ const HELP = `meshingly-mcp settings: your preferences for this project (stored in your home folder, not in the project: a repository must not be able to ship them).
21
+
22
+ npx -y meshingly-mcp settings show every setting and where its value comes from
23
+ npx -y meshingly-mcp settings set <key> <value>
24
+ npx -y meshingly-mcp settings set <key> leave the value out to be asked for it (no quoting needed: handy for a command with quotes or $)
25
+ npx -y meshingly-mcp settings reset back to the defaults
26
+
27
+ Keys: ${SETTING_KEYS.join(", ")}
28
+
29
+ Examples:
30
+ npx -y meshingly-mcp settings set autoResume true continue queued work automatically after the limit is back
31
+ npx -y meshingly-mcp settings set onQuestion recommended an unattended run takes the recommended option instead of waiting
32
+ npx -y meshingly-mcp settings set resumeCommand "codex exec -"
33
+
34
+ You can also just ask your AI session ("turn on the journal", "let every session know what the others did"): it changes the harmless settings itself and asks you to confirm the ones that start programs on this computer.
35
+ A running watcher keeps the values it started with: stop it and start it again after a change.
36
+ An environment variable in your MCP configuration (MESHINGLY_AUTOWATCH, MESHINGLY_WATCH_CMD, MESHINGLY_WATCH_FROM_PEERS) overrides the file.
37
+ `;
38
+
39
+ /** `meshingly-mcp settings ...`. Returns the process exit code. */
40
+ export async function runSettings(argv , root = process.env.MESHINGLY_DIR ?? process.cwd()) {
41
+ const [verb, key, ...rest] = argv;
42
+ if (verb === "--help" || verb === "help" || verb === "-h") {
43
+ console.log(HELP);
44
+ return 0;
45
+ }
46
+ if (verb === undefined) {
47
+ console.log(describeSettings(root));
48
+ return 0;
49
+ }
50
+ if (verb === "reset") {
51
+ resetSettings(root);
52
+ console.log("Settings are back to the defaults.");
53
+ return 0;
54
+ }
55
+ if (verb === "set") {
56
+ // With no value on the command line the value is asked for: what is typed there never passes through the shell.
57
+ const value = key && rest.length === 0 ? (await ask(`${key}: `)).trim() : rest.join(" ");
58
+ if (!key || !value) {
59
+ console.error(key ? `No value for ${key}: give it after the name, or type it when asked.\n\n${HELP}` : `Usage: settings set <key> <value>\n\n${HELP}`);
60
+ return 2;
61
+ }
62
+ try {
63
+ writeSetting(root, key, value);
64
+ } catch (err) {
65
+ console.error(err instanceof Error ? err.message : String(err));
66
+ return 2;
67
+ }
68
+ console.log(`${describeSettings(root)}\n\nSaved. A running watcher keeps its old values: stop it and start it again.`);
69
+ return 0;
70
+ }
71
+ console.error(`Unknown command "${verb}".\n\n${HELP}`);
72
+ return 2;
73
+ }
@@ -0,0 +1,271 @@
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
+
37
+
38
+
39
+
40
+ export const SETTING_INFO = {
41
+ autoResume: {
42
+ kind: { type: "boolean" },
43
+ default: false,
44
+ env: "MESHINGLY_AUTOWATCH",
45
+ description: "Start the background watcher automatically, so queued work continues after the usage limit is back.",
46
+ },
47
+ resumeCommand: {
48
+ kind: { type: "string" },
49
+ default: "claude -p",
50
+ env: "MESHINGLY_WATCH_CMD",
51
+ description: "Command that receives the handoff on stdin and continues the work. Add the permissions it needs yourself.",
52
+ },
53
+ trustPeers: {
54
+ kind: { type: "boolean" },
55
+ default: false,
56
+ env: "MESHINGLY_WATCH_FROM_PEERS",
57
+ description: "Also run handoffs written by teammates. Their text starts an agent on this computer: only for people you trust.",
58
+ },
59
+ onQuestion: {
60
+ kind: { type: "enum", values: ["stop", "recommended"] },
61
+ default: "stop",
62
+ description:
63
+ "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).",
64
+ },
65
+ journal: {
66
+ kind: { type: "enum", values: ["off", "milestones", "detailed"] },
67
+ default: "off",
68
+ description:
69
+ "Sessions write short notes for the other sessions (milestones: finished steps and decisions; detailed: every step). Everyone in the collaboration can read them.",
70
+ },
71
+ catchUp: {
72
+ kind: { type: "int", min: 0, max: 200 },
73
+ default: 0,
74
+ description: "How many recent entries (notes, handoffs, messages) a session reads when it joins or starts, so it knows what the other sessions did. 0 = none.",
75
+ },
76
+ modelMayStartWatcher: {
77
+ kind: { type: "boolean" },
78
+ default: false,
79
+ description: "Let an AI session start the background watcher with its meshingly_watch_start tool. Off: only you start it (watch command, or autoResume).",
80
+ },
81
+ settleMinutes: {
82
+ kind: { type: "int", min: 0, max: 240 },
83
+ default: 5,
84
+ 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.",
85
+ },
86
+ maxAttempts: { kind: { type: "int", min: 1, max: 10 }, default: 3, description: "Failed runs per handoff before the watcher gives up on it." },
87
+ checkIntervalSeconds: { kind: { type: "int", min: 5, max: 3600 }, default: 60, description: "Seconds between queue checks." },
88
+ };
89
+
90
+ export const SETTING_KEYS = Object.keys(SETTING_INFO) ;
91
+
92
+ /**
93
+ * Settings an AI session may change itself when the user asks for it in the conversation. They decide what is shared inside
94
+ * the collaboration or how patient the watcher is; none of them starts a program.
95
+ */
96
+ export const MODEL_MAY_SET = new Set(["journal", "catchUp", "settleMinutes", "maxAttempts", "checkIntervalSeconds"]);
97
+
98
+ /**
99
+ * Whether a change needs the user's own approval, which the model cannot give. Everything does, except what is on the
100
+ * allow-list above and switching a supervision-reducing setting back off: a setting added later is guarded until someone
101
+ * deliberately puts it on the list.
102
+ */
103
+ export function needsApproval(key , value ) {
104
+ if (MODEL_MAY_SET.has(key)) return false;
105
+ if ((key === "autoResume" || key === "trustPeers" || key === "modelMayStartWatcher") && value === false) return false;
106
+ if (key === "onQuestion" && value === "stop") return false;
107
+ return true;
108
+ }
109
+
110
+ /** What a change means, in the words the user sees when asked to approve it. */
111
+ export function riskNote(key , value ) {
112
+ switch (key) {
113
+ case "autoResume":
114
+ return "a background process will start an AI agent on this computer whenever queued work is ready";
115
+ case "resumeCommand":
116
+ return `this command will be run unattended, with queued work as its input: ${String(value)}`;
117
+ case "trustPeers":
118
+ return "handoffs written by teammates will start an AI agent on this computer";
119
+ case "onQuestion":
120
+ return "unattended runs will decide questions themselves instead of waiting for you (never deleting, spending, publishing or pushing)";
121
+ case "modelMayStartWatcher":
122
+ return "an AI session may start the background watcher without asking you again";
123
+ default:
124
+ return "this changes how Meshingly runs on this computer";
125
+ }
126
+ }
127
+
128
+ /** Where this project's settings are kept: under the user's home (MESHINGLY_HOME overrides it, for tests), keyed by the project's path. */
129
+ export function settingsPath(root ) {
130
+ // The same folder must give the same file however it was spelled (c:\dev vs C:\Dev, a symlink): otherwise a setting
131
+ // made from one shell would be silently ignored by a server started from another.
132
+ let real = resolve(root);
133
+ try {
134
+ real = realpathSync.native(real);
135
+ } catch {
136
+ /* the folder does not exist (yet): use the path as given */
137
+ }
138
+ const key = createHash("sha256").update(process.platform === "win32" ? real.toLowerCase() : real).digest("hex").slice(0, 16);
139
+ return join(process.env.MESHINGLY_HOME ?? join(homedir(), ".meshingly"), "projects", `${key}.json`);
140
+ }
141
+ const file = settingsPath;
142
+
143
+ /** Turns text into a valid value for the setting, or says why not. */
144
+ export function parseSetting(key , raw ) {
145
+ const { kind } = SETTING_INFO[key];
146
+ const text = typeof raw === "string" ? raw.trim() : raw;
147
+ switch (kind.type) {
148
+ case "boolean": {
149
+ if (typeof text === "boolean") return { ok: true, value: text };
150
+ const t = String(text).toLowerCase();
151
+ if (["true", "on", "yes", "1"].includes(t)) return { ok: true, value: true };
152
+ if (["false", "off", "no", "0"].includes(t)) return { ok: true, value: false };
153
+ return { ok: false, reason: "use true or false" };
154
+ }
155
+ case "enum":
156
+ return typeof text === "string" && kind.values.includes(text) ? { ok: true, value: text } : { ok: false, reason: `use one of: ${kind.values.join(", ")}` };
157
+ case "int": {
158
+ // Not Number(): it turns "", true and [3] into numbers, and a malformed value must never become a valid setting.
159
+ const n = typeof text === "number" ? text : typeof text === "string" && /^-?\d+$/.test(text) ? Number(text) : Number.NaN;
160
+ 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}` };
161
+ }
162
+ case "string":
163
+ 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" };
164
+ }
165
+ }
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+ export function resolveSettings(root , env = process.env) {
176
+ const values = Object.fromEntries(SETTING_KEYS.map((k) => [k, SETTING_INFO[k].default])) ;
177
+ const sources = Object.fromEntries(SETTING_KEYS.map((k) => [k, "default"])) ;
178
+ let problem ;
179
+
180
+ if (existsSync(file(root))) {
181
+ try {
182
+ const stored = JSON.parse(readFileSync(file(root), "utf8")) ;
183
+ for (const k of SETTING_KEYS) {
184
+ if (!(k in stored)) continue;
185
+ const parsed = parseSetting(k, stored[k]);
186
+ if (parsed.ok) {
187
+ (values )[k] = parsed.value;
188
+ sources[k] = "file";
189
+ } else {
190
+ problem = `${k} in the settings file is invalid (${parsed.reason}); the default is used`;
191
+ }
192
+ }
193
+ } catch (err) {
194
+ problem = `the settings file could not be read (${err instanceof Error ? err.message : String(err)}); the defaults are used`;
195
+ }
196
+ }
197
+ for (const k of SETTING_KEYS) {
198
+ const name = SETTING_INFO[k].env;
199
+ const raw = name ? env[name] : undefined;
200
+ if (!name || raw === undefined || raw === "") continue;
201
+ const parsed = parseSetting(k, raw);
202
+ if (parsed.ok) {
203
+ (values )[k] = parsed.value;
204
+ sources[k] = "environment";
205
+ }
206
+ }
207
+ return { values, sources, ...(problem ? { problem } : {}) };
208
+ }
209
+
210
+ /** Writes one setting to the project's settings file. Throws an Error whose message says what is wrong. */
211
+ export function writeSetting(root , key , raw ) {
212
+ return writeSettings(root, { [key]: raw });
213
+ }
214
+
215
+ /** Writes several settings in one go: all are checked first, then saved with a single rename, so a failure leaves none of them half applied. */
216
+ export function writeSettings(root , entries ) {
217
+ const checked = [];
218
+ for (const [key, raw] of Object.entries(entries)) {
219
+ if (!SETTING_KEYS.includes(key )) throw new Error(`Unknown setting "${key}". Settings: ${SETTING_KEYS.join(", ")}.`);
220
+ const k = key ;
221
+ const parsed = parseSetting(k, raw);
222
+ if (!parsed.ok) throw new Error(`${k}: ${parsed.reason}.`);
223
+ checked.push([k, parsed.value]);
224
+ }
225
+ let stored = {};
226
+ try {
227
+ if (existsSync(file(root))) stored = JSON.parse(readFileSync(file(root), "utf8")) ;
228
+ } catch {
229
+ stored = {}; // a broken file is replaced, not extended
230
+ }
231
+ for (const [k, value] of checked) stored[k] = value;
232
+ mkdirSync(dirname(file(root)), { recursive: true });
233
+ const tmp = `${file(root)}.tmp`;
234
+ writeFileSync(tmp, JSON.stringify(stored, null, 2), { mode: 0o600 });
235
+ renameSync(tmp, file(root));
236
+ return resolveSettings(root).values;
237
+ }
238
+
239
+ export function resetSettings(root ) {
240
+ rmSync(file(root), { force: true });
241
+ }
242
+
243
+ export function describeSettings(root , env = process.env) {
244
+ const { values, sources, problem } = resolveSettings(root, env);
245
+ const lines = SETTING_KEYS.map((k) => `${k.padEnd(21)} ${String(values[k]).padEnd(12)} ${sources[k] === "default" ? "(default)" : `(${sources[k]})`} ${SETTING_INFO[k].description}`);
246
+ lines.push("", `File: ${settingsPath(root)}`);
247
+ if (problem) lines.push(`Note: ${problem}.`);
248
+ return lines.join("\n");
249
+ }
250
+
251
+ /** What a session is told about the journal. Empty when it is off. */
252
+ export function journalPolicy(level ) {
253
+ if (level === "off") return "";
254
+ const when =
255
+ level === "detailed"
256
+ ? "after each step you take (what you changed, which files, what you decided and why, what comes next)"
257
+ : "after each milestone you finish (a feature done, a bug found or fixed, a decision made, what comes next)";
258
+ return `The user turned the journal on (${level}): ${when}, call meshingly_journal with one to three sentences. The other sessions in this collaboration, including your own later ones on other computers, read these notes to know what happened here, and everyone in the collaboration can read them. Never put secrets in a note.`;
259
+ }
260
+
261
+ /** Appended to the prompt of an unattended run. */
262
+ export function questionPolicy(onQuestion ) {
263
+ if (onQuestion === "stop") {
264
+ 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.";
265
+ }
266
+ return [
267
+ "Nobody is at the keyboard, and the user chose that you carry on instead of waiting.",
268
+ "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.",
269
+ "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.",
270
+ ].join(" ");
271
+ }
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
+ }