agent-coord-mcp 0.17.0 → 0.18.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 +34 -5
- package/dist/build.js +113 -0
- package/dist/build.js.map +1 -0
- package/dist/roles.js +92 -0
- package/dist/roles.js.map +1 -0
- package/dist/server.js +30 -14
- package/dist/server.js.map +1 -1
- package/dist/store.js +54 -1
- package/dist/store.js.map +1 -1
- package/dist/tools/admin.js +5 -3
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/messaging.js +187 -8
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/registry.js +54 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render.js +84 -0
- package/dist/tools/render.js.map +1 -0
- package/dist/tools/scopes.js +126 -0
- package/dist/tools/scopes.js.map +1 -0
- package/dist/tools/shared.js +18 -0
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/transport.js +367 -33
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/work.js +209 -0
- package/dist/tools/work.js.map +1 -0
- package/dist/work.js +260 -0
- package/dist/work.js.map +1 -0
- package/hooks/marker.mjs +18 -0
- package/hooks/roles.mjs +79 -0
- package/hooks/submit.mjs +271 -0
- package/hooks/tier.mjs +104 -11
- package/hooks/tmux-pusher.mjs +137 -51
- package/package.json +5 -4
- package/scripts/check-self-dependency.mjs +42 -0
- package/scripts/check-test-count.mjs +83 -0
- package/scripts/coord-pusher.mjs +150 -33
- package/src/build.ts +111 -0
- package/src/roles.ts +111 -0
- package/src/server.ts +79 -14
- package/src/store.ts +60 -1
- package/src/tools/admin.ts +10 -4
- package/src/tools/index.ts +2 -0
- package/src/tools/messaging.ts +206 -7
- package/src/tools/registry.ts +63 -4
- package/src/tools/render.ts +80 -0
- package/src/tools/scopes.ts +177 -0
- package/src/tools/shared.ts +120 -0
- package/src/tools/transport.ts +386 -34
- package/src/tools/work.ts +265 -0
- package/src/work.ts +329 -0
package/hooks/tier.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Pure delivery-tier logic for the pusher.
|
|
2
|
-
//
|
|
3
|
-
// delivery hot path.
|
|
1
|
+
// Pure delivery-tier logic for the pusher. Side-effect-free, and its only
|
|
2
|
+
// import (./roles.mjs) is equally pure — so it can be unit-tested directly and
|
|
3
|
+
// adds no I/O to the delivery hot path.
|
|
4
4
|
//
|
|
5
5
|
// "urgent" = push now (wakes the target agent's model).
|
|
6
6
|
// "routine" = queue silently; it rides along as a coalesced digest on the
|
|
@@ -9,6 +9,41 @@
|
|
|
9
9
|
// All prefix checks are case-sensitive: the protocol prefixes are uppercase
|
|
10
10
|
// by convention, and a lowercase lookalike is chatter, not a work order.
|
|
11
11
|
|
|
12
|
+
import { isGateRunner } from "./roles.mjs";
|
|
13
|
+
|
|
14
|
+
// Typed protocol record → tier (Phase 8). The prefix table below is the same
|
|
15
|
+
// vocabulary parsed out of text; reading the field instead removes the parse
|
|
16
|
+
// entirely, so a body that leads with a greeting no longer downgrades a
|
|
17
|
+
// blocker to routine.
|
|
18
|
+
//
|
|
19
|
+
// TRUST: `record` is caller-supplied, exactly like `from`. A typed record may
|
|
20
|
+
// therefore assert what a text prefix could already assert, and NOTHING more —
|
|
21
|
+
// `scope` resolves the sender against trustedSenders and `done` depends on the
|
|
22
|
+
// RECIPIENT being a gate runner, exactly as the prefix path does. `go` is
|
|
23
|
+
// unconditionally urgent here because literal "GO:" is unconditionally urgent
|
|
24
|
+
// in the prefix path too; do NOT "fix" that asymmetry by gating go, and do not
|
|
25
|
+
// ungate scope to match go — each mirrors its v1 prefix, which is the whole
|
|
26
|
+
// invariant. Typed is not authenticated: anything that would let a peer
|
|
27
|
+
// self-declare urgency belongs in the server-set `urgent` flag, not here.
|
|
28
|
+
function tierFromRecord(rec, m, opts) {
|
|
29
|
+
switch (rec.type) {
|
|
30
|
+
case "blocker":
|
|
31
|
+
case "decision":
|
|
32
|
+
return "urgent";
|
|
33
|
+
case "go":
|
|
34
|
+
return "urgent";
|
|
35
|
+
case "scope":
|
|
36
|
+
return opts.trustedSenders?.has?.(m.from) ? "urgent" : "routine";
|
|
37
|
+
case "done":
|
|
38
|
+
return opts.gateRunner ? "urgent" : "routine";
|
|
39
|
+
// risk / fyi / action / verdict: real semantics, but not a reason to
|
|
40
|
+
// interrupt someone else's turn. A risk that genuinely needs an immediate
|
|
41
|
+
// turn is a blocker — that judgement stays with the sender.
|
|
42
|
+
default:
|
|
43
|
+
return "routine";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
export function classifyTier(m, opts = {}) {
|
|
13
48
|
if (!m) return "routine";
|
|
14
49
|
// Server-set push-now override (post-/clear reminder). send_message builds
|
|
@@ -18,7 +53,39 @@ export function classifyTier(m, opts = {}) {
|
|
|
18
53
|
// wants it specifically, and DM volume is tiny next to room traffic. The
|
|
19
54
|
// tiers exist to absorb broadcast noise, not point-to-point asks (the
|
|
20
55
|
// liaison relaying a David question must not sit in a digest queue).
|
|
21
|
-
|
|
56
|
+
//
|
|
57
|
+
// `tag`, not `kind`: this is the pushers' synthetic channel tag ("DM" /
|
|
58
|
+
// "room #general"), set on their own copy of the message. It used to be
|
|
59
|
+
// called `kind`, which collided with the stored Message.kind (retention
|
|
60
|
+
// weight, "decision"/"status"/"chatter") — a room post tagged
|
|
61
|
+
// kind:"decision" overwrote the channel tag and rendered as `[decision …]`.
|
|
62
|
+
// The tag is process-local and never persisted, so it was the safe half of
|
|
63
|
+
// the collision to rename; Message.kind is on disk in every JSONL file.
|
|
64
|
+
// NOTE: this returns before the record is read, so a DM can never be
|
|
65
|
+
// downgraded by one. The floor rule below makes that moot, but the ordering
|
|
66
|
+
// is load-bearing if anyone reintroduces a record-first branch.
|
|
67
|
+
if (m.tag === "DM") return "urgent";
|
|
68
|
+
// The record is a FLOOR, not an override: it can raise the tier, never lower
|
|
69
|
+
// it. An earlier version let the record win outright, on the argument that
|
|
70
|
+
// Task 3 renders text from the record so the two agree by construction. That
|
|
71
|
+
// is false when BOTH are supplied — contract 3.3 makes the author's `text`
|
|
72
|
+
// win for rendering, so `{text:"BLOCKER: prod down", record:{type:"fyi"}}`
|
|
73
|
+
// displayed a BLOCKER in the pane and delivered it routine, with the reader
|
|
74
|
+
// unable to see why (`record` is never rendered). That is a REGRESSION IN A
|
|
75
|
+
// SAFETY PROPERTY: in v1, "BLOCKER:" at byte 0 always woke the pane. The
|
|
76
|
+
// trigger is ordinary relay, not malice — an aide forwarding a worker's body
|
|
77
|
+
// under its own `fyi` would silently bury that worker's blocker.
|
|
78
|
+
//
|
|
79
|
+
// max() is not the two-overridable-sources design this phase exists to
|
|
80
|
+
// delete: it is monotone, so there is nothing to disagree about, only a
|
|
81
|
+
// higher claim winning. It also fails safe on an UNKNOWN future record type,
|
|
82
|
+
// which hits the routine default — an old pusher meeting a new vocabulary
|
|
83
|
+
// must not bury a "BLOCKER:" body. The one legitimate downgrade, quoting a
|
|
84
|
+
// blocker mid-body, is already handled by the byte-0 rule.
|
|
85
|
+
// Found by ai-workflow-worker-1 gating 721882a.
|
|
86
|
+
if (m.record && typeof m.record.type === "string") {
|
|
87
|
+
if (tierFromRecord(m.record, m, opts) === "urgent") return "urgent";
|
|
88
|
+
}
|
|
22
89
|
if (typeof m.text !== "string") return "routine";
|
|
23
90
|
const text = m.text.trimStart();
|
|
24
91
|
// Control/slash commands are injected raw and must fire immediately.
|
|
@@ -46,10 +113,16 @@ export function effectiveTier(m, opts = {}) {
|
|
|
46
113
|
}
|
|
47
114
|
|
|
48
115
|
// A gate runner is the agent that consumes DONE: reports (QA / coordinator).
|
|
49
|
-
// Resolved from the registry role
|
|
50
|
-
//
|
|
116
|
+
// Resolved from the registry role's frozen `roleId` (Phase 8 Task 4) rather
|
|
117
|
+
// than by regex-matching display prose, so renaming the role does not change
|
|
118
|
+
// who runs the gate. Registry entries with no declared roleId fall back to the
|
|
119
|
+
// legacy word match — see roleMatches in roles.mjs. Env override in both
|
|
120
|
+
// directions (AGENT_COORD_GATE_RUNNER=1|0) is applied by the caller.
|
|
121
|
+
//
|
|
122
|
+
// Accepts a plain string or a whole registry entry ({role, roleId}); pass the
|
|
123
|
+
// entry when you have it, or the id is lost.
|
|
51
124
|
export function isGateRunnerRole(role) {
|
|
52
|
-
return
|
|
125
|
+
return isGateRunner(role);
|
|
53
126
|
}
|
|
54
127
|
|
|
55
128
|
// In-memory routine queue with the push decision. Ingest classified messages;
|
|
@@ -96,15 +169,35 @@ export class TierQueue {
|
|
|
96
169
|
// The per-message PARSE CONTRACT line. Agent harnesses read from/room/text
|
|
97
170
|
// back out of this, so its shape is load-bearing and MUST stay byte-identical
|
|
98
171
|
// to coord-pusher.mjs's `injectLine`. Compact form (v0.14.0, salvaged from
|
|
99
|
-
// v0.8.10): ` [<
|
|
172
|
+
// v0.8.10): ` [<tag> <HH:MM> <from>] <text>` where tag drops the leading
|
|
100
173
|
// "room " ("room #general" → "#general"), the timestamp is HH:MM UTC, and the
|
|
101
|
-
// "from=" label is dropped (bare id).
|
|
174
|
+
// "from=" label is dropped (bare id). tag/time/from never contain spaces
|
|
102
175
|
// (ids are sanitized), so a parser splits on the first "] " unambiguously.
|
|
103
176
|
export function injectLine(m) {
|
|
104
|
-
const tag = String(m.
|
|
177
|
+
const tag = String(m.tag ?? "").replace(/^room /, "");
|
|
105
178
|
const d = new Date(m.ts ?? 0);
|
|
106
179
|
const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
|
|
107
|
-
|
|
180
|
+
let text = m.text ?? "";
|
|
181
|
+
// Phase 8 Task 6: a TYPED record whose rendering spans lines is delivered as
|
|
182
|
+
// ONE attributed line — first line, a count of what was withheld, and the
|
|
183
|
+
// message id as the retrieval handle. Continuation lines used to arrive bare,
|
|
184
|
+
// with no `[tag HH:MM from]` header, so a parser could not attribute them.
|
|
185
|
+
//
|
|
186
|
+
// The handle is the message id, NOT a stashed copy: the full record is
|
|
187
|
+
// already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
|
|
188
|
+
// retrieve_message reads it back by id (falling through to the append-only
|
|
189
|
+
// archive if compaction moved it). A cache would have had a TTL and lost the
|
|
190
|
+
// record permanently on expiry.
|
|
191
|
+
//
|
|
192
|
+
// Gated on `m.record`: a record-LESS multi-line message is untouched and
|
|
193
|
+
// still arrives unattributed past line 1, exactly as today. Task 6 does not
|
|
194
|
+
// fix hand-typed multi-line messages, and must not change their bytes.
|
|
195
|
+
const nl = text.indexOf("\n");
|
|
196
|
+
if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
|
|
197
|
+
const held = text.split("\n").length - 1;
|
|
198
|
+
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
199
|
+
}
|
|
200
|
+
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
108
201
|
}
|
|
109
202
|
|
|
110
203
|
// Render one delivery: urgent verbatim under the banner, then at most ONE
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -39,11 +39,24 @@
|
|
|
39
39
|
* while this daemon runs — both consume the same cursor and would race.
|
|
40
40
|
*
|
|
41
41
|
* Caveats:
|
|
42
|
-
* -
|
|
43
|
-
* the same pane, your buffer gets corrupted. Run the receiving agent in
|
|
44
|
-
* dedicated pane you don't normally edit in.
|
|
45
|
-
* -
|
|
46
|
-
*
|
|
42
|
+
* - PEER MESSAGES paste into whatever pane state exists. If you're mid-typing
|
|
43
|
+
* in the same pane, your buffer gets corrupted. Run the receiving agent in
|
|
44
|
+
* a dedicated pane you don't normally edit in.
|
|
45
|
+
* - CONTROL COMMANDS (/clear, /compact) are the exception: since they paste
|
|
46
|
+
* RAW, a pane that is busy or holds an unsent draft would either queue them
|
|
47
|
+
* behind a running turn or append them to the draft and ship the result to
|
|
48
|
+
* the model as ordinary text — both observed live, both reporting
|
|
49
|
+
* delivery:"confirmed". So control submission is CONDITIONAL: it waits
|
|
50
|
+
* (bounded) for a busy pane to go idle, refuses to paste onto a draft, and
|
|
51
|
+
* verifies via capture-pane that the command left the input. An unverified
|
|
52
|
+
* submission reports delivery:"pending" with a reason, never "confirmed".
|
|
53
|
+
* See hooks/submit.mjs, shared with scripts/coord-pusher.mjs.
|
|
54
|
+
* - CLEANUP: never `pkill -f tmux-pusher.mjs` — the pattern matches EVERY
|
|
55
|
+
* pusher on the bus, and one agent's test-rig cleanup doing exactly that
|
|
56
|
+
* silently detached all four live agents (2026-07-28). attach_agent puts
|
|
57
|
+
* `--agent <id>` in argv so a pattern kill can be scoped to one agent
|
|
58
|
+
* (`pkill -f "tmux-pusher.mjs --agent <id>"`); better still, use
|
|
59
|
+
* detach_agent or the pid file — both target the exact pid.
|
|
47
60
|
*/
|
|
48
61
|
|
|
49
62
|
import {
|
|
@@ -56,10 +69,14 @@ import {
|
|
|
56
69
|
unlinkSync,
|
|
57
70
|
watch,
|
|
58
71
|
} from "node:fs";
|
|
72
|
+
import { readdirSync, statSync } from "node:fs";
|
|
59
73
|
import { homedir } from "node:os";
|
|
74
|
+
import { fileURLToPath } from "node:url";
|
|
60
75
|
import path from "node:path";
|
|
61
76
|
import { spawn, spawnSync } from "node:child_process";
|
|
62
77
|
import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.mjs";
|
|
78
|
+
import { mergeTransportMarker } from "./marker.mjs";
|
|
79
|
+
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
|
|
63
80
|
|
|
64
81
|
const AGENT_ID = process.env.AGENT_COORD_ID;
|
|
65
82
|
const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
|
|
@@ -103,7 +120,9 @@ function refreshTierCtx() {
|
|
|
103
120
|
try {
|
|
104
121
|
const reg = JSON.parse(readFileSync(path.join(ROOT, "agents.json"), "utf8"));
|
|
105
122
|
for (const [id, entry] of Object.entries(reg ?? {})) {
|
|
106
|
-
|
|
123
|
+
// Pass the whole entry: the frozen roleId lives beside `role`, and
|
|
124
|
+
// reading only the display string would throw the identity away.
|
|
125
|
+
if (isGateRunnerRole(entry)) trusted.add(id);
|
|
107
126
|
}
|
|
108
127
|
gateRunner = trusted.has(AGENT_ID);
|
|
109
128
|
} catch {
|
|
@@ -129,6 +148,30 @@ const TRANSPORT_FILE = path.join(ROOT, "transports", `${SAFE_ID}.json`);
|
|
|
129
148
|
// entering any agent's context. Mirrors RECEIPTS_DIR in src/store.ts.
|
|
130
149
|
const RECEIPTS_FILE = path.join(ROOT, "receipts", `${SAFE_ID}.jsonl`);
|
|
131
150
|
const BUFFER_NAME = `coord-${SAFE_ID}`;
|
|
151
|
+
// Newest mtime across this script AND its sibling hooks modules, sampled once
|
|
152
|
+
// at startup — an upgrade to any of them afterwards leaves this value behind,
|
|
153
|
+
// which is exactly what doctor's stale-pusher-script check compares against.
|
|
154
|
+
// The entry file alone is not enough: control submission lives in submit.mjs
|
|
155
|
+
// and tiering in tier.mjs/roles.mjs, so a fix touching only an import would
|
|
156
|
+
// leave a single-file stamp unchanged and a still-running pusher on the old
|
|
157
|
+
// code would read as fresh. Scanning the whole dir over-covers (a hooks file
|
|
158
|
+
// we never import can flag us stale) — that is the safe direction: a spurious
|
|
159
|
+
// re-attach is loud and cheap, a false green silently invalidates the rollout
|
|
160
|
+
// verification of every pusher-side fix.
|
|
161
|
+
const SCRIPT_MTIME = (() => {
|
|
162
|
+
try {
|
|
163
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
164
|
+
let newest;
|
|
165
|
+
for (const f of readdirSync(dir)) {
|
|
166
|
+
if (!f.endsWith(".mjs")) continue;
|
|
167
|
+
const m = statSync(path.join(dir, f)).mtimeMs;
|
|
168
|
+
if (newest === undefined || m > newest) newest = m;
|
|
169
|
+
}
|
|
170
|
+
return newest;
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
132
175
|
|
|
133
176
|
mkdirSync(path.dirname(CURSOR_FILE), { recursive: true });
|
|
134
177
|
mkdirSync(path.dirname(TRANSPORT_FILE), { recursive: true });
|
|
@@ -234,7 +277,15 @@ function collectSource(label, file, cursorKey, cur, staged) {
|
|
|
234
277
|
if (fresh.length === 0) return;
|
|
235
278
|
for (const m of fresh) {
|
|
236
279
|
if (shouldInject(m)) {
|
|
237
|
-
|
|
280
|
+
// The channel tag lives in `tag`, not `kind`. A stored Message carries
|
|
281
|
+
// its own `kind` ("decision"/"status"/"chatter" — retention weight);
|
|
282
|
+
// sharing the name meant a room post tagged kind:"decision" (what the
|
|
283
|
+
// README recommends for GOs and verdicts) overwrote the channel tag and
|
|
284
|
+
// rendered as `[decision …]` instead of `[#general …]`, breaking
|
|
285
|
+
// injectLine's parse contract. Distinct names remove the collision at
|
|
286
|
+
// the source; assignment still follows the spread so a sender cannot
|
|
287
|
+
// smuggle a `tag` in either.
|
|
288
|
+
const tagged = { ...m, tag: label };
|
|
238
289
|
// Assigned after the spread so a sender can't smuggle a tier field in.
|
|
239
290
|
tagged.tier = effectiveTier(tagged, tierCtx);
|
|
240
291
|
staged.msgs.push(tagged);
|
|
@@ -259,7 +310,7 @@ function collectRoomChannel(chan, cur, staged) {
|
|
|
259
310
|
if (fresh.length === 0) return;
|
|
260
311
|
for (const m of fresh) {
|
|
261
312
|
if (shouldInject(m)) {
|
|
262
|
-
const tagged = {
|
|
313
|
+
const tagged = { ...m, tag: `room #${c}` }; // see channel-tag note above
|
|
263
314
|
tagged.tier = effectiveTier(tagged, tierCtx);
|
|
264
315
|
staged.msgs.push(tagged);
|
|
265
316
|
}
|
|
@@ -378,8 +429,11 @@ async function injectViaTmux(batch) {
|
|
|
378
429
|
for (const m of batch) {
|
|
379
430
|
if (isControl(m)) {
|
|
380
431
|
await flushRun();
|
|
381
|
-
|
|
382
|
-
|
|
432
|
+
// Control commands are VERIFIED: capture-pane confirms the command
|
|
433
|
+
// actually left the input. A receipt that says "typed" was being read
|
|
434
|
+
// as "ran" — see writeReceipts.
|
|
435
|
+
const outcome = await submitControlCommand(m.text.trim());
|
|
436
|
+
writeReceipts([m], outcome);
|
|
383
437
|
} else {
|
|
384
438
|
run.push(m);
|
|
385
439
|
}
|
|
@@ -391,7 +445,12 @@ async function injectViaTmux(batch) {
|
|
|
391
445
|
// pane. Receipts are out-of-band proof for the sender (it polls this file),
|
|
392
446
|
// never injected into any agent's context — so verification costs zero tokens.
|
|
393
447
|
// Best-effort: a failed receipt write must not break delivery, so we swallow.
|
|
394
|
-
|
|
448
|
+
// `outcome` (control commands only) carries what verification actually saw.
|
|
449
|
+
// WITHOUT it a receipt proved the pusher TYPED the command, and send_command
|
|
450
|
+
// reported that as delivery:"confirmed" — proof of typing sold as proof of
|
|
451
|
+
// execution. `submitted` is the honest field: false means it is still sitting
|
|
452
|
+
// in the input, and the sender is told so instead of being reassured.
|
|
453
|
+
function writeReceipts(msgs, outcome) {
|
|
395
454
|
const lines = [];
|
|
396
455
|
for (const m of msgs) {
|
|
397
456
|
if (!m || !m.id) continue;
|
|
@@ -402,6 +461,13 @@ function writeReceipts(msgs) {
|
|
|
402
461
|
ts: Date.now(),
|
|
403
462
|
from: m.from,
|
|
404
463
|
control: m.control === true,
|
|
464
|
+
...(outcome
|
|
465
|
+
? {
|
|
466
|
+
submitted: outcome.submitted === true,
|
|
467
|
+
verified: outcome.verified === true,
|
|
468
|
+
...(outcome.reason ? { reason: outcome.reason } : {}),
|
|
469
|
+
}
|
|
470
|
+
: {}),
|
|
405
471
|
}),
|
|
406
472
|
);
|
|
407
473
|
}
|
|
@@ -417,45 +483,29 @@ function writeReceipts(msgs) {
|
|
|
417
483
|
// a compliant TUI treats the payload as inert data — embedded newlines can't
|
|
418
484
|
// submit lines or smuggle a "/command". Control commands (/clear, /compact) must
|
|
419
485
|
// paste RAW (bracketed=false) so the TUI still runs them as slash commands.
|
|
486
|
+
//
|
|
487
|
+
// The pipeline itself lives in ./submit.mjs, shared with scripts/coord-pusher.mjs
|
|
488
|
+
// so the two cannot drift again. This wrapper only supplies tmux.
|
|
489
|
+
const tmuxDeps = {
|
|
490
|
+
target: TMUX_TARGET,
|
|
491
|
+
buffer: BUFFER_NAME,
|
|
492
|
+
run: (args) => spawnSync("tmux", args, { encoding: "utf8" }),
|
|
493
|
+
runStdin: (args, payload) =>
|
|
494
|
+
new Promise((resolve, reject) => {
|
|
495
|
+
const load = spawn("tmux", args);
|
|
496
|
+
load.on("error", reject);
|
|
497
|
+
load.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tmux ${args[0]} exit ${code}`))));
|
|
498
|
+
load.stdin.end(payload);
|
|
499
|
+
}),
|
|
500
|
+
};
|
|
501
|
+
|
|
420
502
|
function pasteAndSubmit(payload, bracketed = false) {
|
|
421
|
-
return
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
"paste-buffer",
|
|
428
|
-
...(bracketed ? ["-p"] : []),
|
|
429
|
-
"-b",
|
|
430
|
-
BUFFER_NAME,
|
|
431
|
-
"-t",
|
|
432
|
-
TMUX_TARGET,
|
|
433
|
-
"-d",
|
|
434
|
-
]);
|
|
435
|
-
if (paste.status !== 0) {
|
|
436
|
-
return reject(new Error(`tmux paste-buffer: ${(paste.stderr ?? "").toString().trim()}`));
|
|
437
|
-
}
|
|
438
|
-
// Multi-line paste + Enter is racy: long buffers can still be flushing
|
|
439
|
-
// to the target app's input when Enter arrives, and many TUIs (Claude
|
|
440
|
-
// Code in particular) treat that first Enter as "still drafting,
|
|
441
|
-
// add newline" rather than "submit." Wait briefly so paste settles,
|
|
442
|
-
// then send Enter. Some apps additionally need a second Enter to
|
|
443
|
-
// exit paste-mode + submit; sending two Enters with a small gap is
|
|
444
|
-
// safe (worst case: harmless empty submit ignored by the app). For a
|
|
445
|
-
// slash command this also dismisses the autocomplete menu and submits.
|
|
446
|
-
setTimeout(() => {
|
|
447
|
-
const e1 = spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
|
|
448
|
-
if (e1.status !== 0) {
|
|
449
|
-
return reject(new Error(`tmux send-keys: ${(e1.stderr ?? "").toString().trim()}`));
|
|
450
|
-
}
|
|
451
|
-
setTimeout(() => {
|
|
452
|
-
spawnSync("tmux", ["send-keys", "-t", TMUX_TARGET, "Enter"]);
|
|
453
|
-
resolve();
|
|
454
|
-
}, 50);
|
|
455
|
-
}, 100);
|
|
456
|
-
});
|
|
457
|
-
load.stdin.end(payload);
|
|
458
|
-
});
|
|
503
|
+
return sharedPasteAndSubmit(tmuxDeps, payload, { bracketed });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Control commands go through the preflight + verify path, never the plain one.
|
|
507
|
+
function submitControlCommand(payload) {
|
|
508
|
+
return sharedSubmitControl(tmuxDeps, payload);
|
|
459
509
|
}
|
|
460
510
|
|
|
461
511
|
// Publish transport marker so list_agents can show this agent is push-capable.
|
|
@@ -470,24 +520,60 @@ const cleanupMarker = () => {
|
|
|
470
520
|
// already gone
|
|
471
521
|
}
|
|
472
522
|
};
|
|
523
|
+
// Log BEFORE cleaning up. A silent exit that also removes its own marker is
|
|
524
|
+
// indistinguishable from "never attached" — on 2026-07-28 all four pushers on
|
|
525
|
+
// the bus vanished at once and the cause could not be determined afterwards,
|
|
526
|
+
// because every other exit path logs and this one did not. Ruled out at the
|
|
527
|
+
// time: dead-pane self-exit (logs), die() (logs), SIGKILL/OOM (the marker
|
|
528
|
+
// would have survived), a normal exit (the poll intervals are not unref'd),
|
|
529
|
+
// doctor's reaper (has-session returned 0 for every live pane), tests (all
|
|
530
|
+
// isolate AGENT_COORD_DIR), coord-chat (not running), stop-agent.sh (would
|
|
531
|
+
// have killed the session too) and a parent process-group signal (the pusher
|
|
532
|
+
// has its own PGID and SID). That left a signal from outside the repo, with
|
|
533
|
+
// nothing recorded to identify it.
|
|
534
|
+
function logSignal(sig) {
|
|
535
|
+
process.stderr.write(
|
|
536
|
+
`[tmux-pusher] received ${sig} — cleaning up marker for '${AGENT_ID}' (pane ${TMUX_TARGET}, pid ${process.pid}) and exiting\n`,
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
473
540
|
process.on("SIGINT", () => {
|
|
541
|
+
logSignal("SIGINT");
|
|
474
542
|
cleanupMarker();
|
|
475
543
|
process.exit(0);
|
|
476
544
|
});
|
|
477
545
|
process.on("SIGTERM", () => {
|
|
546
|
+
logSignal("SIGTERM");
|
|
478
547
|
cleanupMarker();
|
|
479
548
|
process.exit(0);
|
|
480
549
|
});
|
|
481
550
|
process.on("exit", cleanupMarker);
|
|
482
551
|
|
|
483
552
|
function writeTransportMarker() {
|
|
484
|
-
|
|
553
|
+
// Merge over the existing marker, never rebuild from scratch: attach_agent
|
|
554
|
+
// wrote it first and owns fields only the server can know (its
|
|
555
|
+
// serverBuildMtime provenance stamp, and whatever comes next). A
|
|
556
|
+
// from-scratch rewrite here once dropped scriptMtime and silently disabled
|
|
557
|
+
// doctor's stale-pusher-script check — see hooks/marker.mjs.
|
|
558
|
+
let existing;
|
|
559
|
+
try {
|
|
560
|
+
existing = JSON.parse(readFileSync(TRANSPORT_FILE, "utf8"));
|
|
561
|
+
} catch {
|
|
562
|
+
existing = undefined; // first writer, or unreadable — our own fields stand alone
|
|
563
|
+
}
|
|
564
|
+
const marker = mergeTransportMarker(existing, {
|
|
485
565
|
agentId: AGENT_ID,
|
|
486
566
|
transport: "tmux-push",
|
|
487
567
|
pid: process.pid,
|
|
488
568
|
tmuxTarget: TMUX_TARGET,
|
|
489
569
|
since: Date.now(),
|
|
490
|
-
|
|
570
|
+
// MUST be included: attach_agent stamps this too, but this write happens
|
|
571
|
+
// afterwards and would otherwise clobber it — and doctor SKIPS markers
|
|
572
|
+
// without it (pre-v0.8.2 shape), so dropping it silently disabled the
|
|
573
|
+
// stale-pusher-script check for every local pusher. Our own stamp is the
|
|
574
|
+
// more truthful value anyway: it reflects the code THIS process loaded.
|
|
575
|
+
scriptMtime: SCRIPT_MTIME,
|
|
576
|
+
});
|
|
491
577
|
const tmp = TRANSPORT_FILE + ".tmp";
|
|
492
578
|
writeFileSync(tmp, JSON.stringify(marker));
|
|
493
579
|
renameSync(tmp, TRANSPORT_FILE);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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": {
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
|
-
"prepare": "tsc",
|
|
13
|
+
"prepare": "node scripts/check-self-dependency.mjs && tsc",
|
|
14
14
|
"start": "node dist/server.js",
|
|
15
15
|
"dev": "tsx src/server.ts",
|
|
16
|
-
"pretest": "tsc",
|
|
17
|
-
"test": "node
|
|
16
|
+
"pretest": "node scripts/check-self-dependency.mjs && tsc",
|
|
17
|
+
"test": "node scripts/check-test-count.mjs",
|
|
18
|
+
"test:raw": "node --test \"test/*.test.mjs\""
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"dist",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Guards against a recurring defect (see docs/QUEUE.md P1): this package's
|
|
3
|
+
// own name has twice been reintroduced into its own dependency graph —
|
|
4
|
+
// once manually, once by an `npm audit fix` run (commit de4e1ee added
|
|
5
|
+
// "agent-coord-mcp": "^0.8.0" back into dependencies while patching
|
|
6
|
+
// fast-uri/hono/ip-address/qs). A self-dependency makes `npm install`
|
|
7
|
+
// resolve against an old published copy of this package instead of the
|
|
8
|
+
// local source, silently breaking dev installs. Run on every `npm install`
|
|
9
|
+
// (via the "prepare" script) so a reintroduction fails loudly and
|
|
10
|
+
// immediately instead of sitting unnoticed until the next audit.
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
14
|
+
const DEP_FIELDS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
|
15
|
+
|
|
16
|
+
function selfDepFields(manifest) {
|
|
17
|
+
return DEP_FIELDS.filter(
|
|
18
|
+
(field) => manifest[field] && Object.prototype.hasOwnProperty.call(manifest[field], pkg.name),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const offenders = selfDepFields(pkg);
|
|
23
|
+
|
|
24
|
+
let lockOffender = false;
|
|
25
|
+
try {
|
|
26
|
+
const lock = JSON.parse(readFileSync(new URL("../package-lock.json", import.meta.url), "utf8"));
|
|
27
|
+
const rootPkg = lock.packages?.[""];
|
|
28
|
+
if (rootPkg && selfDepFields(rootPkg).length) lockOffender = true;
|
|
29
|
+
} catch {
|
|
30
|
+
/* no lockfile yet (fresh checkout pre-install) — nothing to check */
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (offenders.length || lockOffender) {
|
|
34
|
+
console.error(
|
|
35
|
+
`[check-self-dependency] "${pkg.name}" depends on itself` +
|
|
36
|
+
(offenders.length ? ` in package.json's ${offenders.join(", ")}` : "") +
|
|
37
|
+
(lockOffender ? `${offenders.length ? " and" : " in"} package-lock.json's root package` : "") +
|
|
38
|
+
`.\nThis has recurred before — a prior "npm audit fix" run reintroduced it (see docs/QUEUE.md P1). ` +
|
|
39
|
+
`Remove the self-reference from both files and re-run "npm install".`,
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run the suite and assert it ran the number of tests we expect.
|
|
3
|
+
//
|
|
4
|
+
// WHY: `npm test` was twice observed reporting FOUR fewer tests with zero
|
|
5
|
+
// failures (119→115, 145→141), never reproducible on a re-run. A suite whose
|
|
6
|
+
// count silently varies cannot be used as a gate signal — "all green" and "a
|
|
7
|
+
// file failed to load" look identical. This is the same silent-undercount
|
|
8
|
+
// class as a QUEUE parser returning zero items while passing every test.
|
|
9
|
+
//
|
|
10
|
+
// So the count is an assertion, not a statistic. A short run fails loudly.
|
|
11
|
+
//
|
|
12
|
+
// It compares PASS, not `# tests`. The first version compared `# tests`, and
|
|
13
|
+
// the sighting that followed showed why that was the wrong number: the runner
|
|
14
|
+
// reported `# tests 186 / # pass 182 / # fail 0` — its own totals not adding
|
|
15
|
+
// up, four results landing in no bucket at all. `# tests` read the full 186, so
|
|
16
|
+
// the guard would have passed a run in which four results were lost. The
|
|
17
|
+
// anomaly is a reporter gap, not a short run, and only the pass count sees it.
|
|
18
|
+
//
|
|
19
|
+
// When you add or remove tests, update EXPECTED_TESTS in the same commit —
|
|
20
|
+
// that is the point, not an inconvenience. `AGENT_COORD_EXPECTED_TESTS=n`
|
|
21
|
+
// overrides for a one-off (bisecting, a partial run); `=0` disables the check.
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
const EXPECTED_TESTS = 218;
|
|
26
|
+
|
|
27
|
+
const expected = Number(process.env.AGENT_COORD_EXPECTED_TESTS ?? EXPECTED_TESTS);
|
|
28
|
+
// Same glob the suite always used — `--test test/` would recurse differently
|
|
29
|
+
// on some node versions, and a runner that selects a different set of files
|
|
30
|
+
// is exactly the failure this script exists to catch.
|
|
31
|
+
const child = spawn(process.execPath, ["--test", "test/*.test.mjs"], {
|
|
32
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
33
|
+
shell: false,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
let out = "";
|
|
37
|
+
child.stdout.on("data", (chunk) => {
|
|
38
|
+
out += chunk;
|
|
39
|
+
process.stdout.write(chunk);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.on("exit", (code, signal) => {
|
|
43
|
+
if (signal) process.exit(1);
|
|
44
|
+
// The TAP summary lines the runner prints once, at the end.
|
|
45
|
+
const num = (label) => {
|
|
46
|
+
const m = out.match(new RegExp(`^# ${label} (\\d+)$`, "m"));
|
|
47
|
+
return m ? Number(m[1]) : null;
|
|
48
|
+
};
|
|
49
|
+
const tests = num("tests");
|
|
50
|
+
const pass = num("pass");
|
|
51
|
+
const fail = num("fail");
|
|
52
|
+
|
|
53
|
+
if (code !== 0 || (fail ?? 0) > 0) process.exit(code || 1);
|
|
54
|
+
|
|
55
|
+
if (expected === 0) {
|
|
56
|
+
console.log(`[check-test-count] count assertion disabled (ran ${tests ?? "?"} tests)`);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
if (pass === null) {
|
|
60
|
+
console.error("[check-test-count] FAIL — no '# pass' summary line in the runner output");
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
// The runner's own totals disagreeing is its own finding — report it as such
|
|
64
|
+
// rather than as a count mismatch, so nobody chases a missing test file.
|
|
65
|
+
if (tests !== null && tests !== pass + (fail ?? 0)) {
|
|
66
|
+
console.error(
|
|
67
|
+
`\n[check-test-count] FAIL — the runner's totals do not add up: ${tests} tests, ${pass} pass, ${fail ?? 0} fail.\n` +
|
|
68
|
+
` ${tests - pass - (fail ?? 0)} result(s) landed in no bucket. This is a reporter gap, not a missing test file.\n`,
|
|
69
|
+
);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
if (pass !== expected) {
|
|
73
|
+
console.error(
|
|
74
|
+
`\n[check-test-count] FAIL — ${pass} tests passed, expected ${expected}.\n` +
|
|
75
|
+
(pass < expected
|
|
76
|
+
? ` ${expected - pass} result(s) missing. Zero failures does NOT mean green here: a file that fails to load reports nothing.\n` +
|
|
77
|
+
` Re-run; if the count is stable, a test file is missing or erroring at import.\n`
|
|
78
|
+
: ` ${pass - expected} test(s) were added. Update EXPECTED_TESTS in scripts/check-test-count.mjs in the same commit.\n`),
|
|
79
|
+
);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
console.log(`[check-test-count] ${pass} tests passed, as expected.`);
|
|
83
|
+
});
|