agent-coord-mcp 0.2.1 → 0.3.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.
- package/README.md +83 -8
- package/dist/server.js +7 -2
- package/dist/server.js.map +1 -1
- package/dist/store.js +19 -1
- package/dist/store.js.map +1 -1
- package/dist/tools.js +345 -42
- 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 +46 -1
- package/src/store.ts +22 -1
- package/src/tools.ts +398 -41
|
@@ -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.
|
|
3
|
+
"version": "0.3.1",
|
|
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,8 +3,14 @@ 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,
|
|
12
|
+
joinSchema,
|
|
13
|
+
joinTool,
|
|
8
14
|
listAgentsSchema,
|
|
9
15
|
listAgentsTool,
|
|
10
16
|
postStatusSchema,
|
|
@@ -17,6 +23,10 @@ import {
|
|
|
17
23
|
registerTool,
|
|
18
24
|
sendMessageSchema,
|
|
19
25
|
sendMessageTool,
|
|
26
|
+
statusSchema,
|
|
27
|
+
statusTool,
|
|
28
|
+
unregisterSchema,
|
|
29
|
+
unregisterTool,
|
|
20
30
|
waitForMessageSchema,
|
|
21
31
|
waitForMessageTool,
|
|
22
32
|
} from "./tools.js";
|
|
@@ -35,13 +45,34 @@ async function main() {
|
|
|
35
45
|
version: "0.1.0",
|
|
36
46
|
});
|
|
37
47
|
|
|
48
|
+
server.tool(
|
|
49
|
+
"join",
|
|
50
|
+
"Recommended session-start call. Does register + auto-attach (if running inside tmux) + read inbox in one round-trip. Pass attach=false to skip the transport, attach={...overrides} to customize, or omit it to let the server auto-detect $TMUX_PANE. Returns the registration, attach result, and any unread inbox messages.",
|
|
51
|
+
joinSchema,
|
|
52
|
+
async (args) => jsonResult(await joinTool(args))
|
|
53
|
+
);
|
|
54
|
+
|
|
38
55
|
server.tool(
|
|
39
56
|
"register",
|
|
40
|
-
"Register this agent in the shared registry.
|
|
57
|
+
"Register this agent in the shared registry. Lower-level than `join` — does not attach a transport or drain the inbox. Prefer `join` unless you need explicit control.",
|
|
41
58
|
registerSchema,
|
|
42
59
|
async (args) => jsonResult(await registerTool(args))
|
|
43
60
|
);
|
|
44
61
|
|
|
62
|
+
server.tool(
|
|
63
|
+
"unregister",
|
|
64
|
+
"Tear down this agent: detach any attached transport (kills the pusher) and remove the registry entry. Clean shutdown counterpart to `join`.",
|
|
65
|
+
unregisterSchema,
|
|
66
|
+
async (args) => jsonResult(await unregisterTool(args))
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
server.tool(
|
|
70
|
+
"status",
|
|
71
|
+
"Introspect this agent's coord state: registration, attached transport, inbox depth and unread count, and whether this MCP server is running inside tmux. Useful for debugging 'why isn't my DM landing'.",
|
|
72
|
+
statusSchema,
|
|
73
|
+
async (args) => jsonResult(await statusTool(args))
|
|
74
|
+
);
|
|
75
|
+
|
|
45
76
|
server.tool(
|
|
46
77
|
"heartbeat",
|
|
47
78
|
"Refresh this agent's lastHeartbeat timestamp.",
|
|
@@ -91,6 +122,20 @@ async function main() {
|
|
|
91
122
|
async (args) => jsonResult(await waitForMessageTool(args))
|
|
92
123
|
);
|
|
93
124
|
|
|
125
|
+
server.tool(
|
|
126
|
+
"attach_agent",
|
|
127
|
+
"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.",
|
|
128
|
+
attachAgentSchema,
|
|
129
|
+
async (args) => jsonResult(await attachAgentTool(args))
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.tool(
|
|
133
|
+
"detach_agent",
|
|
134
|
+
"Stop the tmux-push transport for an agent: kills the pusher process and clears the transport marker.",
|
|
135
|
+
detachAgentSchema,
|
|
136
|
+
async (args) => jsonResult(await detachAgentTool(args))
|
|
137
|
+
);
|
|
138
|
+
|
|
94
139
|
const transport = new StdioServerTransport();
|
|
95
140
|
await server.connect(transport);
|
|
96
141
|
}
|
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 });
|