agent-coord-mcp 0.26.19 → 0.26.21
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 +82 -0
- package/dist/capabilities.js +57 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +130 -0
- package/dist/gated-head.js.map +1 -0
- package/dist/server.js +23 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/queue-write.js +431 -0
- package/dist/tools/queue-write.js.map +1 -0
- package/dist/tools/records.js +406 -70
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +34 -5
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/shared.js.map +1 -1
- package/dist/tools/stall.js +2 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +82 -42
- package/dist/tools/transport.js.map +1 -1
- package/dist/tools/tree-provenance.js +107 -0
- package/dist/tools/tree-provenance.js.map +1 -0
- package/dist/tools/work.js +95 -3
- package/dist/tools/work.js.map +1 -1
- package/dist/transports/config.js +82 -0
- package/dist/transports/config.js.map +1 -0
- package/dist/transports/index.js +113 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/transports/tmux.js +140 -0
- package/dist/transports/tmux.js.map +1 -0
- package/dist/transports/types.js +86 -0
- package/dist/transports/types.js.map +1 -0
- package/hooks/peek-coord.mjs +0 -0
- package/hooks/tmux-pusher.mjs +33 -3
- package/package.json +14 -11
- package/scripts/coord-attention-clock.mjs +0 -0
- package/scripts/coord-node.sh +0 -0
- package/scripts/coord-stall-clock.mjs +0 -0
- package/scripts/coord-token.mjs +0 -0
- package/scripts/probe-tmux-liveness.sh +0 -0
- package/scripts/spawn-agent.sh +0 -0
- package/scripts/stop-agent.sh +0 -0
- package/scripts/typed-record-stats.mjs +0 -0
- package/src/capabilities.ts +104 -1
- package/src/gated-head.ts +134 -0
- package/src/server.ts +29 -0
- package/src/tools/queue-write.ts +485 -0
- package/src/tools/records.ts +409 -40
- package/src/tools/registry.ts +36 -5
- package/src/tools/shared.ts +12 -36
- package/src/tools/stall.ts +2 -1
- package/src/tools/transport.ts +96 -43
- package/src/tools/tree-provenance.ts +136 -0
- package/src/tools/work.ts +95 -3
- package/src/transports/config.ts +110 -0
- package/src/transports/index.ts +126 -0
- package/src/transports/tmux.ts +177 -0
- package/src/transports/types.ts +201 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TMUX TRANSPORT — every `tmux` shell-out in this package, in one file.
|
|
3
|
+
*
|
|
4
|
+
* Before this, `spawnSync("tmux", …)` appeared at 7 sites in `tools/transport.ts`
|
|
5
|
+
* and the `has-session` caveat below was written out TWICE, verbatim, at two of
|
|
6
|
+
* them. A rule duplicated in two comments is a rule that will be re-derived
|
|
7
|
+
* wrongly at the third site somebody adds.
|
|
8
|
+
*
|
|
9
|
+
* WHAT THIS FILE DOES NOT DO, on purpose: spawn or reap the pusher process,
|
|
10
|
+
* read receipts, or schedule reminders. Delivery is a second process for THIS
|
|
11
|
+
* transport and will not be for a socket one, so that machinery stays with the
|
|
12
|
+
* tools that own it and reaches this file through `TmuxHost` below. An interface
|
|
13
|
+
* that baked in a pusher could not host an implementation that has none.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { TMUX_PUSH, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
|
|
17
|
+
/** `tmux -V` — is tmux on this host at all? */
|
|
18
|
+
export function tmuxAvailable() {
|
|
19
|
+
return spawnSync("tmux", ["-V"]).status === 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* DOES THIS TARGET EXIST? The only tmux probe with discriminating power.
|
|
23
|
+
*
|
|
24
|
+
* `has-session` VALIDATES THE TARGET; `display-message -p -t <target> "ok"`
|
|
25
|
+
* DOES NOT — tmux exits 0 for any target, including a pane killed a moment ago,
|
|
26
|
+
* so that probe had ZERO discriminating power and reported every dead pane
|
|
27
|
+
* alive. Pinned to the BEHAVIOUR, not a version: measured identical on tmux
|
|
28
|
+
* 3.6b, 3.7b. A version-pinned claim rots on the next upgrade.
|
|
29
|
+
*
|
|
30
|
+
* Positive control, both directions, re-run on tmux 3.7b while extracting this:
|
|
31
|
+
* bogus target `%99999` -> has-session exit 1, display-message exit 0; live pane
|
|
32
|
+
* `%8` -> both exit 0. The wrong probe is wrong in only one direction, which is
|
|
33
|
+
* why it survived: it never reports a live pane dead.
|
|
34
|
+
*/
|
|
35
|
+
export function paneExists(target) {
|
|
36
|
+
return spawnSync("tmux", ["has-session", "-t", target]).status === 0;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `has-session` with the failure text, for the one caller that reports it.
|
|
40
|
+
*
|
|
41
|
+
* `paneExists` is the boolean most sites want; attach quotes tmux's own stderr
|
|
42
|
+
* back to the user, and losing that text would make a refusal less useful while
|
|
43
|
+
* still typechecking — the exact class of silent regression this task guards.
|
|
44
|
+
*/
|
|
45
|
+
export function probePane(target) {
|
|
46
|
+
const probe = spawnSync("tmux", ["has-session", "-t", target]);
|
|
47
|
+
return { exists: probe.status === 0, stderr: (probe.stderr ?? "").toString().trim() };
|
|
48
|
+
}
|
|
49
|
+
/** `tmux -V` output, or undefined when tmux is absent. */
|
|
50
|
+
export function tmuxVersion() {
|
|
51
|
+
const probe = spawnSync("tmux", ["-V"]);
|
|
52
|
+
if (probe.status !== 0)
|
|
53
|
+
return undefined;
|
|
54
|
+
return (probe.stdout ?? "").toString().trim() || undefined;
|
|
55
|
+
}
|
|
56
|
+
export class TmuxTransport {
|
|
57
|
+
kind;
|
|
58
|
+
#host;
|
|
59
|
+
constructor(host, kind = TMUX_PUSH) {
|
|
60
|
+
this.#host = host;
|
|
61
|
+
this.kind = kind;
|
|
62
|
+
}
|
|
63
|
+
available() {
|
|
64
|
+
return tmuxAvailable();
|
|
65
|
+
}
|
|
66
|
+
attach(args) {
|
|
67
|
+
return this.#host.attach(args);
|
|
68
|
+
}
|
|
69
|
+
detach(agentId) {
|
|
70
|
+
return this.#host.detach(agentId);
|
|
71
|
+
}
|
|
72
|
+
push(marker, text) {
|
|
73
|
+
return this.#host.push(marker, text);
|
|
74
|
+
}
|
|
75
|
+
sendControl(marker, cmd) {
|
|
76
|
+
return this.#host.sendControl(marker, cmd);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* THREE ANSWERS, AND THE THIRD IS LOAD-BEARING.
|
|
80
|
+
*
|
|
81
|
+
* Each `unknown` below was previously a `continue` with a comment. The states
|
|
82
|
+
* are preserved exactly, because "we could not look" is not evidence of death
|
|
83
|
+
* and a reaper that cannot tell them apart kills live sessions:
|
|
84
|
+
*
|
|
85
|
+
* · not a tmux marker -> unknown (this transport cannot speak for it)
|
|
86
|
+
* · remote (foreign host) -> unknown (no local pane; heartbeat decides)
|
|
87
|
+
* · no target recorded -> unknown (nothing to probe)
|
|
88
|
+
* · tmux missing on host -> unknown (the instrument is absent, not the pane)
|
|
89
|
+
* · target absent -> dead
|
|
90
|
+
* · pusher gone, pane alive -> dead, and it says which half failed
|
|
91
|
+
*/
|
|
92
|
+
async probe(marker) {
|
|
93
|
+
if (!isTmuxKind(marker.transport)) {
|
|
94
|
+
return { state: "unknown", reason: `transport "${marker.transport}" is not tmux` };
|
|
95
|
+
}
|
|
96
|
+
if (!isLocallyProbeable(marker.transport)) {
|
|
97
|
+
return {
|
|
98
|
+
state: "unknown",
|
|
99
|
+
reason: `${marker.transport} runs on another host${marker.host ? ` (${marker.host})` : ""} — no local pane to probe; liveness is heartbeat-based`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const target = targetOf(marker);
|
|
103
|
+
if (!target)
|
|
104
|
+
return { state: "unknown", reason: "no target recorded on the marker" };
|
|
105
|
+
if (!tmuxAvailable())
|
|
106
|
+
return { state: "unknown", reason: "tmux is not available on this host" };
|
|
107
|
+
if (!paneExists(target))
|
|
108
|
+
return { state: "dead", reason: `pane ${target} does not exist` };
|
|
109
|
+
if (!this.#host.pusherAlive(marker)) {
|
|
110
|
+
return { state: "dead", reason: `pane ${target} is alive but its pusher (pid ${marker.pid}) is gone` };
|
|
111
|
+
}
|
|
112
|
+
return { state: "live" };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Reap pushers whose pane has gone, and say which ones could not be judged.
|
|
116
|
+
*
|
|
117
|
+
* `unprobeable` is NOT a residual list nobody reads — it is the distinction
|
|
118
|
+
* PRODUCTION_ROADMAP Phase 5.3 requires between "dead" and "cannot probe".
|
|
119
|
+
* An agent whose liveness is unknown is left alone and reported, never reaped.
|
|
120
|
+
*/
|
|
121
|
+
async reapWedged(markers) {
|
|
122
|
+
const reaped = [];
|
|
123
|
+
const unprobeable = [];
|
|
124
|
+
for (const marker of markers) {
|
|
125
|
+
const live = await this.probe(marker);
|
|
126
|
+
if (live.state === "unknown") {
|
|
127
|
+
unprobeable.push(marker.agentId);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (live.state === "live")
|
|
131
|
+
continue;
|
|
132
|
+
if (this.#host.killPusher(marker))
|
|
133
|
+
reaped.push(marker.agentId);
|
|
134
|
+
else
|
|
135
|
+
unprobeable.push(marker.agentId);
|
|
136
|
+
}
|
|
137
|
+
return { reaped, unprobeable };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=tmux.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tmux.js","sourceRoot":"","sources":["../../src/transports/tmux.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TRANSPORT SEAM (Phase 5.4 Task 2).
|
|
3
|
+
*
|
|
4
|
+
* This interface is DERIVED from the 12 `"tmux-push"` literals and the 7
|
|
5
|
+
* `spawnSync("tmux", …)` shell-outs that were spread across `tools/`, not
|
|
6
|
+
* designed top-down. Where the phase doc's sketch and the code disagreed, the
|
|
7
|
+
* code won — three times, each recorded below, because the gate for this task is
|
|
8
|
+
* that a running fleet cannot tell the difference.
|
|
9
|
+
*
|
|
10
|
+
* 1. `tmux-push-remote` IS A LIVE KIND. The doc's sketch had two kinds; the code
|
|
11
|
+
* has three. `registry.ts` gives remote markers heartbeat-based liveness
|
|
12
|
+
* because their pid is 0 on a foreign host, and `server.ts` documents the
|
|
13
|
+
* value as the wire contract for `scripts/coord-pusher.mjs`. A union without
|
|
14
|
+
* it silently reclassifies every remote agent as not-tmux.
|
|
15
|
+
* 2. THE MARKER HAS NINE FIELDS, NOT SIX. `scriptMtime`, `serverBuildMtime` and
|
|
16
|
+
* `rooms` are absent from the sketch and each one closes a defect that
|
|
17
|
+
* actually shipped. They are kept verbatim, including the rule they share:
|
|
18
|
+
* ABSENT MEANS UNKNOWN, NEVER "ON" — the same principle as `Liveness.unknown`
|
|
19
|
+
* below, one field deeper.
|
|
20
|
+
* 3. `target` IS ADDITIVE, NOT A RENAME. See `targetOf`.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Every transport value that can appear in a marker on disk.
|
|
24
|
+
*
|
|
25
|
+
* Not `string`: the whole defect being fixed is code branching on a literal
|
|
26
|
+
* instead of reading the field, and a `string` discriminant cannot tell the
|
|
27
|
+
* compiler that a branch was missed. Not a two-member union either — see (1).
|
|
28
|
+
*/
|
|
29
|
+
export const TMUX_PUSH = "tmux-push";
|
|
30
|
+
export const TMUX_PUSH_REMOTE = "tmux-push-remote";
|
|
31
|
+
export const HERDR = "herdr";
|
|
32
|
+
export const TRANSPORT_KINDS = [TMUX_PUSH, TMUX_PUSH_REMOTE, HERDR];
|
|
33
|
+
/**
|
|
34
|
+
* The tmux family. Both members are delivered by a pusher process typing into a
|
|
35
|
+
* pane; they differ in WHERE that pane is, which is why liveness splits below.
|
|
36
|
+
*
|
|
37
|
+
* THIS IS THE ONE PLACE THE TMUX LITERALS LIVE. Twelve call sites used to spell
|
|
38
|
+
* them; they now ask.
|
|
39
|
+
*/
|
|
40
|
+
const TMUX_KINDS = new Set([TMUX_PUSH, TMUX_PUSH_REMOTE]);
|
|
41
|
+
/** Is this marker carried by the tmux family (local OR remote)? */
|
|
42
|
+
export function isTmuxKind(transport) {
|
|
43
|
+
return transport !== undefined && TMUX_KINDS.has(transport);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Is this marker's pane on THIS host, so that a local probe means anything?
|
|
47
|
+
*
|
|
48
|
+
* The distinction every `marker.transport !== "tmux-push"` site was making by
|
|
49
|
+
* hand, with a comment explaining "remote = can't verify". Naming it stops the
|
|
50
|
+
* next person re-deriving it — and re-deriving it wrongly is how a remote agent
|
|
51
|
+
* gets reported dead because a pane that was never local did not answer.
|
|
52
|
+
*/
|
|
53
|
+
export function isLocallyProbeable(transport) {
|
|
54
|
+
return transport === TMUX_PUSH;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Is liveness for this marker decided by the registry heartbeat rather than by a
|
|
58
|
+
* local pid? True only for the remote kind, whose pid is 0 on a foreign host.
|
|
59
|
+
*/
|
|
60
|
+
export function isRemoteTmuxKind(transport) {
|
|
61
|
+
return transport === TMUX_PUSH_REMOTE;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read a marker's address, preferring the generic field and falling back to the
|
|
65
|
+
* tmux-specific one.
|
|
66
|
+
*
|
|
67
|
+
* This is the read half of the dual-write. Call it rather than touching either
|
|
68
|
+
* field: a consumer that reads only `tmuxTarget` goes blind the day a herdr
|
|
69
|
+
* marker appears, and one that reads only `target` is blind to every marker on
|
|
70
|
+
* disk today.
|
|
71
|
+
*/
|
|
72
|
+
export function targetOf(marker) {
|
|
73
|
+
return marker.target ?? marker.tmuxTarget;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Stamp an address into BOTH fields, so the marker is legible to the current
|
|
77
|
+
* code and to whatever a revert restores.
|
|
78
|
+
*/
|
|
79
|
+
export function withTarget(marker, target) {
|
|
80
|
+
if (target === undefined)
|
|
81
|
+
return marker;
|
|
82
|
+
return { ...marker, target, tmuxTarget: target };
|
|
83
|
+
}
|
|
84
|
+
/** Keystroke-shaped commands a transport must deliver to an interactive pane. */
|
|
85
|
+
export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"];
|
|
86
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/transports/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,WAAoB,CAAC;AAC9C,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAA2B,CAAC;AAC5D,MAAM,CAAC,MAAM,KAAK,GAAG,OAAgB,CAAC;AAItC,MAAM,CAAC,MAAM,eAAe,GAA6B,CAAC,SAAS,EAAE,gBAAgB,EAAE,KAAK,CAAU,CAAC;AAEvG;;;;;;GAMG;AACH,MAAM,UAAU,GAAG,IAAI,GAAG,CAAS,CAAC,SAAS,EAAE,gBAAgB,CAAC,CAAC,CAAC;AAElE,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,SAA6B;IACtD,OAAO,SAAS,KAAK,SAAS,IAAI,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AAC9D,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,SAA6B;IAC9D,OAAO,SAAS,KAAK,SAAS,CAAC;AACjC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,SAA6B;IAC5D,OAAO,SAAS,KAAK,gBAAgB,CAAC;AACxC,CAAC;AAoDD;;;;;;;;GAQG;AACH,MAAM,UAAU,QAAQ,CAAC,MAAsD;IAC7E,OAAO,MAAM,CAAC,MAAM,IAAI,MAAM,CAAC,UAAU,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAmB,MAAS,EAAE,MAA0B;IAChF,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAC;IACxC,OAAO,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC;AACnD,CAAC;AAeD,iFAAiF;AACjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,OAAO,EAAE,SAAS,EAAE,eAAe,CAAU,CAAC"}
|
package/hooks/peek-coord.mjs
CHANGED
|
File without changes
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -288,7 +288,11 @@ function readJsonl(file) {
|
|
|
288
288
|
// Allowlisted control commands the bus may type into this CLI. Mirrors
|
|
289
289
|
// CONTROL_COMMANDS in src/tools/transport.ts and scripts/coord-pusher.mjs
|
|
290
290
|
// (test/allowlist-parity.test.mjs locks the three); kept tiny + literal so a bugged/compromised
|
|
291
|
-
// sender can't smuggle an arbitrary slash command
|
|
291
|
+
// sender can't smuggle an arbitrary slash command onto the RAW submit path.
|
|
292
|
+
// ⚠ THIS SET IS THE GATE. It used to be backed by a second check that dropped any
|
|
293
|
+
// text starting with "/"; that check was removed in ⟨q-f14692ca⟩ because it silently
|
|
294
|
+
// discarded legitimate messages and never saw the multi-line shape it was aimed at.
|
|
295
|
+
// Membership here, plus bracketed paste on the peer path, is now the whole defence.
|
|
292
296
|
const CONTROL_COMMANDS = new Set(["/clear", "/compact", "/reload-skills"]);
|
|
293
297
|
|
|
294
298
|
// Shells we refuse to inject into: if the pane's foreground command is one of
|
|
@@ -321,9 +325,35 @@ function isControl(m) {
|
|
|
321
325
|
function shouldInject(m) {
|
|
322
326
|
if (!m || m.from === AGENT_ID) return false;
|
|
323
327
|
if (ALLOWLIST.length > 0 && !ALLOWLIST.includes(m.from)) return false;
|
|
324
|
-
// Authorized control command —
|
|
328
|
+
// Authorized control command — injected RAW later, so the allowlist in
|
|
329
|
+
// `isControl` is the gate that matters. Checked first and unchanged.
|
|
325
330
|
if (isControl(m)) return true;
|
|
326
|
-
|
|
331
|
+
|
|
332
|
+
// ⛔ THE SLASH DROP USED TO LIVE HERE AND IT IS GONE ON PURPOSE (⟨q-f14692ca⟩).
|
|
333
|
+
//
|
|
334
|
+
// It returned false for any text starting with "/", which SILENTLY discarded
|
|
335
|
+
// the message: the sender was told nothing, and `collectSource` advances the
|
|
336
|
+
// offset past rejected lines, so it was never retried either. Measured in an
|
|
337
|
+
// isolated pane: a plain message and a trailing message both arrived, the
|
|
338
|
+
// slash-leading one between them did not, and the pusher log said nothing.
|
|
339
|
+
//
|
|
340
|
+
// AND IT WAS PROTECTING NOTHING THAT IS NOT ALREADY PROTECTED TWICE, measured
|
|
341
|
+
// rather than reasoned:
|
|
342
|
+
// 1. `formatBatch` prefixes every peer line with ` [DM hh:mm from] `, so a
|
|
343
|
+
// single-line "/foo" reaches the pane with ZERO lines starting with "/" —
|
|
344
|
+
// it is chat text, not a command.
|
|
345
|
+
// 2. Ordinary batches paste with `bracketed = true`, which makes the whole
|
|
346
|
+
// payload inert: embedded newlines cannot submit a line or smuggle a
|
|
347
|
+
// "/command". That is the defence that actually covers the multi-line
|
|
348
|
+
// case, which this check never caught — `trimStart()` only ever examined
|
|
349
|
+
// the FIRST line, so "opener\n/clear" passed straight through it.
|
|
350
|
+
//
|
|
351
|
+
// So the old guard was wrong in both directions at once: it dropped safe
|
|
352
|
+
// single-line messages, and it did not see the multi-line shape it was aimed
|
|
353
|
+
// at. Removing it restores delivery; the protection that matters is the
|
|
354
|
+
// allowlist above plus bracketed paste, and BOTH are pinned by tests in
|
|
355
|
+
// test/slash-message-delivery.test.mjs. ⚠ If anyone makes the peer path paste
|
|
356
|
+
// raw, those tests fail — which is the price of not having this check.
|
|
327
357
|
return true;
|
|
328
358
|
}
|
|
329
359
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.21",
|
|
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": {
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
"coord-chat": "scripts/coord-chat.mjs",
|
|
9
9
|
"coord-pusher": "scripts/coord-pusher.mjs"
|
|
10
10
|
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
13
|
+
"build": "pnpm run clean && tsc",
|
|
14
|
+
"prepare": "node scripts/check-self-dependency.mjs && pnpm run clean && tsc",
|
|
15
|
+
"prepack": "node scripts/check-self-dependency.mjs && pnpm run build",
|
|
16
|
+
"prepublishOnly": "node scripts/check-publish-tool.mjs",
|
|
17
|
+
"start": "node dist/server.js",
|
|
18
|
+
"dev": "tsx src/server.ts",
|
|
19
|
+
"pretest": "node scripts/check-self-dependency.mjs && node scripts/check-optional-call.mjs && tsc",
|
|
20
|
+
"test": "node scripts/check-test-count.mjs",
|
|
21
|
+
"test:raw": "node --test \"test/*.test.mjs\""
|
|
22
|
+
},
|
|
11
23
|
"files": [
|
|
12
24
|
"dist",
|
|
13
25
|
"src",
|
|
@@ -53,14 +65,5 @@
|
|
|
53
65
|
"@types/proper-lockfile": "^4.1.4",
|
|
54
66
|
"tsx": "^4.23.12",
|
|
55
67
|
"typescript": "^7.0.2"
|
|
56
|
-
},
|
|
57
|
-
"scripts": {
|
|
58
|
-
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
59
|
-
"build": "pnpm run clean && tsc",
|
|
60
|
-
"start": "node dist/server.js",
|
|
61
|
-
"dev": "tsx src/server.ts",
|
|
62
|
-
"pretest": "node scripts/check-self-dependency.mjs && node scripts/check-optional-call.mjs && tsc",
|
|
63
|
-
"test": "node scripts/check-test-count.mjs",
|
|
64
|
-
"test:raw": "node --test \"test/*.test.mjs\""
|
|
65
68
|
}
|
|
66
|
-
}
|
|
69
|
+
}
|
|
File without changes
|
package/scripts/coord-node.sh
CHANGED
|
File without changes
|
|
File without changes
|
package/scripts/coord-token.mjs
CHANGED
|
File without changes
|
|
File without changes
|
package/scripts/spawn-agent.sh
CHANGED
|
File without changes
|
package/scripts/stop-agent.sh
CHANGED
|
File without changes
|
|
File without changes
|
package/src/capabilities.ts
CHANGED
|
@@ -39,6 +39,14 @@ import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
|
39
39
|
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
40
|
import { recordAuthorityFor } from "./roles.js";
|
|
41
41
|
import { subscriptionHealth } from "./tools/events.js";
|
|
42
|
+
import {
|
|
43
|
+
configuredTransport,
|
|
44
|
+
runningTransport,
|
|
45
|
+
isTmuxKind,
|
|
46
|
+
targetOf,
|
|
47
|
+
type TransportKind,
|
|
48
|
+
} from "./transports/index.js";
|
|
49
|
+
import { readAllTransportMarkers } from "./tools/registry.js";
|
|
42
50
|
|
|
43
51
|
export type ProbeResult = {
|
|
44
52
|
id: string;
|
|
@@ -129,6 +137,44 @@ const PROBES: Probe[] = [
|
|
|
129
137
|
},
|
|
130
138
|
];
|
|
131
139
|
|
|
140
|
+
/**
|
|
141
|
+
* TWO CLAIMS, AND THE TYPE MAKES YOU CARRY BOTH (Phase 5.4 Task 3.3).
|
|
142
|
+
*
|
|
143
|
+
* *A config value is a label someone typed.* `configured` is that label;
|
|
144
|
+
* `running` is answered by CALLING the transport in this process. They are
|
|
145
|
+
* separate fields because they are separate facts, and `agrees` exists so a
|
|
146
|
+
* reader cannot skim one and believe the other.
|
|
147
|
+
*
|
|
148
|
+
* ⛔ THIS IS THE MACHINE-READABLE FORM OF A MISTAKE MADE IN PROSE. An hour
|
|
149
|
+
* before this shipped, a Task 2 contract asserted that "every seat in this fleet
|
|
150
|
+
* is running on the code you are refactoring." Measured afterwards: every server
|
|
151
|
+
* and every pusher loads the global install at 0.26.19 while `main` declared
|
|
152
|
+
* 0.26.20 — the fleet was running PRE-refactor code and always had been. The
|
|
153
|
+
* claim came from what the repo said, not from what any process answered. That
|
|
154
|
+
* is `configured` reported as `running`, in English instead of in a type.
|
|
155
|
+
*
|
|
156
|
+
* `running` is `undefined` when nothing is wired, which is NOT "tmux by
|
|
157
|
+
* default": a process with no transport delivers nothing, and substituting a
|
|
158
|
+
* default there would restage the same substitution one layer down.
|
|
159
|
+
*/
|
|
160
|
+
export type TransportCapability = {
|
|
161
|
+
configured: TransportKind;
|
|
162
|
+
/** Answered by calling the transport, never by reading config. */
|
|
163
|
+
running: TransportKind | undefined;
|
|
164
|
+
agrees: boolean;
|
|
165
|
+
/** What was called and what came back. */
|
|
166
|
+
evidence: string;
|
|
167
|
+
/** Where `configured` came from — config file, env, or the built-in default. */
|
|
168
|
+
configuredSource: "config" | "env" | "default";
|
|
169
|
+
/**
|
|
170
|
+
* MIXED FLEET, MADE LOUD (3.4). Agents whose marker names a transport other
|
|
171
|
+
* than the running one. Whole-fleet is the rule; this is the code noticing
|
|
172
|
+
* when reality disagrees with the rule rather than trusting it — at 2-of-5
|
|
173
|
+
* stall coverage a silent disagreement is a failure nobody can see.
|
|
174
|
+
*/
|
|
175
|
+
disagreeingAgents: { agentId: string; marker: string }[];
|
|
176
|
+
};
|
|
177
|
+
|
|
132
178
|
export type CapabilityReport = {
|
|
133
179
|
/**
|
|
134
180
|
* WHICH PROCESS ANSWERED. Not evidence of anything — context, so a reader can
|
|
@@ -140,6 +186,8 @@ export type CapabilityReport = {
|
|
|
140
186
|
probes: ProbeResult[];
|
|
141
187
|
missing: string[];
|
|
142
188
|
ok: boolean;
|
|
189
|
+
/** Absent only if the probe itself threw — see `probeTransport`. */
|
|
190
|
+
transport?: TransportCapability;
|
|
143
191
|
note: string;
|
|
144
192
|
};
|
|
145
193
|
|
|
@@ -182,7 +230,62 @@ import { resolveServerIdentity } from "./server-identity.js";
|
|
|
182
230
|
|
|
183
231
|
export const capabilitiesSchema = {} as const;
|
|
184
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Build the transport capability by CALLING things, then compare.
|
|
235
|
+
*
|
|
236
|
+
* Deliberately async and deliberately separate from `probeCapabilities`, which
|
|
237
|
+
* is synchronous: the transport answer requires I/O (a `tmux -V`, a pane probe),
|
|
238
|
+
* and making the sync report do I/O to obtain it would have meant reading the
|
|
239
|
+
* config instead — which is the substitution this whole field exists to refuse.
|
|
240
|
+
*/
|
|
241
|
+
export async function probeTransport(): Promise<TransportCapability> {
|
|
242
|
+
const conf = configuredTransport();
|
|
243
|
+
const running = await runningTransport();
|
|
244
|
+
|
|
245
|
+
// 3.4 — a mixed fleet must be detectable and LOUD. Every marker on disk is
|
|
246
|
+
// read and any that names a different transport is listed by agent, because a
|
|
247
|
+
// count alone tells you something is wrong and not where to look.
|
|
248
|
+
const disagreeingAgents: { agentId: string; marker: string }[] = [];
|
|
249
|
+
try {
|
|
250
|
+
// READ-ONLY on purpose. The reaping loader would delete markers it judged
|
|
251
|
+
// not-live, so a diagnostic would mutate the fleet it is describing — and it
|
|
252
|
+
// would hide the remote kind, whose liveness needs a registry heartbeat.
|
|
253
|
+
for (const { marker, live } of await readAllTransportMarkers()) {
|
|
254
|
+
if (running.kind !== undefined && marker.transport !== running.kind) {
|
|
255
|
+
const where = isTmuxKind(marker.transport) ? targetOf(marker) : undefined;
|
|
256
|
+
disagreeingAgents.push({
|
|
257
|
+
agentId: marker.agentId,
|
|
258
|
+
marker: `${marker.transport}${where ? ` (${where})` : ""}${live ? "" : " [stale]"}`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
// An unreadable transports dir is not evidence of a uniform fleet. Left
|
|
264
|
+
// empty, and the caller can see `running` was still answered.
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
configured: conf.kind,
|
|
269
|
+
running: running.kind,
|
|
270
|
+
agrees: running.kind === conf.kind,
|
|
271
|
+
evidence: running.evidence,
|
|
272
|
+
configuredSource: conf.source,
|
|
273
|
+
disagreeingAgents,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
185
277
|
export async function capabilitiesTool() {
|
|
186
278
|
const id = resolveServerIdentity();
|
|
187
|
-
|
|
279
|
+
const report = probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
280
|
+
try {
|
|
281
|
+
return { ...report, transport: await probeTransport() };
|
|
282
|
+
} catch (e) {
|
|
283
|
+
// A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
|
|
284
|
+
// refuses at startup by design, and this verb is exactly what an operator
|
|
285
|
+
// reaches for to find out why — so it must still answer, and say what threw.
|
|
286
|
+
return {
|
|
287
|
+
...report,
|
|
288
|
+
transportError: `transport probe threw: ${(e as Error).message}`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
188
291
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ⛔⛆ THE THING MERGED MUST BE THE THING GATED — AND NOTHING COMPARED THEM.
|
|
3
|
+
*
|
|
4
|
+
* `⟨q-6a4f0c38⟩`, from a real breach on kit#268. The sequence, which no single
|
|
5
|
+
* seat could see alone:
|
|
6
|
+
*
|
|
7
|
+
* 10:23:14Z QA posts PASS bound to 6051193
|
|
8
|
+
* 10:23:22Z the merge runs <- 8 seconds later
|
|
9
|
+
* but the branch head was 1d6deab by then: an author force-pushed
|
|
10
|
+
* 10:28:06Z QA re-issues PASS bound to 1d6deab, AFTER the fact
|
|
11
|
+
*
|
|
12
|
+
* Three controls had to fail together: the force-push created the opportunity,
|
|
13
|
+
* the merge did not re-read the head, and the recording step did not compare.
|
|
14
|
+
* This module is the predicate for the two that are mechanisable.
|
|
15
|
+
*
|
|
16
|
+
* ⭐ WHY THE CHECK MUST BE TEMPORAL, and this is the trap that makes the naive
|
|
17
|
+
* version certify the very breach it was built for: by the time anyone looks,
|
|
18
|
+
* a PASS bound to `1d6deab` EXISTS. "Is there a PASS for the merged head?" is
|
|
19
|
+
* TRUE for #268 today. Only "was there one AT OR BEFORE the merge" is false.
|
|
20
|
+
* A re-issued verdict is an honest record that the CONTENT was verified; it is
|
|
21
|
+
* not evidence that the MERGE was gated, and conflating them erases the event.
|
|
22
|
+
*
|
|
23
|
+
* ⭐ WHY THE SHA COMPARISON IS PREFIX-TOLERANT RATHER THAN EQUALITY. Measured
|
|
24
|
+
* across all 45 verdict records on this fleet's room log:
|
|
25
|
+
*
|
|
26
|
+
* headRefOid length: 7 chars -> 14 records, 40 chars -> 31 records
|
|
27
|
+
*
|
|
28
|
+
* Verdicts are written by hand and abbreviate. An exact compare against `gh`'s
|
|
29
|
+
* 40-char head would report "never gated" on ~31% of verdicts that DID gate —
|
|
30
|
+
* false positives on correct merges, which is the failure that gets a check
|
|
31
|
+
* switched off. MIN_SHA guards the other direction: a 4-character "sha" is not
|
|
32
|
+
* an identifier, it is a collision, so it is REFUSED rather than matched.
|
|
33
|
+
*
|
|
34
|
+
* ⛔ AND "COULD NOT CHECK" IS NEVER "CHECKED AND CLEAN" — the rule this repo
|
|
35
|
+
* already applies in `versionDrift`. No verdict found, an unreadable log and an
|
|
36
|
+
* unreachable `gh` are each a REFUSAL with a reason, never a pass.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Below this, an abbreviation is a collision rather than an identifier. */
|
|
40
|
+
export const MIN_SHA = 7;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Do two object names refer to the same commit, allowing either to be
|
|
44
|
+
* abbreviated? Case-insensitive; hex only; both must reach MIN_SHA.
|
|
45
|
+
*/
|
|
46
|
+
export function shaAgrees(a: string | undefined, b: string | undefined): boolean {
|
|
47
|
+
const x = (a ?? "").trim().toLowerCase();
|
|
48
|
+
const y = (b ?? "").trim().toLowerCase();
|
|
49
|
+
if (!/^[0-9a-f]+$/.test(x) || !/^[0-9a-f]+$/.test(y)) return false;
|
|
50
|
+
if (x.length < MIN_SHA || y.length < MIN_SHA) return false;
|
|
51
|
+
return x.length <= y.length ? y.startsWith(x) : x.startsWith(y);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type PassVerdict = { head: string; ts: number; from: string; result: string };
|
|
55
|
+
|
|
56
|
+
/** The PR number a verdict record is about, from its `cites`. */
|
|
57
|
+
function citedPr(cites: unknown): string | null {
|
|
58
|
+
if (!Array.isArray(cites)) return null;
|
|
59
|
+
for (const c of cites) {
|
|
60
|
+
const ref = String((c as { ref?: unknown })?.ref ?? "");
|
|
61
|
+
const m = ref.match(/#(\d+)/);
|
|
62
|
+
if (m) return m[1];
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every verdict record in a room log that is about PR `pr`.
|
|
69
|
+
*
|
|
70
|
+
* Tolerant of unparseable lines by SKIPPING them and reporting how many, rather
|
|
71
|
+
* than throwing: a log this cannot fully read still answers the question for the
|
|
72
|
+
* lines it can, and the caller is told the denominator it actually saw.
|
|
73
|
+
*/
|
|
74
|
+
export function verdictsFor(logText: string, pr: string): { verdicts: PassVerdict[]; lines: number; unparsed: number } {
|
|
75
|
+
const lines = logText.split("\n").filter((l) => l.trim());
|
|
76
|
+
let unparsed = 0;
|
|
77
|
+
const verdicts: PassVerdict[] = [];
|
|
78
|
+
for (const l of lines) {
|
|
79
|
+
let o: Record<string, unknown>;
|
|
80
|
+
try {
|
|
81
|
+
o = JSON.parse(l) as Record<string, unknown>;
|
|
82
|
+
} catch {
|
|
83
|
+
unparsed++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const r = o.record as { type?: string; payload?: Record<string, unknown>; cites?: unknown } | undefined;
|
|
87
|
+
if (!r || r.type !== "verdict") continue;
|
|
88
|
+
if (citedPr(r.cites) !== pr) continue;
|
|
89
|
+
verdicts.push({
|
|
90
|
+
head: String(r.payload?.headRefOid ?? ""),
|
|
91
|
+
ts: Number(o.ts ?? 0),
|
|
92
|
+
from: String(o.from ?? ""),
|
|
93
|
+
result: String(r.payload?.result ?? ""),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return { verdicts, lines: lines.length, unparsed };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type GateAnswer =
|
|
100
|
+
| { gated: true; by: PassVerdict }
|
|
101
|
+
| { gated: false; reason: string; crossed?: { gatedSha: string; at: number }[] };
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Was `head` gated by a PASS that existed at or before `at`?
|
|
105
|
+
*
|
|
106
|
+
* `at` is the decisive argument. Pass the merge time to ask "was this merge
|
|
107
|
+
* gated"; pass `Date.now()` to ask "is this head gated right now", which is the
|
|
108
|
+
* pre-merge question. Same predicate, two positions.
|
|
109
|
+
*/
|
|
110
|
+
export function gatedAt(verdicts: PassVerdict[], head: string, at: number): GateAnswer {
|
|
111
|
+
const passes = verdicts.filter((v) => v.result === "pass");
|
|
112
|
+
if (passes.length === 0) {
|
|
113
|
+
return { gated: false, reason: `no PASS verdict was ever recorded for this PR — not checked, which is not the same as checked and passing` };
|
|
114
|
+
}
|
|
115
|
+
const inTime = passes.filter((v) => v.ts <= at);
|
|
116
|
+
if (inTime.length === 0) {
|
|
117
|
+
return {
|
|
118
|
+
gated: false,
|
|
119
|
+
reason:
|
|
120
|
+
`every PASS for this PR was recorded AFTER the moment being judged — a verdict posted later says the content ` +
|
|
121
|
+
`was verified, never that this merge was gated`,
|
|
122
|
+
crossed: passes.map((v) => ({ gatedSha: v.head, at: v.ts })),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const match = inTime.find((v) => shaAgrees(v.head, head));
|
|
126
|
+
if (match) return { gated: true, by: match };
|
|
127
|
+
return {
|
|
128
|
+
gated: false,
|
|
129
|
+
reason:
|
|
130
|
+
`the head being judged (${head.slice(0, 7)}) matches no PASS recorded at or before that moment — ` +
|
|
131
|
+
`the thing merged is not the thing gated`,
|
|
132
|
+
crossed: inTime.map((v) => ({ gatedSha: v.head, at: v.ts })),
|
|
133
|
+
};
|
|
134
|
+
}
|