agent-coord-mcp 0.25.0 → 0.25.2

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 CHANGED
@@ -69,7 +69,7 @@ If you're building an agent with the official MCP SDKs (`@modelcontextprotocol/s
69
69
  | `heartbeat({agentId})` | Manual heartbeat. Usually unnecessary — agents with a live transport get heartbeats auto-bumped on every `list_agents`. |
70
70
  | `list_agents()` | See all known agents, who looks online, and which `transport` (if any) they have attached. Validates transport pid liveness on every call. |
71
71
  | `send_message({from, to?, room?, text, kind?})` | If `to` set → that agent's inbox (DM). Else → a channel: `room` names it (`"seo"` / `"#seo"`), omit for the default `general` channel. Tag channel posts with `kind: "decision"` for GOs/verdicts (longer retention, quoted in digests), `"status"` for progress notes; absent = chatter. |
72
- | `send_command({from, to?, command, reminderMs?, reminderText?})` / `send_command({from, room, command, ...})` | Inject a context-management slash command (`/clear`, `/compact` or `/reload-skills`) **directly into a sub-agent's CLI** — delivered raw (no banner/prefix) so the receiving TUI runs it as a real slash command. `to` targets one agent; `room` broadcasts to a channel's tmux-attached members (never the sender). **Gated to tmux:** returns `ok:false` unless the target has a live `tmux-push`(`-remote`) transport. Command allowlist is locked to `/clear` + `/compact`. After `/clear`, a follow-up identity-reminder DM is auto-scheduled (`reminderMs:0` to opt out, `reminderText` to override) so the freshly-cleared worker re-anchors on its agentId and bus attach state. **Blocks until an out-of-band delivery receipt confirms the command reached the pane** (`delivery:"confirmed"`+`deliveredAt`, or `"pending"`+`warning` on a stale pusher); `waitForDelivery:false` for fire-and-forget, `deliveryTimeoutMs` to tune. See [clearing sub-agent context](#clearing-sub-agent-context-send_command). |
72
+ | `send_command({from, to?, command, reminderMs?, reminderText?})` / `send_command({from, room, command, ...})` | Inject a context-management slash command (`/clear`, `/compact` or `/reload-skills`) **directly into a sub-agent's CLI** — delivered raw (no banner/prefix) so the receiving TUI runs it as a real slash command. `to` targets one agent; `room` broadcasts to a channel's tmux-attached members (never the sender). **Gated to tmux:** returns `ok:false` unless the target has a live `tmux-push`(`-remote`) transport. Command allowlist is locked to `/clear` + `/compact` + `/reload-skills`. After `/clear`, a follow-up identity-reminder DM is auto-scheduled (`reminderMs:0` to opt out, `reminderText` to override) so the freshly-cleared worker re-anchors on its agentId and bus attach state. **Blocks until an out-of-band delivery receipt confirms the command reached the pane** (`delivery:"confirmed"`+`deliveredAt`, or `"pending"`+`warning` on a stale pusher); `waitForDelivery:false` for fire-and-forget, `deliveryTimeoutMs` to tune. See [clearing sub-agent context](#clearing-sub-agent-context-send_command). |
73
73
  | `read_messages({agentId, source, room?, limit?, peek?, sinceTs?})` | Read new messages. `source` is `inbox`/`room`/`status`; for `room`, `room` picks the channel (default `general`). Advances the per-channel cursor unless `peek:true`. Room reads return the most recent `limit` (default 50); any older overflow is replaced by a compact `history` digest carrying a retrieval hash. |
74
74
  | `retrieve_room_history({agentId, hash, query?})` | Expand a `history` digest from `read_messages` into the original overflow messages. `query` filters to matching messages (case-insensitive substring). Scoped to the producing agent; entries expire after 30 min. |
75
75
  | `post_status({agentId, status, detail?})` | Append to the shared status stream (separate from chat). |
package/dist/server.js CHANGED
@@ -160,7 +160,7 @@ function buildServer(initialBound, opts = {}) {
160
160
  }
161
161
  const server = new McpServer({
162
162
  name: "agent-coord",
163
- version: "0.25.0",
163
+ version: "0.25.2",
164
164
  });
165
165
  const addTool = (name, description, inputSchema, cb) => {
166
166
  server.registerTool(name, { description, inputSchema: z.object(inputSchema) }, async (args) => cb((args ?? {})));
package/hooks/submit.mjs CHANGED
@@ -38,6 +38,13 @@ export const ENTER_GAP_MS = () => envInt("AGENT_COORD_ENTER_GAP_MS", 150);
38
38
  // often. Bounded and finite — this must not spin, and it must not stall the
39
39
  // delivery loop behind a pane that will never change.
40
40
  export const VERIFY_TIMEOUT_MS = () => envInt("AGENT_COORD_SUBMIT_VERIFY_MS", 1500);
41
+ // Ceiling for the paste-landing check: how long after paste-buffer we keep
42
+ // polling for the pasted text to APPEAR in the input line before refusing to
43
+ // send Enter. ENTER_DELAY_MS stays the floor (menu-settle reasoning above) —
44
+ // landing early never shortens it. On a slow machine the paste can take
45
+ // longer than any fixed delay; a blind Enter then submits whatever WAS in the
46
+ // input (nothing, or someone's draft) while the real payload lands after it.
47
+ export const PASTE_VERIFY_MS = () => envInt("AGENT_COORD_PASTE_VERIFY_MS", 4000);
41
48
  export const VERIFY_POLL_MS = () => envInt("AGENT_COORD_SUBMIT_POLL_MS", 100);
42
49
  // Extra Enters to try when the first pair didn't submit. 0 disables retrying.
43
50
  export const ENTER_RETRIES = () => envInt("AGENT_COORD_ENTER_RETRIES", 2);
@@ -187,6 +194,25 @@ export function stillInInput(paneText, payload) {
187
194
  return squash(draft).includes(needle);
188
195
  }
189
196
 
197
+ // Has the PASTED payload appeared in the input line yet? Distinct from
198
+ // stillInInput (verify-by-absence after Enter): before Enter, "gone" cannot
199
+ // mean submitted — it means not-landed-yet. The input box wraps long pastes
200
+ // across lines the prompt regex does not match, so the visible draft may be
201
+ // only the head of the payload; match on a shared prefix in either direction.
202
+ // true = payload (or its head) is visibly in the input; false = input readable
203
+ // and payload not there; null = no readable input line (unknown TUI/capture).
204
+ export function landedInInput(paneText, payload) {
205
+ if (paneText === null || paneText === undefined) return null;
206
+ const needle = squash(payload);
207
+ if (!needle) return false;
208
+ const { draft } = readPaneState(paneText);
209
+ if (draft === null) return null;
210
+ const d = squash(draft);
211
+ if (!d) return false;
212
+ if (d.includes(needle.slice(0, 32))) return true; // draft carries the payload head
213
+ return needle.includes(d) && d.length >= 8; // visible draft is a genuine fragment of the payload
214
+ }
215
+
190
216
  // Read the pane's input state: is the TUI busy, and is there unsent text?
191
217
  //
192
218
  // Accepts BOTH plain and styled (`capture-pane -e`) text — on styled input the
@@ -323,7 +349,30 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
323
349
  throw new Error(`tmux paste-buffer: ${String(paste.stderr ?? "").trim()}`);
324
350
  }
325
351
 
326
- await sleep(ENTER_DELAY_MS());
352
+ // Verify the paste LANDED before any Enter (recurring loss on slow machines:
353
+ // the blind delay elapsed before the paste rendered, and the Enter submitted
354
+ // an empty input while the payload arrived after it). ENTER_DELAY_MS is the
355
+ // floor; PASTE_VERIFY_MS is the ceiling. Three outcomes:
356
+ // true — payload seen in the input; proceed to Enter.
357
+ // false — input line readable the whole time and the payload never
358
+ // appeared; refuse the Enter and say so (honest timeout, not a
359
+ // blind CR into someone's session).
360
+ // null — no readable input line (unknown TUI / capture failed); fall back
361
+ // to the legacy fixed-delay path — unknown never blocks delivery.
362
+ const floor = sleep(ENTER_DELAY_MS());
363
+ const landed = await pollUntilLanded(deps, payload);
364
+ await floor;
365
+ if (landed === false) {
366
+ return {
367
+ submitted: false,
368
+ verified: true, // we DID observe the input line; what we observed is a paste that never arrived
369
+ attempts: 0,
370
+ reason:
371
+ `pasted text did not appear in the input line of pane '${target}' within ${PASTE_VERIFY_MS()}ms — ` +
372
+ `Enter NOT sent (the paste may still land later; raise AGENT_COORD_PASTE_VERIFY_MS on slow machines, ` +
373
+ `and check the pane for a stranded draft before resending)`,
374
+ };
375
+ }
327
376
  const e1 = run(["send-keys", "-t", target, "Enter"]);
328
377
  if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
329
378
  await sleep(ENTER_GAP_MS());
@@ -369,6 +418,22 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
369
418
  }
370
419
  }
