agent-coord-mcp 0.2.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 +64 -2
- package/dist/server.js +3 -1
- package/dist/server.js.map +1 -1
- package/dist/store.js +25 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +244 -43
- package/dist/tools.js.map +1 -1
- package/hooks/tmux-pusher.mjs +273 -0
- package/package.json +2 -1
- package/scripts/spawn-agent.sh +91 -0
- package/scripts/stop-agent.sh +43 -0
- package/src/server.ts +18 -0
- package/src/store.ts +28 -1
- package/src/tools.ts +277 -42
package/src/tools.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { watch } from "node:fs";
|
|
2
|
+
import { existsSync, openSync, watch } from "node:fs";
|
|
3
|
+
import { promises as fsp } from "node:fs";
|
|
4
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { z } from "zod";
|
|
4
7
|
import path from "node:path";
|
|
5
8
|
import {
|
|
6
9
|
AGENTS_FILE,
|
|
10
|
+
CURSOR_DIR,
|
|
7
11
|
INBOX_DIR,
|
|
8
12
|
ROOM_FILE,
|
|
9
13
|
STATUS_FILE,
|
|
@@ -12,10 +16,15 @@ import {
|
|
|
12
16
|
deleteFile,
|
|
13
17
|
fileSize,
|
|
14
18
|
inboxFile,
|
|
19
|
+
listCursorFiles,
|
|
15
20
|
listInboxFiles,
|
|
21
|
+
listTransportFiles,
|
|
22
|
+
logFile,
|
|
23
|
+
pidFile,
|
|
16
24
|
readJson,
|
|
17
25
|
readJsonl,
|
|
18
26
|
rewriteJsonl,
|
|
27
|
+
transportFile,
|
|
19
28
|
updateJson,
|
|
20
29
|
} from "./store.js";
|
|
21
30
|
|
|
@@ -25,6 +34,15 @@ type AgentEntry = {
|
|
|
25
34
|
role?: string;
|
|
26
35
|
registeredAt: number;
|
|
27
36
|
lastHeartbeat: number;
|
|
37
|
+
capabilities?: string[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type TransportMarker = {
|
|
41
|
+
agentId: string;
|
|
42
|
+
transport: string;
|
|
43
|
+
pid: number;
|
|
44
|
+
tmuxTarget?: string;
|
|
45
|
+
since: number;
|
|
28
46
|
};
|
|
29
47
|
|
|
30
48
|
type AgentRegistry = Record<string, AgentEntry>;
|
|
@@ -114,14 +132,57 @@ export async function listAgentsTool() {
|
|
|
114
132
|
}
|
|
115
133
|
return current;
|
|
116
134
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
|
|
136
|
+
// Merge in live transport markers (e.g. tmux-push from the pusher daemon).
|
|
137
|
+
// A marker counts only if its pid is still alive; stale markers are pruned.
|
|
138
|
+
const liveTransports = await loadLiveTransports();
|
|
139
|
+
|
|
140
|
+
const agents = Object.values(reg).map((a) => {
|
|
141
|
+
const transport = liveTransports.get(a.agentId);
|
|
142
|
+
const merged = [...(a.capabilities ?? [])];
|
|
143
|
+
if (transport && !merged.includes(transport.transport)) merged.push(transport.transport);
|
|
144
|
+
return {
|
|
145
|
+
...a,
|
|
146
|
+
online: now - a.lastHeartbeat < STALE_MS,
|
|
147
|
+
secondsSinceHeartbeat: Math.floor((now - a.lastHeartbeat) / 1000),
|
|
148
|
+
capabilities: merged.length > 0 ? merged : undefined,
|
|
149
|
+
transport: transport
|
|
150
|
+
? { kind: transport.transport, tmuxTarget: transport.tmuxTarget, pid: transport.pid }
|
|
151
|
+
: undefined,
|
|
152
|
+
};
|
|
153
|
+
});
|
|
122
154
|
return { agents, evicted };
|
|
123
155
|
}
|
|
124
156
|
|
|
157
|
+
async function loadLiveTransports(): Promise<Map<string, TransportMarker>> {
|
|
158
|
+
const out = new Map<string, TransportMarker>();
|
|
159
|
+
for (const fname of await listTransportFiles()) {
|
|
160
|
+
const file = path.join(path.dirname(transportFile("x")), fname);
|
|
161
|
+
const marker = await readJson<TransportMarker | null>(file, null);
|
|
162
|
+
if (!marker) {
|
|
163
|
+
await deleteFile(file);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (!isPidAlive(marker.pid)) {
|
|
167
|
+
await deleteFile(file);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
out.set(marker.agentId, marker);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isPidAlive(pid: number): boolean {
|
|
176
|
+
if (!pid || pid <= 0) return false;
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
182
|
+
return code === "EPERM"; // EPERM = exists but not ours; ESRCH = gone
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
125
186
|
// ---------- send_message ----------
|
|
126
187
|
|
|
127
188
|
export const sendMessageSchema = {
|
|
@@ -193,7 +254,19 @@ export async function readMessagesTool(args: {
|
|
|
193
254
|
});
|
|
194
255
|
}
|
|
195
256
|
|
|
196
|
-
|
|
257
|
+
// Drop the agent's own posts on shared channels — reading your own broadcast
|
|
258
|
+
// back is never useful and confuses turn-based agents into self-replies.
|
|
259
|
+
// Cursor has already advanced past them, so they won't reappear.
|
|
260
|
+
const visible =
|
|
261
|
+
args.source === "room" || args.source === "status"
|
|
262
|
+
? limited.filter((e) => entryAuthor(e) !== args.agentId)
|
|
263
|
+
: limited;
|
|
264
|
+
|
|
265
|
+
return { messages: visible, totalNew, returned: visible.length };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function entryAuthor(e: Message | StatusEntry): string | undefined {
|
|
269
|
+
return "from" in e ? e.from : e.agentId;
|
|
197
270
|
}
|
|
198
271
|
|
|
199
272
|
// ---------- post_status ----------
|
|
@@ -229,46 +302,57 @@ export async function waitForMessageTool(args: {
|
|
|
229
302
|
source: "inbox" | "room" | "status";
|
|
230
303
|
timeoutMs?: number;
|
|
231
304
|
}) {
|
|
232
|
-
const
|
|
305
|
+
const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
|
|
233
306
|
const file = sourceFile(args.source, args.agentId);
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
307
|
+
const deadline = Date.now() + totalTimeout;
|
|
308
|
+
|
|
309
|
+
// Loop so that file growth caused only by the agent's own self-posts (which
|
|
310
|
+
// readMessagesTool now filters out for room/status) doesn't return an empty
|
|
311
|
+
// result — keep waiting until we have something to deliver or time out.
|
|
312
|
+
while (Date.now() < deadline) {
|
|
313
|
+
const startSize = await fileSize(file);
|
|
314
|
+
const remaining = deadline - Date.now();
|
|
315
|
+
if (remaining <= 0) break;
|
|
316
|
+
|
|
317
|
+
const changed = await new Promise<boolean>((resolve) => {
|
|
318
|
+
let settled = false;
|
|
319
|
+
const finish = (v: boolean) => {
|
|
320
|
+
if (settled) return;
|
|
321
|
+
settled = true;
|
|
322
|
+
clearInterval(poll);
|
|
323
|
+
try {
|
|
324
|
+
watcher?.close();
|
|
325
|
+
} catch {
|
|
326
|
+
// ignore
|
|
327
|
+
}
|
|
328
|
+
clearTimeout(t);
|
|
329
|
+
resolve(v);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const check = async () => {
|
|
333
|
+
const sz = await fileSize(file);
|
|
334
|
+
if (sz > startSize) finish(true);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
let watcher: ReturnType<typeof watch> | undefined;
|
|
242
338
|
try {
|
|
243
|
-
watcher
|
|
339
|
+
watcher = watch(file, () => {
|
|
340
|
+
void check();
|
|
341
|
+
});
|
|
244
342
|
} catch {
|
|
245
|
-
//
|
|
343
|
+
// file may not exist; polling will handle
|
|
246
344
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const check = async () => {
|
|
252
|
-
const sz = await fileSize(file);
|
|
253
|
-
if (sz > startSize) finish(true);
|
|
254
|
-
};
|
|
255
|
-
|
|
256
|
-
let watcher: ReturnType<typeof watch> | undefined;
|
|
257
|
-
try {
|
|
258
|
-
watcher = watch(file, () => {
|
|
259
|
-
void check();
|
|
260
|
-
});
|
|
261
|
-
} catch {
|
|
262
|
-
// file may not exist; polling will handle
|
|
263
|
-
}
|
|
264
|
-
const poll = setInterval(() => void check(), 500);
|
|
265
|
-
const t = setTimeout(() => finish(false), timeout);
|
|
266
|
-
});
|
|
345
|
+
const poll = setInterval(() => void check(), 500);
|
|
346
|
+
const t = setTimeout(() => finish(false), remaining);
|
|
347
|
+
});
|
|
267
348
|
|
|
268
|
-
|
|
269
|
-
|
|
349
|
+
if (!changed) break;
|
|
350
|
+
const result = await readMessagesTool({ agentId: args.agentId, source: args.source });
|
|
351
|
+
if (result.returned > 0) return result;
|
|
352
|
+
// otherwise, only self-posts arrived; keep waiting on the remaining budget
|
|
270
353
|
}
|
|
271
|
-
|
|
354
|
+
|
|
355
|
+
return { ok: false, timedOut: true };
|
|
272
356
|
}
|
|
273
357
|
|
|
274
358
|
// ---------- prune ----------
|
|
@@ -322,6 +406,9 @@ export async function pruneTool(args: {
|
|
|
322
406
|
const inboxFiles = await listInboxFiles();
|
|
323
407
|
let inboxRemoved = 0;
|
|
324
408
|
const deletedOrphans: string[] = [];
|
|
409
|
+
// perAgentInboxRemoved: how many entries were stripped from each *kept* agent's
|
|
410
|
+
// inbox, so we can shift only that agent's inboxOffset cursor below.
|
|
411
|
+
const perAgentInboxRemoved: Record<string, number> = {};
|
|
325
412
|
for (const fname of inboxFiles) {
|
|
326
413
|
const id = fname.replace(/\.jsonl$/, "");
|
|
327
414
|
const filePath = path.join(INBOX_DIR, fname);
|
|
@@ -334,6 +421,35 @@ export async function pruneTool(args: {
|
|
|
334
421
|
}
|
|
335
422
|
const r = await rewriteJsonl<Message>(filePath, (e) => e.ts > cutoff);
|
|
336
423
|
inboxRemoved += r.removed;
|
|
424
|
+
perAgentInboxRemoved[id] = r.removed;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Cursor adjustment: appendJsonl is time-ordered and rewriteJsonl removes
|
|
428
|
+
// the oldest entries (ts <= cutoff). Any cursor offset shifts down by the
|
|
429
|
+
// removed count, clamped at 0. Without this, an offset past the new file
|
|
430
|
+
// length would make read_messages return [] forever.
|
|
431
|
+
const cursorsAdjusted: string[] = [];
|
|
432
|
+
for (const cname of await listCursorFiles()) {
|
|
433
|
+
const id = cname.replace(/\.json$/, "");
|
|
434
|
+
const cursorPath = path.join(CURSOR_DIR, cname);
|
|
435
|
+
let touched = false;
|
|
436
|
+
await updateJson<Cursor>(cursorPath, {}, (current) => {
|
|
437
|
+
if (current.roomOffset !== undefined && roomResult.removed > 0) {
|
|
438
|
+
current.roomOffset = Math.max(0, current.roomOffset - roomResult.removed);
|
|
439
|
+
touched = true;
|
|
440
|
+
}
|
|
441
|
+
if (current.statusOffset !== undefined && statusResult.removed > 0) {
|
|
442
|
+
current.statusOffset = Math.max(0, current.statusOffset - statusResult.removed);
|
|
443
|
+
touched = true;
|
|
444
|
+
}
|
|
445
|
+
const myInboxRemoved = perAgentInboxRemoved[id] ?? 0;
|
|
446
|
+
if (current.inboxOffset !== undefined && myInboxRemoved > 0) {
|
|
447
|
+
current.inboxOffset = Math.max(0, current.inboxOffset - myInboxRemoved);
|
|
448
|
+
touched = true;
|
|
449
|
+
}
|
|
450
|
+
return current;
|
|
451
|
+
});
|
|
452
|
+
if (touched) cursorsAdjusted.push(id);
|
|
337
453
|
}
|
|
338
454
|
|
|
339
455
|
return {
|
|
@@ -346,10 +462,129 @@ export async function pruneTool(args: {
|
|
|
346
462
|
inboxMessages: inboxRemoved,
|
|
347
463
|
orphanInboxes: deletedOrphans,
|
|
348
464
|
},
|
|
349
|
-
|
|
465
|
+
cursorsAdjusted,
|
|
350
466
|
};
|
|
351
467
|
}
|
|
352
468
|
|
|
469
|
+
// ---------- attach_agent / detach_agent (tmux push transport) ----------
|
|
470
|
+
|
|
471
|
+
export const attachAgentSchema = {
|
|
472
|
+
agentId: z.string().min(1),
|
|
473
|
+
tmuxTarget: z.string().optional(),
|
|
474
|
+
includeRoom: z.boolean().optional(),
|
|
475
|
+
allowlist: z.array(z.string()).optional(),
|
|
476
|
+
debounceMs: z.number().int().positive().max(60_000).optional(),
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
export async function attachAgentTool(args: {
|
|
480
|
+
agentId: string;
|
|
481
|
+
tmuxTarget?: string;
|
|
482
|
+
includeRoom?: boolean;
|
|
483
|
+
allowlist?: string[];
|
|
484
|
+
debounceMs?: number;
|
|
485
|
+
}) {
|
|
486
|
+
// Resolve target: explicit arg > MCP server's own TMUX_PANE env.
|
|
487
|
+
const target = args.tmuxTarget ?? process.env.TMUX_PANE;
|
|
488
|
+
if (!target) {
|
|
489
|
+
return {
|
|
490
|
+
ok: false,
|
|
491
|
+
error:
|
|
492
|
+
"tmuxTarget not provided and the MCP server is not running inside tmux (no $TMUX_PANE). Pass tmuxTarget explicitly (e.g. '%42' or 'session:window.pane').",
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Validate target exists.
|
|
497
|
+
const probe = spawnSync("tmux", ["display-message", "-p", "-t", target, "ok"]);
|
|
498
|
+
if (probe.status !== 0) {
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
error: `tmux target '${target}' not found: ${(probe.stderr ?? "").toString().trim()}`,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// If something's already attached, refuse rather than spawn a second pusher.
|
|
506
|
+
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
507
|
+
if (existing && isPidAlive(existing.pid)) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
error: `agent '${args.agentId}' already has a live ${existing.transport} attached (pid ${existing.pid}). Call detach_agent first.`,
|
|
511
|
+
existing,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
// Clean up dead marker, if any.
|
|
515
|
+
if (existing) await deleteFile(transportFile(args.agentId));
|
|
516
|
+
|
|
517
|
+
const pusher = resolvePusherPath();
|
|
518
|
+
if (!existsSync(pusher)) {
|
|
519
|
+
return { ok: false, error: `tmux-pusher not found at ${pusher}` };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Detached spawn so the pusher outlives this MCP request/process.
|
|
523
|
+
const log = logFile(args.agentId, "pusher");
|
|
524
|
+
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
525
|
+
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
526
|
+
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
527
|
+
const out = openSync(log, "a");
|
|
528
|
+
const err = openSync(log, "a");
|
|
529
|
+
const child = spawn("node", [pusher], {
|
|
530
|
+
detached: true,
|
|
531
|
+
stdio: ["ignore", out, err],
|
|
532
|
+
env: {
|
|
533
|
+
...process.env,
|
|
534
|
+
AGENT_COORD_ID: args.agentId,
|
|
535
|
+
AGENT_COORD_TMUX_TARGET: target,
|
|
536
|
+
...(args.includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
|
|
537
|
+
...(args.allowlist && args.allowlist.length > 0
|
|
538
|
+
? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
|
|
539
|
+
: {}),
|
|
540
|
+
...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
child.unref();
|
|
544
|
+
const pid = child.pid;
|
|
545
|
+
if (!pid) return { ok: false, error: "spawn returned no pid" };
|
|
546
|
+
|
|
547
|
+
// Write pid file (for scripts) and transport marker (for list_agents).
|
|
548
|
+
await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
|
|
549
|
+
const marker: TransportMarker = {
|
|
550
|
+
agentId: args.agentId,
|
|
551
|
+
transport: "tmux-push",
|
|
552
|
+
pid,
|
|
553
|
+
tmuxTarget: target,
|
|
554
|
+
since: Date.now(),
|
|
555
|
+
};
|
|
556
|
+
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
557
|
+
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
558
|
+
|
|
559
|
+
return { ok: true, agentId: args.agentId, transport: "tmux-push", tmuxTarget: target, pid, log };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export const detachAgentSchema = {
|
|
563
|
+
agentId: z.string().min(1),
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
export async function detachAgentTool(args: { agentId: string }) {
|
|
567
|
+
const marker = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
568
|
+
let killed = false;
|
|
569
|
+
if (marker && isPidAlive(marker.pid)) {
|
|
570
|
+
try {
|
|
571
|
+
process.kill(marker.pid, "SIGTERM");
|
|
572
|
+
killed = true;
|
|
573
|
+
} catch {
|
|
574
|
+
// already gone
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await deleteFile(transportFile(args.agentId));
|
|
578
|
+
await deleteFile(pidFile(args.agentId, "pusher"));
|
|
579
|
+
return { ok: true, agentId: args.agentId, killed, hadMarker: marker !== null };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function resolvePusherPath(): string {
|
|
583
|
+
// tools.js (compiled) lives in dist/; pusher lives in hooks/ at repo root.
|
|
584
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
585
|
+
return path.resolve(here, "..", "hooks", "tmux-pusher.mjs");
|
|
586
|
+
}
|
|
587
|
+
|
|
353
588
|
// ---------- helpers ----------
|
|
354
589
|
|
|
355
590
|
function sourceFile(source: "inbox" | "room" | "status", agentId: string): string {
|