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.
- package/README.md +19 -1
- package/dist/background.js +183 -0
- package/dist/connection.js +409 -14
- package/dist/lib/client.js +80 -80
- package/dist/lib/collab.js +155 -155
- package/dist/lib/config.js +98 -95
- package/dist/lib/crypto.js +143 -143
- package/dist/lib/listener.js +96 -96
- package/dist/lib/messenger.js +129 -117
- package/dist/lib/protocol.js +49 -45
- package/dist/queue.js +96 -0
- package/dist/server.js +393 -180
- package/dist/settings-cli.js +73 -0
- package/dist/settings.js +271 -0
- package/dist/watch.js +328 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,28 @@ Other tools take the same command as a local stdio server: `npx -y meshingly-mcp
|
|
|
32
32
|
| `meshingly_peers` | Lists the other sessions in the collaboration |
|
|
33
33
|
| `meshingly_send` | Sends an encrypted message to the others |
|
|
34
34
|
| `meshingly_receive` | Reads new messages, optionally waiting for one |
|
|
35
|
+
| `meshingly_handoff`, `meshingly_queue`, `meshingly_claim`, `meshingly_release`, `meshingly_done` | A work queue: park unfinished work, list it, take it over, give it back, finish it |
|
|
36
|
+
| `meshingly_watch_start`, `meshingly_watch_stop`, `meshingly_watch_status` | Optional background watcher that restarts queued work (see below) |
|
|
37
|
+
| `meshingly_journal` | Writes a short note for the other sessions (only if you turned the journal on) |
|
|
38
|
+
| `meshingly_settings` | Shows your preferences, and changes them when you ask your agent to |
|
|
39
|
+
|
|
40
|
+
## Several computers and handing work over
|
|
41
|
+
|
|
42
|
+
The same person can join one collaboration from several computers; each session is labelled with a computer name you choose (not the host name). When a session is about to run out of usage it can park its work in the queue, and any session, a teammate's or your own on another computer, can take it over.
|
|
43
|
+
|
|
44
|
+
## Remembering what the other sessions did
|
|
45
|
+
|
|
46
|
+
Two settings, both off until you turn them on (just ask your agent, or use the terminal). `journal` (`milestones` or `detailed`) lets sessions write short notes for the others with `meshingly_journal`; `catchUp` (a number of entries) adds a digest of the latest notes, handoffs and messages to the join and status results, so a session on another computer already knows what happened. Everyone in a collaboration can read its notes, so sessions are told never to write secrets in them. Messages are kept by the relay for seven days; code travels through git, not through Meshingly.
|
|
47
|
+
|
|
48
|
+
## The optional watcher
|
|
49
|
+
|
|
50
|
+
A session that has ended cannot wake itself. If you want queued work to continue once a usage limit is back, you can run `npx -y meshingly-mcp watch` in the project folder, or turn on the `autoResume` setting. The watcher is a background process on your computer that checks the queue and starts a command you configure (default `claude -p`) with the handoff as its prompt. **It is off by default.** Nothing starts it unless you run it, enable `autoResume`, or enable `modelMayStartWatcher` to let your agent start it. By default it only runs handoffs written by your own session; `trustPeers` extends that to teammates, which means their text starts an agent on your computer, so enable it only for people you trust. Handoffs marked as a question for a person are never run.
|
|
51
|
+
|
|
52
|
+
Settings live in your home folder, not in the project (so a repository cannot ship them), and you can change them by asking your agent ("turn on the journal", "let every session know what the others did") or in a terminal: `npx -y meshingly-mcp settings` lists them and `settings set <key> <value>` changes one. The agent changes the harmless ones (journal, catch-up, retry tuning, switching things off) when you ask. The ones that make programs start on your computer (`autoResume`, `resumeCommand`, `trustPeers`, `onQuestion: recommended`, `modelMayStartWatcher`) need your approval: your client asks you directly, outside the model's reach, and if it cannot ask, nothing changes and you are given the terminal command instead.
|
|
35
53
|
|
|
36
54
|
## What it runs, stores and sends
|
|
37
55
|
|
|
38
|
-
|
|
56
|
+
Unless you start the watcher described above, the server runs locally over stdio and starts no other program. Creating a collaboration generates a random key on your machine. It is stored in `.meshingly/connection.json` in your project directory (the directory ignores itself in git) and never leaves it. The only network traffic goes to `https://api.meshingly.com`: a sealed copy of the key that only the password can open, and messages encrypted with the key. Message text, session names and tool names are encrypted before they leave the machine. The relay keeps messages for seven days. Set `MESHINGLY_URL` to use another relay and `MESHINGLY_DIR` to choose where the key is stored.
|
|
39
57
|
|
|
40
58
|
Messages from other sessions are handed to your agent as untrusted input, never as instructions from you.
|
|
41
59
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { resolveSettings } from "./settings.js";
|
|
5
|
+
|
|
6
|
+
const MAX_LOG_BYTES = 1_000_000;
|
|
7
|
+
/** The watcher process writes a heartbeat this often; a pid alone is not proof that the process is still our watcher. */
|
|
8
|
+
export const BEAT_MS = 30_000;
|
|
9
|
+
const BEAT_STALE_MS = 5 * 60_000;
|
|
10
|
+
const START_GRACE_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const dir = (root ) => join(root, ".meshingly");
|
|
26
|
+
const recordFile = (root ) => join(dir(root), "watch.json");
|
|
27
|
+
const beatFile = (root ) => join(dir(root), "watch.beat");
|
|
28
|
+
const logFile = (root ) => join(dir(root), "watch.log");
|
|
29
|
+
const lockFile = (root ) => join(dir(root), "watch.lock");
|
|
30
|
+
export const stateFile = (root ) => join(dir(root), "watch-state.json");
|
|
31
|
+
|
|
32
|
+
/** The handoff a watcher was running when it stopped (killed, crashed): the caller gives it back. */
|
|
33
|
+
export function takeCurrentRun(root ) {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(stateFile(root))) return undefined;
|
|
36
|
+
const raw = JSON.parse(readFileSync(stateFile(root), "utf8")) ;
|
|
37
|
+
if (typeof raw.current !== "string") return undefined;
|
|
38
|
+
const id = raw.current;
|
|
39
|
+
delete raw.current;
|
|
40
|
+
writeFileSync(stateFile(root), JSON.stringify(raw));
|
|
41
|
+
return id;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function writeBeat(root ) {
|
|
48
|
+
try {
|
|
49
|
+
mkdirSync(dir(root), { recursive: true });
|
|
50
|
+
writeFileSync(beatFile(root), String(Date.now()));
|
|
51
|
+
} catch {
|
|
52
|
+
/* a missed beat only makes the watcher look stale for a moment */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function clearBeat(root ) {
|
|
57
|
+
rmSync(beatFile(root), { force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function alive(pid ) {
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
return true;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// EPERM means the process exists but is not ours to signal.
|
|
66
|
+
return (err ).code === "EPERM";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Ends a process and everything it started: with `shell: true` the pid is the shell's, and a plain kill would leave the agent running. */
|
|
71
|
+
export function killTree(pid ) {
|
|
72
|
+
if (process.platform === "win32") {
|
|
73
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Children are started detached (their own process group), so the negative pid reaches the whole group.
|
|
77
|
+
for (const target of [-pid, pid]) {
|
|
78
|
+
try {
|
|
79
|
+
process.kill(target, "SIGTERM");
|
|
80
|
+
return;
|
|
81
|
+
} catch {
|
|
82
|
+
/* try the single process, then give up: it is already gone */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A watcher counts as running only when its pid is alive AND it has a fresh heartbeat (or has only just been started).
|
|
89
|
+
* After a crash or a reboot the pid may belong to an unrelated process; without the heartbeat check Meshingly would
|
|
90
|
+
* refuse to start a new watcher and, worse, stop would kill a stranger.
|
|
91
|
+
*/
|
|
92
|
+
export function watcherState(root ) {
|
|
93
|
+
const file = recordFile(root);
|
|
94
|
+
if (!existsSync(file)) return { running: false, logFile: logFile(root) };
|
|
95
|
+
try {
|
|
96
|
+
const record = JSON.parse(readFileSync(file, "utf8")) ;
|
|
97
|
+
if (!alive(record.pid)) return { running: false, record, logFile: logFile(root) };
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const beat = existsSync(beatFile(root)) ? now - statSync(beatFile(root)).mtimeMs : Infinity;
|
|
100
|
+
const young = now - Date.parse(record.startedAt) < START_GRACE_MS;
|
|
101
|
+
return { running: beat < BEAT_STALE_MS || young, record, logFile: logFile(root) };
|
|
102
|
+
} catch {
|
|
103
|
+
return { running: false, logFile: logFile(root) };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Starts `meshingly-mcp watch` detached, so it keeps running after this session (and its MCP server) ended. */
|
|
108
|
+
export function startWatcher(root , entry ) {
|
|
109
|
+
const state = watcherState(root);
|
|
110
|
+
if (state.running) return `The watcher is already running (pid ${state.record .pid}, since ${state.record .startedAt}). Log: ${state.logFile}`;
|
|
111
|
+
if (!entry || !existsSync(entry)) throw new Error("cannot find the script that starts the watcher");
|
|
112
|
+
|
|
113
|
+
// Two servers starting together (two sessions in one project) must not both start a watcher: one would be orphaned.
|
|
114
|
+
mkdirSync(dir(root), { recursive: true });
|
|
115
|
+
let lock ;
|
|
116
|
+
try {
|
|
117
|
+
lock = openSync(lockFile(root), "wx");
|
|
118
|
+
} catch {
|
|
119
|
+
if (existsSync(lockFile(root)) && Date.now() - statSync(lockFile(root)).mtimeMs < 30_000) return "A watcher is being started right now.";
|
|
120
|
+
rmSync(lockFile(root), { force: true }); // left behind by a start that died
|
|
121
|
+
lock = openSync(lockFile(root), "wx");
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const again = watcherState(root);
|
|
125
|
+
if (again.running) return `The watcher is already running (pid ${again.record .pid}, since ${again.record .startedAt}). Log: ${again.logFile}`;
|
|
126
|
+
return launch(root, entry);
|
|
127
|
+
} finally {
|
|
128
|
+
closeSync(lock);
|
|
129
|
+
rmSync(lockFile(root), { force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function launch(root , entry ) {
|
|
134
|
+
|
|
135
|
+
// The watcher reads the same settings itself; the flags pin what this start saw, so a later edit needs a restart.
|
|
136
|
+
const { resumeCommand: command, trustPeers: fromPeers, onQuestion, maxAttempts, checkIntervalSeconds, settleMinutes } = resolveSettings(root).values;
|
|
137
|
+
mkdirSync(dir(root), { recursive: true });
|
|
138
|
+
writeFileSync(join(dir(root), ".gitignore"), "*\n");
|
|
139
|
+
const log = logFile(root);
|
|
140
|
+
if (existsSync(log) && statSync(log).size > MAX_LOG_BYTES) rmSync(log);
|
|
141
|
+
const fd = openSync(log, "a", 0o600);
|
|
142
|
+
try {
|
|
143
|
+
const args = [
|
|
144
|
+
entry, "watch", "--dir", root, "--cmd", command, "--on-question", onQuestion,
|
|
145
|
+
"--max-attempts", String(maxAttempts), "--interval", String(checkIntervalSeconds), "--settle-minutes", String(settleMinutes),
|
|
146
|
+
...(fromPeers ? ["--from-peers"] : []),
|
|
147
|
+
];
|
|
148
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", fd, fd], windowsHide: true, env: process.env });
|
|
149
|
+
// Without a listener a failed spawn would be an uncaught exception that takes the MCP server down with it.
|
|
150
|
+
child.on("error", (err) => console.error(`meshingly: the watcher process failed: ${err.message}`));
|
|
151
|
+
child.unref();
|
|
152
|
+
if (!child.pid) throw new Error("the watcher process did not start");
|
|
153
|
+
const record = { pid: child.pid, startedAt: new Date().toISOString(), command, fromPeers };
|
|
154
|
+
writeFileSync(recordFile(root), JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
155
|
+
} finally {
|
|
156
|
+
closeSync(fd);
|
|
157
|
+
}
|
|
158
|
+
return [
|
|
159
|
+
`Watcher started. It checks the queue every ${checkIntervalSeconds} s and, once a handoff is ready, runs: ${command}`,
|
|
160
|
+
onQuestion === "recommended" ? "If a run would ask a question it takes the recommended option and notes the assumption." : "If a run would need an answer it parks the question for a person.",
|
|
161
|
+
fromPeers ? "It also runs handoffs written by other sessions (trustPeers)." : "It runs only handoffs written by this project's own session.",
|
|
162
|
+
`It keeps running after this session ends, for as long as this computer is on. Log: ${log}. Stop it with meshingly_watch_stop. Settings: meshingly_settings.`,
|
|
163
|
+
].join("\n");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function stopWatcher(root ) {
|
|
167
|
+
const state = watcherState(root);
|
|
168
|
+
rmSync(recordFile(root), { force: true });
|
|
169
|
+
if (!state.running || !state.record) {
|
|
170
|
+
clearBeat(root);
|
|
171
|
+
return "No watcher is running.";
|
|
172
|
+
}
|
|
173
|
+
const { pid } = state.record;
|
|
174
|
+
killTree(pid);
|
|
175
|
+
clearBeat(root);
|
|
176
|
+
return `Watcher stopped (pid ${pid}).`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function describeWatcher(root ) {
|
|
180
|
+
const s = watcherState(root);
|
|
181
|
+
if (!s.running || !s.record) return "No watcher is running.";
|
|
182
|
+
return `Watcher running (pid ${s.record.pid}, since ${s.record.startedAt}), command: ${s.record.command}${s.record.fromPeers ? ", also for peers' handoffs" : ", own handoffs only"}. Log: ${s.logFile}`;
|
|
183
|
+
}
|