371
420
 
421
+ // Poll capture-pane until the pasted payload APPEARS in the input line, the
422
+ // PASTE_VERIFY_MS budget runs out, or the input line proves unreadable.
423
+ // true = landed, false = readable-but-never-landed, null = never readable.
424
+ async function pollUntilLanded(deps, payload) {
425
+ const { run, target } = deps;
426
+ const deadline = Date.now() + PASTE_VERIFY_MS();
427
+ let readable = false;
428
+ for (;;) {
429
+ const verdict = landedInInput(captureStyled(run, target), payload);
430
+ if (verdict === true) return true;
431
+ if (verdict === false) readable = true;
432
+ if (Date.now() >= deadline) return readable ? false : null;
433
+ await sleep(VERIFY_POLL_MS());
434
+ }
435
+ }
436
+
372
437
  // Poll capture-pane until the payload leaves the input line, the budget runs
373
438
  // out, or we run out of ways to tell. true = still there, false = gone,
374
439
  // null = unknown (capture failed, or no input line we can read).
@@ -224,7 +224,8 @@ function readJsonl(file) {
224
224
  }
225
225
 
226
226
  // Allowlisted control commands the bus may type into this CLI. Mirrors
227
- // CONTROL_COMMANDS in src/tools.ts; kept tiny + literal so a bugged/compromised
227
+ // CONTROL_COMMANDS in src/tools/transport.ts and scripts/coord-pusher.mjs
228
+ // (test/allowlist-parity.test.mjs locks the three); kept tiny + literal so a bugged/compromised
228
229
  // sender can't smuggle an arbitrary slash command past the guard below.
