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.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tmux-pusher.mjs
4
+ *
5
+ * Long-running daemon that watches an agent's coord inbox (and optionally the
6
+ * shared room) and types new messages into a tmux pane running an interactive
7
+ * CLI agent. Works with any line-driven agent CLI: Claude Code, Aider, codex,
8
+ * gemini-cli, opencode, etc.
9
+ *
10
+ * Required env:
11
+ * AGENT_COORD_ID agentId registered with the MCP
12
+ * AGENT_COORD_TMUX_TARGET tmux target, e.g. "coord-frontend:agent.0"
13
+ *
14
+ * Optional env:
15
+ * AGENT_COORD_DIR override state dir (default ~/agent-coord)
16
+ * AGENT_COORD_INCLUDE_ROOM "1" to also inject shared-room messages
17
+ * AGENT_COORD_ALLOWLIST comma-separated peer agentIds to accept
18
+ * (default: accept all)
19
+ * AGENT_COORD_DEBOUNCE_MS coalesce window for bursts (default 1000)
20
+ * AGENT_COORD_POLL_MS fallback poll interval (default 1000)
21
+ *
22
+ * Safety:
23
+ * - drops messages where from === AGENT_COORD_ID (no self-echo)
24
+ * - drops messages whose text starts with "/" (avoid injected slash commands)
25
+ * - if allowlist set, drops messages from peers not in it
26
+ * - serializes tmux sends so two batches never overlap
27
+ *
28
+ * Cursor: shares ~/agent-coord/cursors/<id>.json with the MCP server, so the
29
+ * agent calling read_messages won't see anything the pusher already delivered.
30
+ *
31
+ * Do NOT enable peek-coord.mjs Stop/UserPromptSubmit hooks for the same agent
32
+ * while this daemon runs — both consume the same cursor and would race.
33
+ *
34
+ * Caveats:
35
+ * - Pasting types into whatever pane state exists. If you're mid-typing in
36
+ * the same pane, your buffer gets corrupted. Run the receiving agent in a
37
+ * dedicated pane you don't normally edit in.
38
+ * - The pusher can't tell if the agent is idle, mid-tool, or showing a
39
+ * permission prompt. send-keys is unconditional.
40
+ */
41
+
42
+ import {
43
+ existsSync,
44
+ readFileSync,
45
+ writeFileSync,
46
+ renameSync,
47
+ mkdirSync,
48
+ unlinkSync,
49
+ watch,
50
+ } from "node:fs";
51
+ import { homedir } from "node:os";
52
+ import path from "node:path";
53
+ import { spawn, spawnSync } from "node:child_process";
54
+
55
+ const AGENT_ID = process.env.AGENT_COORD_ID;
56
+ const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
57
+ if (!AGENT_ID) die("AGENT_COORD_ID is required");
58
+ if (!TMUX_TARGET) die("AGENT_COORD_TMUX_TARGET is required");
59
+
60
+ const ROOT = process.env.AGENT_COORD_DIR || path.join(homedir(), "agent-coord");
61
+ const INCLUDE_ROOM = process.env.AGENT_COORD_INCLUDE_ROOM === "1";
62
+ const ALLOWLIST = (process.env.AGENT_COORD_ALLOWLIST || "")
63
+ .split(",")
64
+ .map((s) => s.trim())
65
+ .filter(Boolean);
66
+ const DEBOUNCE_MS = parseInt(process.env.AGENT_COORD_DEBOUNCE_MS || "1000", 10);
67
+ const POLL_MS = parseInt(process.env.AGENT_COORD_POLL_MS || "1000", 10);
68
+
69
+ const SAFE_ID = AGENT_ID.replace(/[^a-zA-Z0-9._-]/g, "_");
70
+ const INBOX_FILE = path.join(ROOT, "inbox", `${SAFE_ID}.jsonl`);
71
+ const ROOM_FILE = path.join(ROOT, "room.jsonl");
72
+ const CURSOR_FILE = path.join(ROOT, "cursors", `${SAFE_ID}.json`);
73
+ const TRANSPORT_FILE = path.join(ROOT, "transports", `${SAFE_ID}.json`);
74
+ const BUFFER_NAME = `coord-${SAFE_ID}`;
75
+
76
+ mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
77
+ mkdirSync(path.dirname(TRANSPORT_FILE), { recursive: true });
78
+
79
+ // Confirm tmux target exists at startup so we fail loudly instead of silently.
80
+ const probe = spawnSync("tmux", ["display-message", "-p", "-t", TMUX_TARGET, "ok"]);
81
+ if (probe.status !== 0) {
82
+ die(`tmux target '${TMUX_TARGET}' not found: ${(probe.stderr ?? "").toString().trim()}`);
83
+ }
84
+
85
+ let pending = [];
86
+ let debounceTimer = null;
87
+ let sending = false;
88
+
89
+ function readCursor() {
90
+ if (!existsSync(CURSOR_FILE)) return {};
91
+ try {
92
+ return JSON.parse(readFileSync(CURSOR_FILE, "utf8"));
93
+ } catch {
94
+ return {};
95
+ }
96
+ }
97
+
98
+ function writeCursor(c) {
99
+ const tmp = CURSOR_FILE + ".tmp";
100
+ writeFileSync(tmp, JSON.stringify(c));
101
+ renameSync(tmp, CURSOR_FILE);
102
+ }
103
+
104
+ function readJsonl(file) {
105
+ if (!existsSync(file)) return [];
106
+ return readFileSync(file, "utf8")
107
+ .split("\n")
108
+ .filter(Boolean)
109
+ .map((l) => {
110
+ try {
111
+ return JSON.parse(l);
112
+ } catch {
113
+ return null;
114
+ }
115
+ })
116
+ .filter(Boolean);
117
+ }
118
+
119
+ function shouldInject(m) {
120
+ if (!m || m.from === AGENT_ID) return false;
121
+ if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
122
+ if (typeof m.text === "string" && m.text.trimStart().startsWith("/")) return false;
123
+ return true;
124
+ }
125
+
126
+ function drainSource(label, file, cursorKey, cur) {
127
+ const all = readJsonl(file);
128
+ const off = cur[cursorKey] ?? 0;
129
+ const fresh = all.slice(off);
130
+ if (fresh.length === 0) return false;
131
+ for (const m of fresh) {
132
+ if (shouldInject(m)) pending.push({ kind: label, ...m });
133
+ }
134
+ cur[cursorKey] = off + fresh.length;
135
+ return true;
136
+ }
137
+
138
+ function checkOnce() {
139
+ const cur = readCursor();
140
+ let changed = false;
141
+ if (drainSource("DM", INBOX_FILE, "inboxOffset", cur)) changed = true;
142
+ if (INCLUDE_ROOM && drainSource("ROOM", ROOM_FILE, "roomOffset", cur)) changed = true;
143
+ if (changed) writeCursor(cur);
144
+ if (pending.length > 0) scheduleFlush();
145
+ }
146
+
147
+ function scheduleFlush() {
148
+ if (debounceTimer) return;
149
+ debounceTimer = setTimeout(flush, DEBOUNCE_MS);
150
+ }
151
+
152
+ async function flush() {
153
+ debounceTimer = null;
154
+ if (sending) {
155
+ scheduleFlush();
156
+ return;
157
+ }
158
+ if (pending.length === 0) return;
159
+ const batch = pending;
160
+ pending = [];
161
+ sending = true;
162
+ try {
163
+ await injectViaTmux(batch);
164
+ } catch (e) {
165
+ process.stderr.write(`[tmux-pusher] inject failed: ${e?.message ?? e}\n`);
166
+ pending = [...batch, ...pending];
167
+ scheduleFlush();
168
+ } finally {
169
+ sending = false;
170
+ }
171
+ }
172
+
173
+ function formatBatch(batch) {
174
+ const lines = [
175
+ "[agent-coord] incoming peer messages — already consumed from your inbox, do not call read_messages for them:",
176
+ ];
177
+ for (const m of batch) {
178
+ const tag = m.kind;
179
+ const ts = new Date(m.ts ?? Date.now()).toISOString();
180
+ lines.push(` [${tag} ${ts} from=${m.from}] ${m.text ?? ""}`);
181
+ }
182
+ return lines.join("\n");
183
+ }
184
+
185
+ function injectViaTmux(batch) {
186
+ return new Promise((resolve, reject) => {
187
+ const payload = formatBatch(batch);
188
+ const load = spawn("tmux", ["load-buffer", "-b", BUFFER_NAME, "-"]);
189
+ load.on("error", reject);
190
+ load.on("exit", (code) => {
191
+ if (code !== 0) return reject(new Error(`tmux load-buffer exit ${code}`));
192
+ const paste = spawnSync("tmux", [
193
+ "paste-buffer",
194
+ "-b",
195
+ BUFFER_NAME,
196
+ "-t",
197
+ TMUX_TARGET,
198
+ "-d",
199
+ ]);
200
+ if (paste.status !== 0) {
201
+ return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
202
+ }
203
+ const enter = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
204
+ if (enter.status !== 0) {
205
+ return reject(new Error(`tmux send-keys: ${(enter.stderr ?? "").toString().trim()}`));
206
+ }
207
+ resolve();
208
+ });
209
+ load.stdin.end(payload);
210
+ });
211
+ }
212
+
213
+ // Publish transport marker so list_agents can show this agent is push-capable.
214
+ writeTransportMarker();
215
+ let markerCleaned = false;
216
+ const cleanupMarker = () => {
217
+ if (markerCleaned) return;
218
+ markerCleaned = true;
219
+ try {
220
+ unlinkSync(TRANSPORT_FILE);
221
+ } catch {
222
+ // already gone
223
+ }
224
+ };
225
+ process.on("SIGINT", () => {
226
+ cleanupMarker();
227
+ process.exit(0);
228
+ });
229
+ process.on("SIGTERM", () => {
230
+ cleanupMarker();
231
+ process.exit(0);
232
+ });
233
+ process.on("exit", cleanupMarker);
234
+
235
+ function writeTransportMarker() {
236
+ const marker = {
237
+ agentId: AGENT_ID,
238
+ transport: "tmux-push",
239
+ pid: process.pid,
240
+ tmuxTarget: TMUX_TARGET,
241
+ since: Date.now(),
242
+ };
243
+ const tmp = TRANSPORT_FILE + ".tmp";
244
+ writeFileSync(tmp, JSON.stringify(marker));
245
+ renameSync(tmp, TRANSPORT_FILE);
246
+ }
247
+
248
+ // Initial drain in case messages accumulated before the daemon started.
249
+ checkOnce();
250
+
251
+ // Watch + poll fallback.
252
+ try {
253
+ if (existsSync(INBOX_FILE)) watch(INBOX_FILE, () => checkOnce());
254
+ } catch {
255
+ // file may not exist yet; polling covers it
256
+ }
257
+ if (INCLUDE_ROOM) {
258
+ try {
259
+ if (existsSync(ROOM_FILE)) watch(ROOM_FILE, () => checkOnce());
260
+ } catch {
261
+ // ignore
262
+ }
263
+ }
264
+ setInterval(checkOnce, POLL_MS);
265
+
266
+ process.stderr.write(
267
+ `[tmux-pusher] watching inbox for '${AGENT_ID}' -> tmux ${TMUX_TARGET} (room=${INCLUDE_ROOM ? "on" : "off"})\n`,
268
+ );
269
+
270
+ function die(msg) {
271
+ process.stderr.write(`[tmux-pusher] ${msg}\n`);
272
+ process.exit(1);
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.) on the same machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "dist",
17
17
  "src",
