agent-coord-mcp 0.2.1 → 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/src/tools.ts CHANGED
@@ -1,5 +1,8 @@
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 {
@@ -15,9 +18,13 @@ import {
15
18
  inboxFile,
16
19
  listCursorFiles,
17
20
  listInboxFiles,
21
+ listTransportFiles,
22
+ logFile,
23
+ pidFile,
18
24
  readJson,
19
25
  readJsonl,
20
26
  rewriteJsonl,
27
+ transportFile,
21
28
  updateJson,
22
29
  } from "./store.js";
23
30
 
@@ -27,6 +34,15 @@ type AgentEntry = {
27
34
  role?: string;
28
35
  registeredAt: number;
29
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;
30
46
  };
31
47
 
32
48
  type AgentRegistry = Record<string, AgentEntry>;
@@ -116,14 +132,57 @@ export async function listAgentsTool() {
116
132
  }
117
133
  return current;
118
134
  });
119
- const agents = Object.values(reg).map((a) => ({
120
- ...a,
121
- online: now - a.lastHeartbeat < STALE_MS,
122
- secondsSinceHeartbeat: Math.floor((now - a.lastHeartbeat) / 1000),
123
- }));
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
+ });
124
154
  return { agents, evicted };
125
155
  }
126
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
+
127
186
  // ---------- send_message ----------
128
187
 
129
188
  export const sendMessageSchema = {
@@ -195,7 +254,19 @@ export async function readMessagesTool(args: {
195
254
  });
196
255
  }
197
256
 
198
- return { messages: limited, totalNew, returned: limited.length };
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;
199
270
  }
200
271
 
201
272
  // ---------- post_status ----------
@@ -231,46 +302,57 @@ export async function waitForMessageTool(args: {
231
302
  source: "inbox" | "room" | "status";
232
303
  timeoutMs?: number;
233
304
  }) {
234
- const timeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
305
+ const totalTimeout = Math.min(args.timeoutMs ?? 30_000, MAX_WAIT_MS);
235
306
  const file = sourceFile(args.source, args.agentId);
236
- const startSize = await fileSize(file);
237
-
238
- const result = await new Promise<{ changed: boolean }>((resolve) => {
239
- let settled = false;
240
- const finish = (changed: boolean) => {
241
- if (settled) return;
242
- settled = true;
243
- clearInterval(poll);
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;
244
338
  try {
245
- watcher?.close();
339
+ watcher = watch(file, () => {
340
+ void check();
341
+ });
246
342
  } catch {
247
- // ignore
343
+ // file may not exist; polling will handle
248
344
  }
249
- clearTimeout(t);
250
- resolve({ changed });
251
- };
252
-
253
- const check = async () => {
254
- const sz = await fileSize(file);
255
- if (sz > startSize) finish(true);
256
- };
257
-
258
- let watcher: ReturnType<typeof watch> | undefined;
259
- try {
260
- watcher = watch(file, () => {
261
- void check();
262
- });
263
- } catch {
264
- // file may not exist; polling will handle
265
- }
266
- const poll = setInterval(() => void check(), 500);
267
- const t = setTimeout(() => finish(false), timeout);
268
- });
345
+ const poll = setInterval(() => void check(), 500);
346
+ const t = setTimeout(() => finish(false), remaining);
347
+ });
269
348
 
270
- if (!result.changed) {
271
- return { ok: false, timedOut: true };
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
272
353
  }
273
- return readMessagesTool({ agentId: args.agentId, source: args.source });
354
+
355
+ return { ok: false, timedOut: true };
274
356
  }
275
357
 
276
358
  // ---------- prune ----------
@@ -384,6 +466,125 @@ export async function pruneTool(args: {
384
466
  };
385
467
  }
386
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
+
387
588
  // ---------- helpers ----------
388
589
 
389
590
  function sourceFile(source: "inbox" | "room" | "status", agentId: string): string {