229
230
  const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
230
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-coord-mcp",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,7 +46,7 @@
46
46
  "@modelcontextprotocol/sdk": "^1.30.0",
47
47
  "proper-lockfile": "^4.1.2",
48
48
  "zod": "^4.4.3",
49
- "@davidbalzan/groundwork-seam": "0.1.1"
49
+ "@davidbalzan/groundwork-seam": "0.1.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^26.2.0",
@@ -55,7 +55,8 @@
55
55
  "typescript": "^7.0.2"
56
56
  },
57
57
  "scripts": {
58
- "build": "tsc",
58
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
59
+ "build": "pnpm run clean && tsc",
59
60
  "start": "node dist/server.js",
60
61
  "dev": "tsx src/server.ts",
61
62
  "pretest": "node scripts/check-self-dependency.mjs && tsc",
@@ -22,13 +22,18 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
 
25
- const EXPECTED_TESTS = 282;
25
+ const EXPECTED_TESTS = 285;
26
26
 
27
27
  const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
28
28
  // Same glob the suite always used — `--test test/` would recurse differently
29
29
  // on some node versions, and a runner that selects a different set of files
30
30
  // is exactly the failure this script exists to catch.
31
- const child = spawn(process.execPath, ["--test", "test/*.test.mjs"], {
31
+ // Reporter pinned to TAP: newer Node defaults to the spec reporter even when
32
+ // piped (and env like FORCE_COLOR can style its summary lines), so grepping
33
+ // the summary was machine-dependent — it broke David's publish run 2026-08-20.
34
+ // TAP is stable, uncolored, and the format the totals-check below was written
35
+ // against.
36
+ const child = spawn(process.execPath, ["--test", "--test-reporter=tap", "test/*.test.mjs"], {
32
37
  stdio: ["inherit", "pipe", "inherit"],
33
38
  shell: false,
34
39
  });
@@ -41,7 +46,8 @@ child.stdout.on("data", (chunk) => {
41
46
 
42
47
  child.on("exit", (code, signal) => {
43
48
  if (signal) process.exit(1);
44
- // TAP (`# pass N`) on Node 20–22; spec reporter (`ℹ pass N`) on newer Node.
49
+ // TAP is forced above, so `# pass N` is the contract; the `ℹ` alternative
50
+ // stays as a belt against a runner that ignores the flag.
45
51
  const num = (label) => {
46
52
  const m = out.match(new RegExp(`^(?:#|ℹ) ${label} (\\d+)\\s*$`, "m"));
47
53
  return m ? Number(m[1]) : null;
@@ -179,8 +179,9 @@ let pending = [];
179
179
  let debounceTimer = null;
180
180
  let sending = false;
181
181
 
182
- // Allowlisted control commands (mirrors CONTROL_COMMANDS in src/tools.ts).
183
- const CONTROL_COMMANDS = new Set(["/clear", "/compact"]);
182
+ // Allowlisted control commands (mirrors CONTROL_COMMANDS in src/tools/transport.ts and
183
+ // hooks/tmux-pusher.mjs test/allowlist-parity.test.mjs locks the three together).
184
+ const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
184
185
 
185
186
  // Shells we refuse to inject into: if the pane's foreground command is one of
186
187
  // these, the agent CLI has exited and typing would run pasted text as commands.
package/src/server.ts CHANGED
@@ -241,7 +241,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
241
241
 
242
242
  const server = new McpServer({
243
243
  name: "agent-coord",
244
- version: "0.25.0",
244
+ version: "0.25.2",
245
245
  });
246
246
 
247
247
  const addTool = (