18
18
  "hooks",
19
+ "scripts",
19
20
  "README.md",
20
21
  "LICENSE"
21
22
  ],
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # spawn-agent.sh — start a CLI agent in a dedicated tmux session and wire
3
+ # coord-inbox auto-delivery to its pane via hooks/tmux-pusher.mjs.
4
+ #
5
+ # Works with any line-driven CLI agent (Claude Code, Aider, codex, gemini-cli,
6
+ # opencode, ...). The pusher just types into the pane; it doesn't care what's
7
+ # on the receiving end.
8
+ #
9
+ # Usage:
10
+ # scripts/spawn-agent.sh --id frontend --cmd "claude"
11
+ # scripts/spawn-agent.sh --id backend --cmd "aider --model sonnet" --include-room
12
+ # scripts/spawn-agent.sh --id worker --cmd "codex" --allowlist frontend,backend
13
+ #
14
+ # Flags:
15
+ # --id <agentId> coord agentId, also names the tmux session (coord-<id>)
16
+ # --cmd "<command>" agent CLI to run in pane 0 (quote it)
17
+ # --include-room also push shared-room messages into the pane
18
+ # --allowlist a,b,c only deliver DMs from these peer agentIds
19
+ # --dir <path> override AGENT_COORD_DIR
20
+ #
21
+ # After spawn, attach with: tmux attach -t coord-<id>
22
+ # Stop with: scripts/stop-agent.sh --id <id>
23
+
24
+ set -euo pipefail
25
+
26
+ ID=""
27
+ CMD=""
28
+ INCLUDE_ROOM=""
29
+ ALLOWLIST=""
30
+ COORD_DIR=""
31
+
32
+ while [[ $# -gt 0 ]]; do
33
+ case "$1" in
34
+ --id) ID="$2"; shift 2 ;;
35
+ --cmd) CMD="$2"; shift 2 ;;
36
+ --include-room) INCLUDE_ROOM="1"; shift ;;
37
+ --allowlist) ALLOWLIST="$2"; shift 2 ;;
38
+ --dir) COORD_DIR="$2"; shift 2 ;;
39
+ -h|--help)
40
+ sed -n '2,22p' "$0"; exit 0 ;;
41
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
42
+ esac
43
+ done
44
+
45
+ [[ -z "$ID" ]] && { echo "--id required" >&2; exit 2; }
46
+ [[ -z "$CMD" ]] && { echo "--cmd required (e.g. 'claude')" >&2; exit 2; }
47
+
48
+ command -v tmux >/dev/null || { echo "tmux not installed" >&2; exit 1; }
49
+ command -v node >/dev/null || { echo "node not installed" >&2; exit 1; }
50
+
51
+ SESSION="coord-$ID"
52
+ ROOT="${COORD_DIR:-${AGENT_COORD_DIR:-$HOME/agent-coord}}"
53
+ LOGDIR="$ROOT/logs"
54
+ PIDDIR="$ROOT/pids"
55
+ mkdir -p "$LOGDIR" "$PIDDIR"
56
+
57
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
58
+ PUSHER="$REPO_DIR/hooks/tmux-pusher.mjs"
59
+ [[ -f "$PUSHER" ]] || { echo "missing $PUSHER" >&2; exit 1; }
60
+
61
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
62
+ echo "tmux session '$SESSION' already exists — attach with: tmux attach -t $SESSION" >&2
63
+ exit 1
64
+ fi
65
+
66
+ PID_FILE="$PIDDIR/pusher-$ID.pid"
67
+ if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
68
+ echo "pusher already running for '$ID' (pid $(cat "$PID_FILE"))" >&2
69
+ exit 1
70
+ fi
71
+
72
+ tmux new-session -d -s "$SESSION" -n agent "$CMD"
73
+
74
+ # Resolve the actual pane id — globally unique, immune to base-index settings.
75
+ TARGET="$(tmux display-message -p -t "$SESSION:agent" '#{pane_id}')"
76
+ [[ -n "$TARGET" ]] || { echo "failed to resolve tmux pane id" >&2; exit 1; }
77
+
78
+ (
79
+ export AGENT_COORD_ID="$ID"
80
+ export AGENT_COORD_TMUX_TARGET="$TARGET"
81
+ [[ -n "$COORD_DIR" ]] && export AGENT_COORD_DIR="$COORD_DIR"
82
+ [[ -n "$INCLUDE_ROOM" ]] && export AGENT_COORD_INCLUDE_ROOM=1
83
+ [[ -n "$ALLOWLIST" ]] && export AGENT_COORD_ALLOWLIST="$ALLOWLIST"
84
+ nohup node "$PUSHER" >> "$LOGDIR/pusher-$ID.log" 2>&1 &
85
+ echo $! > "$PID_FILE"
86
+ )
87
+
88
+ echo "agent '$ID' started"
89
+ echo " attach: tmux attach -t $SESSION"
90
+ echo " pusher: pid $(cat "$PID_FILE") log: $LOGDIR/pusher-$ID.log"
91
+ echo " stop: scripts/stop-agent.sh --id $ID"
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # stop-agent.sh — tear down an agent spawned by spawn-agent.sh.
3
+ # Kills the tmux session and the tmux-pusher daemon.
4
+ #
5
+ # Usage:
6
+ # scripts/stop-agent.sh --id frontend
7
+ # scripts/stop-agent.sh --id frontend --dir /custom/coord/dir
8
+
9
+ set -euo pipefail
10
+
11
+ ID=""
12
+ COORD_DIR=""
13
+
14
+ while [[ $# -gt 0 ]]; do
15
+ case "$1" in
16
+ --id) ID="$2"; shift 2 ;;
17
+ --dir) COORD_DIR="$2"; shift 2 ;;
18
+ -h|--help) sed -n '2,8p' "$0"; exit 0 ;;
19
+ *) echo "unknown arg: $1" >&2; exit 2 ;;
20
+ esac
21
+ done
22
+
23
+ [[ -z "$ID" ]] && { echo "--id required" >&2; exit 2; }
24
+
25
+ ROOT="${COORD_DIR:-${AGENT_COORD_DIR:-$HOME/agent-coord}}"
26
+ PID_FILE="$ROOT/pids/pusher-$ID.pid"
27
+ SESSION="coord-$ID"
28
+
29
+ if [[ -f "$PID_FILE" ]]; then
30
+ PID="$(cat "$PID_FILE")"
31
+ if kill -0 "$PID" 2>/dev/null; then
32
+ kill "$PID" && echo "stopped pusher pid $PID"
33
+ fi
34
+ rm -f "$PID_FILE"
35
+ else
36
+ echo "no pusher pid file for '$ID'"
37
+ fi
38
+
39
+ if tmux has-session -t "$SESSION" 2>/dev/null; then
40
+ tmux kill-session -t "$SESSION" && echo "killed tmux session $SESSION"
41
+ else
42
+ echo "no tmux session '$SESSION'"
43
+ fi
package/src/server.ts CHANGED
@@ -3,6 +3,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
4
  import { ensureDirs } from "./store.js";
