agent-coord-mcp 0.5.2 → 0.7.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.
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * coord-pusher — remote-machine counterpart to hooks/tmux-pusher.mjs.
4
+ *
5
+ * Where tmux-pusher reads the bus directly from the local filesystem,
6
+ * coord-pusher consumes it over MCP (Streamable HTTP) from a server running
7
+ * on another machine, then pastes incoming peer messages into the local tmux
8
+ * pane. This is what makes "wake an idle agent" work cross-machine.
9
+ *
10
+ * Usage:
11
+ * coord-pusher --server <url> --token <t> --agent <id> --tmux <pane>
12
+ * [--no-room] [--allowlist a,b]
13
+ * [--debounce-ms 1000] [--refresh-ms 30000]
14
+ *
15
+ * Environment fallbacks (used if a flag is omitted):
16
+ * AGENT_COORD_SERVER server URL (e.g. http://host:8765/mcp)
17
+ * AGENT_COORD_TOKEN bearer token
18
+ * AGENT_COORD_ID agentId
19
+ * AGENT_COORD_TMUX_TARGET tmux target pane (e.g. coord-frontend:agent.0)
20
+ *
21
+ * Safety mirrors tmux-pusher:
22
+ * - drops messages where from === agentId (no self-echo)
23
+ * - drops messages whose text starts with "/" (avoid injected slash commands)
24
+ * - if allowlist set, drops messages from peers not in it
25
+ * - single-flight tmux send so two batches never overlap
26
+ *
27
+ * Liveness: heartbeats the server every 60s. The server treats the remote
28
+ * transport marker as live while heartbeats stay fresh; without them, the
29
+ * marker is garbage-collected (see loadLiveTransports in src/tools.ts).
30
+ */
31
+
32
+ import { hostname } from "node:os";
33
+ import { spawn, spawnSync } from "node:child_process";
34
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
35
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
36
+
37
+ // ---------- args + env ----------
38
+
39
+ const argv = parseArgs(process.argv.slice(2));
40
+ const SERVER = argv.server ?? process.env.AGENT_COORD_SERVER;
41
+ const TOKEN = argv.token ?? process.env.AGENT_COORD_TOKEN;
42
+ const AGENT_ID = argv.agent ?? process.env.AGENT_COORD_ID;
43
+ const TMUX_TARGET = argv.tmux ?? process.env.AGENT_COORD_TMUX_TARGET;
44
+ const INCLUDE_ROOM = argv["no-room"] ? false : true;
45
+ const ALLOWLIST = (argv.allowlist ?? "").split(",").map((s) => s.trim()).filter(Boolean);
46
+ const DEBOUNCE_MS = parseInt(argv["debounce-ms"] ?? "1000", 10);
47
+ const REFRESH_MS = parseInt(argv["refresh-ms"] ?? "30000", 10);
48
+
49
+ if (!SERVER) die("--server (or AGENT_COORD_SERVER) is required");
50
+ if (!TOKEN) die("--token (or AGENT_COORD_TOKEN) is required");
51
+ if (!AGENT_ID) die("--agent (or AGENT_COORD_ID) is required");
52
+ if (!TMUX_TARGET) die("--tmux (or AGENT_COORD_TMUX_TARGET) is required");
53
+
54
+ const SAFE_ID = AGENT_ID.replace(/[^a-zA-Z0-9._-]/g, "_");
55
+ const BUFFER_NAME = `coord-${SAFE_ID}`;
56
+
57
+ // Verify the tmux target exists up front — same probe tmux-pusher uses, so
58
+ // we fail loudly instead of silently dropping messages later.
59
+ const probe = spawnSync("tmux", ["display-message", "-p", "-t", TMUX_TARGET, "ok"]);
60
+ if (probe.status !== 0) {
61
+ die(`tmux target '${TMUX_TARGET}' not found: ${(probe.stderr ?? "").toString().trim()}`);
62
+ }
63
+
64
+ // ---------- MCP client ----------
65
+
66
+ const client = new Client({ name: "coord-pusher", version: "0.6.0" }, { capabilities: {} });
67
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
68
+ requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
69
+ });
70
+
71
+ try {
72
+ await client.connect(transport);
73
+ } catch (e) {
74
+ die(`failed to connect to ${SERVER}: ${e?.message ?? e}`);
75
+ }
76
+
77
+ // Call a tool and JSON-parse the wrapped text content. The server's tool
78
+ // handlers return { content: [{ type:"text", text: JSON.stringify(payload) }] }
79
+ // (see jsonResult in src/server.ts), so we unwrap exactly once.
80
+ async function call(name, args) {
81
+ // SDK zod schemas reject arguments:undefined; send {} for parameter-less tools.
82
+ const r = await client.callTool({ name, arguments: args ?? {} });
83
+ const text = r?.content?.[0]?.text;
84
+ if (typeof text !== "string") return r;
85
+ try { return JSON.parse(text); } catch { return text; }
86
+ }
87
+
88
+ // Register (idempotent), then publish the transport marker so list_agents
89
+ // shows us attached. Marker liveness is heartbeat-based server-side.
90
+ await call("register", { agentId: AGENT_ID });
91
+ await call("report_transport", {
92
+ agentId: AGENT_ID,
93
+ transport: "tmux-push-remote",
94
+ host: hostname(),
95
+ tmuxTarget: TMUX_TARGET,
96
+ since: Date.now(),
97
+ });
98
+ process.stderr.write(
99
+ `[coord-pusher] attached agent='${AGENT_ID}' tmux=${TMUX_TARGET} server=${SERVER} (room=${INCLUDE_ROOM ? "on" : "off"})\n`,
100
+ );
101
+
102
+ // Heartbeat keeps the registry's lastHeartbeat fresh so loadLiveTransports
103
+ // doesn't GC our marker. 60s is well under the 5min staleness window.
104
+ const hbTimer = setInterval(() => { call("heartbeat", { agentId: AGENT_ID }).catch(() => {}); }, 60_000);
105
+
106
+ // ---------- tmux inject pipeline (mirrors hooks/tmux-pusher.mjs) ----------
107
+
108
+ let pending = [];
109
+ let debounceTimer = null;
110
+ let sending = false;
111
+
112
+ function shouldInject(m) {
113
+ if (!m || m.from === AGENT_ID) return false;
114
+ if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
115
+ if (typeof m.text === "string" && m.text.trimStart().startsWith("/")) return false;
116
+ return true;
117
+ }
118
+
119
+ function scheduleFlush() {
120
+ if (debounceTimer) return;
121
+ debounceTimer = setTimeout(flush, DEBOUNCE_MS);
122
+ }
123
+
124
+ async function flush() {
125
+ debounceTimer = null;
126
+ if (sending) { scheduleFlush(); return; }
127
+ if (pending.length === 0) return;
128
+ const batch = pending;
129
+ pending = [];
130
+ sending = true;
131
+ try {
132
+ await injectViaTmux(batch);
133
+ } catch (e) {
134
+ process.stderr.write(`[coord-pusher] inject failed: ${e?.message ?? e}\n`);
135
+ pending = [...batch, ...pending];
136
+ scheduleFlush();
137
+ } finally {
138
+ sending = false;
139
+ }
140
+ }
141
+
142
+ function formatBatch(batch) {
143
+ const lines = [
144
+ "[agent-coord] incoming peer messages — already consumed from your inbox, do not call read_messages for them:",
145
+ ];
146
+ for (const m of batch) {
147
+ const ts = new Date(m.ts ?? Date.now()).toISOString();
148
+ lines.push(` [${m.kind} ${ts} from=${m.from}] ${m.text ?? ""}`);
149
+ }
150
+ return lines.join("\n");
151
+ }
152
+
153
+ function injectViaTmux(batch) {
154
+ return new Promise((resolve, reject) => {
155
+ const payload = formatBatch(batch);
156
+ const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
157
+ load.on("error", reject);
158
+ load.on("exit", (code) => {
159
+ if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
160
+ const paste = spawnSync("tmux", ["paste-buffer", "-b", BUFFER_NAME, "-t", TMUX_TARGET, "-d"]);
161
+ if (paste.status !== 0) return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
162
+ const enter = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
163
+ if (enter.status !== 0) return reject(new Error(`tmux send-keys: ${(enter.stderr ?? "").toString().trim()}`));
164
+ resolve();
165
+ });
166
+ load.stdin.end(payload);
167
+ });
168
+ }
169
+
170
+ // ---------- per-source wait loops + subscription refresh ----------
171
+
172
+ // One loop per source: inbox (always) plus one per joined channel. Each loop
173
+ // long-polls wait_for_message — the server already filters self-posts and
174
+ // advances the cursor on returned batches, so we don't need a separate
175
+ // read_messages call. A `cancelled` flag is checked between waits so that
176
+ // channels we've left stop tailing on their own (within one wait window).
177
+ const loops = new Map(); // key → { cancelled }
178
+
179
+ function loopKey(source, room) { return source === "inbox" ? "inbox" : `room:${normalizeRoom(room)}`; }
180
+
181
+ function startLoop(source, room) {
182
+ const key = loopKey(source, room);
183
+ if (loops.has(key)) return;
184
+ const state = { cancelled: false };
185
+ loops.set(key, state);
186
+ const tag = source === "inbox" ? "DM" : `room #${normalizeRoom(room)}`;
187
+ (async () => {
188
+ while (!state.cancelled) {
189
+ let r;
190
+ try {
191
+ r = await call("wait_for_message", { agentId: AGENT_ID, source, room, timeoutMs: 60_000 });
192
+ } catch (e) {
193
+ // Transport hiccup — back off briefly so we don't spin against a dead server.
194
+ process.stderr.write(`[coord-pusher] wait_for_message(${tag}) error: ${e?.message ?? e}\n`);
195
+ await sleep(2_000);
196
+ continue;
197
+ }
198
+ if (state.cancelled) break;
199
+ const msgs = Array.isArray(r?.messages) ? r.messages : [];
200
+ for (const m of msgs) {
201
+ if (shouldInject(m)) pending.push({ kind: tag, ...m });
202
+ }
203
+ if (pending.length > 0) scheduleFlush();
204
+ }
205
+ loops.delete(key);
206
+ })();
207
+ }
208
+
209
+ function stopLoop(source, room) {
210
+ const key = loopKey(source, room);
211
+ const s = loops.get(key);
212
+ if (s) s.cancelled = true;
213
+ }
214
+
215
+ function normalizeRoom(name) {
216
+ if (!name) return "general";
217
+ const n = String(name).trim().replace(/^#+/, "").toLowerCase().replace(/[^a-z0-9._-]/g, "");
218
+ return n || "general";
219
+ }
220
+
221
+ // Always tail the inbox.
222
+ startLoop("inbox");
223
+
224
+ // Periodically reconcile the set of joined channels — `list_rooms` returns
225
+ // each channel's members, so we tail the channels containing our agentId.
226
+ // New /join_room from the agent's Claude session shows up on the next refresh.
227
+ async function refreshSubscriptions() {
228
+ if (!INCLUDE_ROOM) return;
229
+ let rooms;
230
+ try { rooms = (await call("list_rooms"))?.rooms ?? []; } catch (e) {
231
+ process.stderr.write(`[coord-pusher] list_rooms error: ${e?.message ?? e}\n`);
232
+ return;
233
+ }
234
+ const desired = new Set(["general"]);
235
+ for (const r of rooms) if (Array.isArray(r.members) && r.members.includes(AGENT_ID)) desired.add(r.room);
236
+ // Start loops for newly-joined channels.
237
+ for (const chan of desired) startLoop("room", chan);
238
+ // Cancel loops for channels we've left (but never general).
239
+ for (const key of loops.keys()) {
240
+ if (!key.startsWith("room:")) continue;
241
+ const chan = key.slice("room:".length);
242
+ if (chan === "general") continue;
243
+ if (!desired.has(chan)) stopLoop("room", chan);
244
+ }
245
+ }
246
+ await refreshSubscriptions();
247
+ const refreshTimer = setInterval(() => { refreshSubscriptions().catch(() => {}); }, REFRESH_MS);
248
+
249
+ // ---------- shutdown ----------
250
+
251
+ let shuttingDown = false;
252
+ async function shutdown(signal) {
253
+ if (shuttingDown) return;
254
+ shuttingDown = true;
255
+ process.stderr.write(`[coord-pusher] ${signal} → clearing transport marker + closing\n`);
256
+ clearInterval(hbTimer);
257
+ clearInterval(refreshTimer);
258
+ for (const s of loops.values()) s.cancelled = true;
259
+ try { await call("clear_transport", { agentId: AGENT_ID }); } catch {}
260
+ try { await client.close(); } catch {}
261
+ process.exit(0);
262
+ }
263
+ process.on("SIGINT", () => void shutdown("SIGINT"));
264
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
265
+
266
+ // ---------- helpers ----------
267
+
268
+ function parseArgs(argv) {
269
+ const out = {};
270
+ for (let i = 0; i < argv.length; i++) {
271
+ const a = argv[i];
272
+ if (!a.startsWith("--")) continue;
273
+ const name = a.slice(2);
274
+ if (name === "no-room") { out["no-room"] = true; continue; }
275
+ const next = argv[i + 1];
276
+ if (next === undefined || next.startsWith("--")) { out[name] = true; continue; }
277
+ out[name] = next;
278
+ i++;
279
+ }
280
+ return out;
281
+ }
282
+
283
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
284
+
285
+ function die(msg) {
286
+ process.stderr.write(`[coord-pusher] ${msg}\n`);
287
+ process.exit(1);
288
+ }