5
5
  import {
6
+ attachAgentSchema,
7
+ attachAgentTool,
8
+ detachAgentSchema,
9
+ detachAgentTool,
6
10
  heartbeatSchema,
7
11
  heartbeatTool,
8
12
  listAgentsSchema,
@@ -91,6 +95,20 @@ async function main() {
91
95
  async (args) => jsonResult(await waitForMessageTool(args))
92
96
  );
93
97
 
98
+ server.tool(
99
+ "attach_agent",
100
+ "Start the tmux-push transport for an agent: spawns hooks/tmux-pusher.mjs as a background process so peer DMs (and optionally room messages) get typed into the agent's tmux pane in real time. tmuxTarget defaults to the MCP server's own $TMUX_PANE if this server is running inside tmux. allowlist restricts which peer agentIds can push. Updates list_agents to show transport=tmux-push.",
101
+ attachAgentSchema,
102
+ async (args) => jsonResult(await attachAgentTool(args))
103
+ );
104
+
105
+ server.tool(
106
+ "detach_agent",
107
+ "Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
108
+ detachAgentSchema,
109
+ async (args) => jsonResult(await detachAgentTool(args))
110
+ );
111
+
94
112
  const transport = new StdioServerTransport();
95
113
  await server.connect(transport);
96
114
  }
package/src/store.ts CHANGED
@@ -12,9 +12,12 @@ export const ROOM_FILE = path.join(ROOT, "room.jsonl");
12
12
  export const STATUS_FILE = path.join(ROOT, "status.jsonl");
13
13
  export const INBOX_DIR = path.join(ROOT, "inbox");
14
14
  export const CURSOR_DIR = path.join(ROOT, "cursors");
15
+ export const TRANSPORT_DIR = path.join(ROOT, "transports");
16
+ export const PID_DIR = path.join(ROOT, "pids");
17
+ export const LOG_DIR = path.join(ROOT, "logs");
15
18
 
16
19
  export function ensureDirs(): void {
17
- for (const d of [ROOT, INBOX_DIR, CURSOR_DIR]) {
20
+ for (const d of [ROOT, INBOX_DIR, CURSOR_DIR, TRANSPORT_DIR, PID_DIR, LOG_DIR]) {
18
21
  if (!existsSync(d)) mkdirSync(d, { recursive: true });
19
22
  }
20
23
  for (const f of [ROOM_FILE, STATUS_FILE]) {
@@ -22,6 +25,24 @@ export function ensureDirs(): void {
22
25
  }
23
26
  }
24
27
 
28
+ export function transportFile(agentId: string): string {
29
+ return path.join(TRANSPORT_DIR, `${sanitize(agentId)}.json`);
30
+ }
31
+
32
+ export function pidFile(agentId: string, kind: string): string {
33
+ return path.join(PID_DIR, `${kind}-${sanitize(agentId)}.pid`);
34
+ }
35
+
36
+ export function logFile(agentId: string, kind: string): string {
37
+ return path.join(LOG_DIR, `${kind}-${sanitize(agentId)}.log`);
38
+ }
39
+
40
+ export async function listTransportFiles(): Promise<string[]> {
41
+ if (!existsSync(TRANSPORT_DIR)) return [];
42
+ const names = await fs.readdir(TRANSPORT_DIR);
43
+ return names.filter((n) => n.endsWith(".json"));
44
+ }
45
+
25
46
  async function ensureFile(file: string): Promise<void> {
26
47
  if (!existsSync(file)) {
27
48
  await fs.mkdir(path.dirname(file), { recursive: true });