agent-coord-mcp 0.26.16 → 0.26.18
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/hooks/replay.mjs +107 -0
- package/hooks/tier.mjs +11 -0
- package/hooks/tmux-pusher.mjs +27 -0
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +16 -32
- package/src/server.ts +40 -20
- package/src/tools/admin.ts +8 -1
- package/src/tools/away.ts +48 -4
- package/src/tools/board-ref.ts +47 -2
- package/src/tools/messaging.ts +43 -2
- package/src/tools/records.ts +381 -25
- package/src/tools/registry.ts +5 -2
- package/src/tools/rooms.ts +2 -1
- package/src/tools/shared.ts +28 -0
- package/src/tools/stall.ts +29 -1
- package/src/tools/transport.ts +79 -10
- package/src/tools/work.ts +70 -4
- package/src/tools/worktrees.ts +64 -2
- package/src/work.ts +4 -0
- package/dist/build.js +0 -113
- package/dist/build.js.map +0 -1
- package/dist/capabilities.js +0 -158
- package/dist/capabilities.js.map +0 -1
- package/dist/prefix.js +0 -64
- package/dist/prefix.js.map +0 -1
- package/dist/roles.js +0 -132
- package/dist/roles.js.map +0 -1
- package/dist/server-identity.js +0 -82
- package/dist/server-identity.js.map +0 -1
- package/dist/server.js +0 -625
- package/dist/server.js.map +0 -1
- package/dist/store.js +0 -553
- package/dist/store.js.map +0 -1
- package/dist/tools/admin.js +0 -317
- package/dist/tools/admin.js.map +0 -1
- package/dist/tools/attention.js +0 -73
- package/dist/tools/attention.js.map +0 -1
- package/dist/tools/away.js +0 -243
- package/dist/tools/away.js.map +0 -1
- package/dist/tools/board-ref.js +0 -164
- package/dist/tools/board-ref.js.map +0 -1
- package/dist/tools/event-kinds.js +0 -39
- package/dist/tools/event-kinds.js.map +0 -1
- package/dist/tools/events.js +0 -234
- package/dist/tools/events.js.map +0 -1
- package/dist/tools/index.js +0 -14
- package/dist/tools/index.js.map +0 -1
- package/dist/tools/logwatch.js +0 -85
- package/dist/tools/logwatch.js.map +0 -1
- package/dist/tools/messaging.js +0 -667
- package/dist/tools/messaging.js.map +0 -1
- package/dist/tools/record-events.js +0 -380
- package/dist/tools/record-events.js.map +0 -1
- package/dist/tools/records.js +0 -686
- package/dist/tools/records.js.map +0 -1
- package/dist/tools/registry.js +0 -497
- package/dist/tools/registry.js.map +0 -1
- package/dist/tools/render.js +0 -2
- package/dist/tools/render.js.map +0 -1
- package/dist/tools/rooms.js +0 -210
- package/dist/tools/rooms.js.map +0 -1
- package/dist/tools/rotate.js +0 -143
- package/dist/tools/rotate.js.map +0 -1
- package/dist/tools/scopes.js +0 -126
- package/dist/tools/scopes.js.map +0 -1
- package/dist/tools/shared.js +0 -86
- package/dist/tools/shared.js.map +0 -1
- package/dist/tools/stall.js +0 -387
- package/dist/tools/stall.js.map +0 -1
- package/dist/tools/transport.js +0 -1810
- package/dist/tools/transport.js.map +0 -1
- package/dist/tools/work.js +0 -319
- package/dist/tools/work.js.map +0 -1
- package/dist/tools/worktrees.js +0 -339
- package/dist/tools/worktrees.js.map +0 -1
- package/dist/typed-records.js +0 -174
- package/dist/typed-records.js.map +0 -1
- package/dist/work.js +0 -2
- package/dist/work.js.map +0 -1
package/hooks/replay.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* IS THIS INSTRUCTION ARRIVING FOR THE FIRST TIME, OR AGAIN?
|
|
3
|
+
*
|
|
4
|
+
* Phase 5.3 Task 21.2 asks for a property and deliberately names no mechanism:
|
|
5
|
+
* A REPLAYED INSTRUCTION MUST BE DISTINGUISHABLE FROM A LIVE ONE IN THE PANE,
|
|
6
|
+
* WITHOUT RELYING ON ANY AGENT'S RETAINED CONTEXT.
|
|
7
|
+
*
|
|
8
|
+
* WHY RETAINED CONTEXT CANNOT BE THE ANSWER — this is the whole reason the task
|
|
9
|
+
* forbids it. DM delivery is at-least-once by design (losing a message is worse
|
|
10
|
+
* than repeating one), so a crash, SIGTERM or dead pane between paste and cursor
|
|
11
|
+
* commit REDELIVERS. Today the redelivered bytes are IDENTICAL to the original,
|
|
12
|
+
* so the only thing that has ever caught a duplicate is an agent recognising the
|
|
13
|
+
* instruction — and that memory is destroyed two ways:
|
|
14
|
+
*
|
|
15
|
+
* /clear at a VISIBLE moment, at a task boundary, by an operator
|
|
16
|
+
* compaction SILENTLY and CONTINUOUSLY, with no command and no moment
|
|
17
|
+
* anyone notices
|
|
18
|
+
*
|
|
19
|
+
* The aide's framing, which is stronger than the one I brought: the second is
|
|
20
|
+
* the case that matters, because there is no point at which anyone knows the
|
|
21
|
+
* memory is gone. A control whose only failure mode is invisible is not a
|
|
22
|
+
* control. Both were hit twice in one reconnect.
|
|
23
|
+
*
|
|
24
|
+
* THE SIGNAL IS THE RECEIPT LOG, WHICH IS ALREADY ON DISK. Receipts are stamped
|
|
25
|
+
* AFTER the payload is typed into the pane and the push cursor advances only
|
|
26
|
+
* after that, so the redelivery window is EXACTLY the window in which a receipt
|
|
27
|
+
* exists and the cursor has not moved. The evidence of a prior delivery is
|
|
28
|
+
* therefore already written down, by construction, before the redelivery can
|
|
29
|
+
* happen — nothing new has to be recorded and no agent has to remember anything.
|
|
30
|
+
*
|
|
31
|
+
* WHAT THE ABSENCE OF A MARKER DOES NOT MEAN. `prune` deletes receipts (default
|
|
32
|
+
* 7 days), and a receipt file that has been swept cannot report the delivery it
|
|
33
|
+
* once held. So an unmarked line means "no earlier delivery IS RECORDED", never
|
|
34
|
+
* "this is provably the first". That gap is bounded and not load-bearing —
|
|
35
|
+
* redelivery follows the original by seconds to minutes, while the sweep cutoff
|
|
36
|
+
* is days — but it is stated here rather than left for a reader to assume
|
|
37
|
+
* totality, which is the recurring defect this phase exists to remove.
|
|
38
|
+
*
|
|
39
|
+
* ONE GRAMMAR, TWO PRODUCERS. The local pusher reads its own receipts file; the
|
|
40
|
+
* remote pusher cannot see the server's filesystem, so the server annotates the
|
|
41
|
+
* message instead (readMessagesTool). Those two compute the same DATA by
|
|
42
|
+
* different routes and both render it through `replayMarker` here, so the pane
|
|
43
|
+
* cannot receive two different vocabularies for one fact.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Prior deliveries per message id, from the text of a receipts JSONL file.
|
|
48
|
+
*
|
|
49
|
+
* Takes TEXT rather than a path so the parsing is exercisable without a
|
|
50
|
+
* filesystem — the reason cursor IO was moved into push-cursor.mjs.
|
|
51
|
+
* Unparseable lines are skipped: a corrupt tail must not blind the whole log.
|
|
52
|
+
*/
|
|
53
|
+
export function priorDeliveries(text) {
|
|
54
|
+
const out = new Map();
|
|
55
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
let r;
|
|
58
|
+
try {
|
|
59
|
+
r = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!r || typeof r.id !== "string") continue;
|
|
64
|
+
const seen = out.get(r.id);
|
|
65
|
+
const ts = Number.isFinite(r.ts) ? r.ts : undefined;
|
|
66
|
+
if (!seen) out.set(r.id, { deliveries: 1, ...(ts !== undefined ? { firstAt: ts } : {}) });
|
|
67
|
+
else {
|
|
68
|
+
seen.deliveries += 1;
|
|
69
|
+
// The FIRST delivery is the useful one to name: it tells a reader how
|
|
70
|
+
// long ago the instruction they are looking at was already handled.
|
|
71
|
+
if (ts !== undefined && (seen.firstAt === undefined || ts < seen.firstAt)) seen.firstAt = ts;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The `replay` annotation for a message about to be pasted, or undefined when
|
|
79
|
+
* no earlier delivery is recorded.
|
|
80
|
+
*
|
|
81
|
+
* `deliveries` counts receipts ALREADY on disk, so the attempt number of the
|
|
82
|
+
* delivery being rendered is that plus one.
|
|
83
|
+
*/
|
|
84
|
+
export function replayInfo(prior, id) {
|
|
85
|
+
const seen = id ? prior?.get(id) : undefined;
|
|
86
|
+
if (!seen || !(seen.deliveries > 0)) return undefined;
|
|
87
|
+
return { attempt: seen.deliveries + 1, ...(seen.firstAt !== undefined ? { firstAt: seen.firstAt } : {}) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The pane-visible marker for a replay, or "" for a first delivery.
|
|
92
|
+
*
|
|
93
|
+
* It says three things, and the third is the one that makes it a control rather
|
|
94
|
+
* than a label: THIS IS NOT THE FIRST TIME, WHEN THE FIRST TIME WAS, and WHAT TO
|
|
95
|
+
* DO ABOUT IT. A reader with no retained context cannot infer the third, and
|
|
96
|
+
* "you may ignore a duplicate, an executor may not" is exactly the case where
|
|
97
|
+
* guessing is expensive — re-running a merge or a push because the instruction
|
|
98
|
+
* arrived twice is the failure at-least-once delivery pays for.
|
|
99
|
+
*/
|
|
100
|
+
export function replayMarker(replay) {
|
|
101
|
+
if (!replay || !(replay.attempt > 1)) return "";
|
|
102
|
+
const at = Number.isFinite(replay.firstAt) ? new Date(replay.firstAt) : null;
|
|
103
|
+
const when = at
|
|
104
|
+
? ` · first delivered ${String(at.getUTCHours()).padStart(2, "0")}:${String(at.getUTCMinutes()).padStart(2, "0")}Z`
|
|
105
|
+
: "";
|
|
106
|
+
return `⟲ REPLAY (delivery ${replay.attempt}${when}) — ALREADY DELIVERED BEFORE; check server truth before acting, do not re-run it · `;
|
|
107
|
+
}
|
package/hooks/tier.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// by convention, and a lowercase lookalike is chatter, not a work order.
|
|
11
11
|
|
|
12
12
|
import { isGateRunner } from "./roles.mjs";
|
|
13
|
+
import { replayMarker } from "./replay.mjs";
|
|
13
14
|
|
|
14
15
|
// Typed protocol record → tier (Phase 8). The prefix table below is the same
|
|
15
16
|
// vocabulary parsed out of text; reading the field instead removes the parse
|
|
@@ -211,6 +212,16 @@ export function injectLine(m) {
|
|
|
211
212
|
const held = text.split("\n").length - 1;
|
|
212
213
|
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
213
214
|
}
|
|
215
|
+
// Phase 5.3 Task 21.2 — A REPLAY IS MARKED IN THE PANE, so telling it from a
|
|
216
|
+
// live instruction needs no retained context. Applied LAST so the marker is
|
|
217
|
+
// the first thing after the attribution header even when the record digest
|
|
218
|
+
// above has already rewritten `text` — a warning below the fold is an absent
|
|
219
|
+
// warning, and this line is often long.
|
|
220
|
+
//
|
|
221
|
+
// The header stays byte-identical, so the parse contract holds: attribution
|
|
222
|
+
// is still `[tag HH:MM from] ` and the marker is part of the TEXT, which is
|
|
223
|
+
// correct — it is something the reader must see, not metadata about routing.
|
|
224
|
+
text = `${replayMarker(m.replay)}${text}`;
|
|
214
225
|
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
215
226
|
}
|
|
216
227
|
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -102,6 +102,7 @@ import { effectiveTier, isGateRunnerRole, TierQueue, formatBatch } from "./tier.
|
|
|
102
102
|
import { isCi } from "./roles.mjs";
|
|
103
103
|
import { mergeTransportMarker } from "./marker.mjs";
|
|
104
104
|
import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
|
|
105
|
+
import { priorDeliveries, replayInfo } from "./replay.mjs";
|
|
105
106
|
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
|
|
106
107
|
|
|
107
108
|
const AGENT_ID = process.env.AGENT_COORD_ID;
|
|
@@ -583,6 +584,32 @@ async function injectViaTmux(batch) {
|
|
|
583
584
|
);
|
|
584
585
|
return [...batch];
|
|
585
586
|
}
|
|
587
|
+
// Phase 5.3 Task 21.2 — STAMP THE REPLAY FACT BEFORE PASTING.
|
|
588
|
+
//
|
|
589
|
+
// Read once per inject, not per message: the receipt log is append-only and a
|
|
590
|
+
// single read is a consistent snapshot for the whole batch.
|
|
591
|
+
//
|
|
592
|
+
// WHY THIS FILE IS THE RIGHT PLACE, and why it can work at all: receipts are
|
|
593
|
+
// stamped AFTER a payload is typed (writeReceipts, below) and the push cursor
|
|
594
|
+
// advances only after the whole batch lands (commitOffsets, in flush). So a
|
|
595
|
+
// death anywhere in that window redelivers a message whose receipt is ALREADY
|
|
596
|
+
// on disk — the evidence exists before the duplicate can, without anyone
|
|
597
|
+
// having recorded anything for this purpose or remembered anything at all.
|
|
598
|
+
//
|
|
599
|
+
// Best-effort: an unreadable receipt log yields an empty map and therefore no
|
|
600
|
+
// markers, which is the honest direction. A missing marker means "no earlier
|
|
601
|
+
// delivery is RECORDED", never "this is provably the first" — see replay.mjs.
|
|
602
|
+
let prior = new Map();
|
|
603
|
+
try {
|
|
604
|
+
prior = priorDeliveries(readFileSync(RECEIPTS_FILE, "utf8"));
|
|
605
|
+
} catch {
|
|
606
|
+
// no receipts yet, or unreadable — every message renders as a first delivery
|
|
607
|
+
}
|
|
608
|
+
batch = batch.map((m) => {
|
|
609
|
+
const replay = replayInfo(prior, m?.id);
|
|
610
|
+
return replay ? { ...m, replay } : m;
|
|
611
|
+
});
|
|
612
|
+
|
|
586
613
|
const undelivered = [];
|
|
587
614
|
let run = [];
|
|
588
615
|
const flushRun = async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.18",
|
|
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": {
|
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
import { hostname } from "node:os";
|
|
39
39
|
import { spawn, spawnSync } from "node:child_process";
|
|
40
40
|
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
|
|
41
|
+
// The pane's parse-contract line, single-sourced — see the note above formatBatch.
|
|
42
|
+
import { injectLine } from "../hooks/tier.mjs";
|
|
41
43
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
42
44
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
43
45
|
|
|
@@ -248,38 +250,20 @@ async function flush() {
|
|
|
248
250
|
}
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
// with no `[tag HH:MM from]` header, so a parser could not attribute them.
|
|
266
|
-
//
|
|
267
|
-
// The handle is the message id, NOT a stashed copy: the full record is
|
|
268
|
-
// already persisted in rooms/<chan>.jsonl or inbox/<id>.jsonl, and
|
|
269
|
-
// retrieve_message reads it back by id (falling through to the append-only
|
|
270
|
-
// archive if compaction moved it). A cache would have had a TTL and lost the
|
|
271
|
-
// record permanently on expiry.
|
|
272
|
-
//
|
|
273
|
-
// Gated on `m.record`: a record-LESS multi-line message is untouched and
|
|
274
|
-
// still arrives unattributed past line 1, exactly as today. Task 6 does not
|
|
275
|
-
// fix hand-typed multi-line messages, and must not change their bytes.
|
|
276
|
-
const nl = text.indexOf("\n");
|
|
277
|
-
if (nl !== -1 && m.record && typeof m.record.type === "string" && m.id) {
|
|
278
|
-
const held = text.split("\n").length - 1;
|
|
279
|
-
text = `${text.slice(0, nl)} [+${held} lines · record:${m.record.type} · retrieve_message id=${m.id}]`;
|
|
280
|
-
}
|
|
281
|
-
return ` [${tag} ${hhmm} ${m.from}] ${text}`;
|
|
282
|
-
}
|
|
253
|
+
// THE PER-MESSAGE PARSE CONTRACT LINE IS IMPORTED, NOT COPIED.
|
|
254
|
+
//
|
|
255
|
+
// This was a duplicate of hooks/tier.mjs's `injectLine`, kept "byte-identical"
|
|
256
|
+
// by a comment and locked by nothing, on the rationale that this pusher is
|
|
257
|
+
// standalone and "may deploy without hooks/". THAT RATIONALE IS ALREADY FALSE:
|
|
258
|
+
// the file imports ../hooks/submit.mjs at the top, so it cannot run without
|
|
259
|
+
// hooks/ today and has not been able to for several releases. A copy kept in
|
|
260
|
+
// step by a comment is two grammars the moment one is fixed — the same argument
|
|
261
|
+
// the seam's phase-citation grammar records, where a second copy would have
|
|
262
|
+
// kept a fixed bug alive in the other package with nothing to find it.
|
|
263
|
+
//
|
|
264
|
+
// Deleting the copy is what makes Task 21.2 land in BOTH panes: the replay
|
|
265
|
+
// marker had to be added once rather than twice-and-hopefully-identically.
|
|
266
|
+
// (the import itself sits with the other imports at the top of the file)
|
|
283
267
|
|
|
284
268
|
function formatBatch(batch) {
|
|
285
269
|
const lines = ["[agent-coord] msgs (pre-consumed, don't re-read):"];
|
package/src/server.ts
CHANGED
|
@@ -135,8 +135,13 @@ function jsonResult(data: unknown) {
|
|
|
135
135
|
// transport, or another live bound session) — that silently created a
|
|
136
136
|
// second session acting as an already-running agent (hit live 2026-07-06:
|
|
137
137
|
// a dev session bound itself to `<project>-liaison`). A live-id claim needs
|
|
138
|
-
// the agent's
|
|
139
|
-
//
|
|
138
|
+
// the agent's TOKEN — `force` alone is refused against a provably live
|
|
139
|
+
// incumbent (q-314e0187, v0.26.0: `force` used to bypass this evidence
|
|
140
|
+
// check entirely, which is what let two live sessions hold
|
|
141
|
+
// `groundwork-kit-worker-3` at once on 2026-08-31). `force` still works
|
|
142
|
+
// for evidence the guard cannot verify at all, or an id verified absent.
|
|
143
|
+
// See guardFirstClaim for how absent vs unreadable vs live-but-unproven
|
|
144
|
+
// evidence is decided.
|
|
140
145
|
// - `trackSession` (stdio only): each successful bind writes a
|
|
141
146
|
// sessions/<id>.<pid>.<nonce>.json marker so doctor can SEE two live
|
|
142
147
|
// sessions bound to one id — closure state alone cannot be inspected
|
|
@@ -227,9 +232,14 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
227
232
|
// - a presented token must MATCH or the claim fails loudly, even when the
|
|
228
233
|
// id is not live — a wrong credential silently succeeding via the
|
|
229
234
|
// not-live path would teach callers that garbage tokens work;
|
|
230
|
-
// -
|
|
231
|
-
//
|
|
232
|
-
//
|
|
235
|
+
// - evidence is gathered BEFORE `force` is consulted (q-314e0187): a live
|
|
236
|
+
// PROVABLE incumbent can no longer be superseded by `force` alone — see
|
|
237
|
+
// below. `force` is still honored, but only where it was never the
|
|
238
|
+
// defect: unverifiable evidence, or a verified-absent id;
|
|
239
|
+
// - evidence that exists but cannot be read REFUSES unless `force` is
|
|
240
|
+
// passed (cannot-verify ≠ verified-absent; unreadable state must not
|
|
241
|
+
// silently disable the guard, but an explicit override still works —
|
|
242
|
+
// there is no incumbent to protect from being duplicated here);
|
|
233
243
|
// - a live id refuses, except when its live pusher types into THIS
|
|
234
244
|
// process's own tmux pane — two sessions cannot share a pane, so that
|
|
235
245
|
// is the same seat restarting (the routine fleet-restart case), not a
|
|
@@ -246,9 +256,9 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
246
256
|
`Mint one with scripts/coord-token.mjs add ${claimed} (then SIGHUP the bus), or pass force:true if you are certain.`,
|
|
247
257
|
);
|
|
248
258
|
}
|
|
249
|
-
if (args["force"] === true) return "force";
|
|
250
259
|
const ev = await liveClaimEvidence(claimed, Date.now());
|
|
251
260
|
if (!ev.verifiable) {
|
|
261
|
+
if (args["force"] === true) return "force";
|
|
252
262
|
throw new Error(
|
|
253
263
|
`cannot verify whether '${claimed}' is live: ${ev.reasons.join("; ")}. ` +
|
|
254
264
|
`Refusing to bind rather than treating unreadable evidence as absence. ` +
|
|
@@ -257,14 +267,19 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
257
267
|
}
|
|
258
268
|
if (ev.live) {
|
|
259
269
|
if (ev.samePane && ev.boundElsewhere === 0) return "same-pane";
|
|
260
|
-
// A LEFTOVER SESSION IS NAMED AS ONE, AND force
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
270
|
+
// A LEFTOVER SESSION IS NAMED AS ONE, AND force NO LONGER REMOVES IT
|
|
271
|
+
// EITHER — q-314e0187: `force:true` used to be checked BEFORE this
|
|
272
|
+
// evidence was even gathered (line order, not this message), so a
|
|
273
|
+
// provably-live incumbent was never actually protected — it just
|
|
274
|
+
// looked protected to anyone who did not pass `force`. Measured
|
|
275
|
+
// 2026-08-31: `doctor` found pid 7129 (via FORCE) and pid 9760 (via
|
|
276
|
+
// same-pane) both live and bound to `groundwork-kit-worker-3`
|
|
277
|
+
// simultaneously, and the bus log could not tell which one wrote a
|
|
278
|
+
// disputed message. `force` binding past a live session does not stop
|
|
279
|
+
// that process — it adds a second claimant to one identity, which is
|
|
280
|
+
// the condition that produced exactly that incident. So a provably
|
|
281
|
+
// live incumbent can now be superseded ONLY by its own token; `force`
|
|
282
|
+
// is refused here regardless of its value.
|
|
268
283
|
//
|
|
269
284
|
// A live TRANSPORT marker is a different fact and is not called a leak:
|
|
270
285
|
// that pid is the pusher daemon, doing its job. Only a live SESSION
|
|
@@ -273,15 +288,20 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
273
288
|
const leakLine = leaks.length
|
|
274
289
|
? ` LEFTOVER PROCESS${leaks.length > 1 ? "ES" : ""} HOLDING THIS IDENTITY: ` +
|
|
275
290
|
leaks.map((l) => `pid ${l.pid} (via ${l.via})`).join(", ") +
|
|
276
|
-
`. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}
|
|
291
|
+
`. Look at ${leaks.length > 1 ? "them" : "it"} before deciding — \`ps -p ${leaks.map((l) => l.pid).join(",")}\`.`
|
|
277
292
|
: "";
|
|
278
293
|
throw new Error(
|
|
279
|
-
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it
|
|
294
|
+
`agent '${claimed}' is live on this bus (${ev.reasons.join("; ")}) — refusing to bind this fresh session to it` +
|
|
295
|
+
(args["force"] === true ? ", even with force:true" : "") +
|
|
296
|
+
`.` +
|
|
280
297
|
leakLine +
|
|
281
|
-
`
|
|
298
|
+
` A live incumbent can only be superseded with ITS OWN TOKEN now (mint one: scripts/coord-token.mjs add ${claimed}, then SIGHUP the bus) — ` +
|
|
299
|
+
`\`force:true\` binds a SECOND session to one identity rather than stopping the first, which is the defect this refusal exists to prevent, not a step past it. ` +
|
|
300
|
+
`If you ARE '${claimed}' restarting, re-join from its own tmux pane${leaks.length ? " once nothing else holds it" : ""}. ` +
|
|
282
301
|
`If you are diagnosing, use status/ping (read-only, they never bind) or your own id.`,
|
|
283
302
|
);
|
|
284
303
|
}
|
|
304
|
+
if (args["force"] === true) return "force";
|
|
285
305
|
return "tofu";
|
|
286
306
|
}
|
|
287
307
|
|
|
@@ -349,7 +369,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
349
369
|
|
|
350
370
|
addTool(
|
|
351
371
|
"join",
|
|
352
|
-
"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, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token
|
|
372
|
+
"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, any unread inbox messages, and the default channel's topic + MOTD (room rules) so you see them on connect. Calling join binds this MCP process's identity to agentId for the lifetime of the session — no env var or config needed. Each Claude Code session runs its own stdio process so bindings are naturally isolated. Claiming an id that is currently LIVE on the bus (fresh heartbeat, live pusher, or another bound session) is refused unless the claim comes from that agent's own tmux pane or carries the agent's token — `force:true` no longer overrides a provably live incumbent (only the token does); it still works when liveness cannot be verified at all or the id is verified absent. Diagnosing someone else's agent is what status/ping are for. `proseOnly:true` claims the per-agent exemption from the typed-record rule — see `register`.",
|
|
353
373
|
joinSchema,
|
|
354
374
|
// join explicitly sets the session binding when unset, so each agent can
|
|
355
375
|
// declare its identity via join rather than relying on env vars.
|
|
@@ -607,7 +627,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
607
627
|
|
|
608
628
|
addTool(
|
|
609
629
|
"stall_check",
|
|
610
|
-
"The stall predicate over the board and the bus: a 🚧 row whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
|
|
630
|
+
"The stall predicate over the board and the bus: a 🚧 In Progress or 🔍 In Review row (both are lanes somebody is working; a review that never gets re-gated is the stall shape of a QA-gated fleet) whose agent's heartbeat is older than the window, or whose claimed branch has no commits in it. HIT returns the hits for the caller to DM; MISS returns none and sends nothing — but EVERY run, hit or miss, leaves a mark, because a check that only speaks when it fires cannot be told from a broken one. Read the mark with stall_clock_status.",
|
|
611
631
|
stallCheckSchema,
|
|
612
632
|
gate(null, stallCheckTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
613
633
|
);
|
|
@@ -642,7 +662,7 @@ function buildServer(initialBound?: string, opts: { trackSession?: boolean } = {
|
|
|
642
662
|
|
|
643
663
|
addTool(
|
|
644
664
|
"next_unblocked",
|
|
645
|
-
"The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
|
|
665
|
+
"The next queue item a WORKER can claim: re-reads docs/QUEUE.md via the seam, orders P1>P2>P3 with document order breaking ties, and SKIPS a blocked item rather than stalling the lane on a reorder (returning the board hunk to record the skip). Also skips — as its own reported axis, `notClaimable`, never silently — canon prose (`[SWEEP:canon]`/`[SWEEP:canon.N]`) that the aide/coordinator author directly and no worker takes as code work. Also skips — as its own axis, `awaitingDecision`, never silently — an item that DECLARES it awaits a named human (`**[AWAITS:<who>]**` leading the item line; never inferred from prose): dependency-free and unbuildable at once, so it is reported for the human and not routed to a lane. Also reports items NOTHING WAITS ON as their own axis: an item that blocks nothing announces nothing when it stalls, so its absence is silent and needs an explicit check at a stage boundary.",
|
|
646
666
|
nextUnblockedSchema,
|
|
647
667
|
gate(null, nextUnblockedTool as (a: Record<string, unknown>) => Promise<unknown>),
|
|
648
668
|
);
|
package/src/tools/admin.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type Message,
|
|
32
32
|
type StatusEntry,
|
|
33
33
|
isDecision,
|
|
34
|
+
agentIdFromCursorFilename,
|
|
34
35
|
} from "./shared.js";
|
|
35
36
|
|
|
36
37
|
// ---------- prune ----------
|
|
@@ -73,7 +74,13 @@ export async function adjustCursors(removals: {
|
|
|
73
74
|
const perAgentInboxRemoved = removals.perAgentInboxRemoved ?? {};
|
|
74
75
|
const cursorsAdjusted: string[] = [];
|
|
75
76
|
for (const cname of await listCursorFiles()) {
|
|
76
|
-
|
|
77
|
+
// q-40449919: this was `cname.replace(/\.json$/, "")`, which strips only
|
|
78
|
+
// the outer extension. A push cursor's `<agent>.push.json` then keyed
|
|
79
|
+
// `perAgentInboxRemoved` lookups below on `<agent>.push` — a key that
|
|
80
|
+
// never matches — so a push cursor's inboxOffset was silently never
|
|
81
|
+
// decremented after a prune shrank the inbox, drifting it toward a
|
|
82
|
+
// false past-EOF the next time doctor ran.
|
|
83
|
+
const { id } = agentIdFromCursorFilename(cname);
|
|
77
84
|
const cursorPath = path.join(CURSOR_DIR, cname);
|
|
78
85
|
let touched = false;
|
|
79
86
|
await updateJson<Cursor>(cursorPath, {}, (current) => {
|
package/src/tools/away.ts
CHANGED
|
@@ -155,7 +155,14 @@ export function secondCoordinatorRefusal(
|
|
|
155
155
|
|
|
156
156
|
export const coordAwaySchema = {
|
|
157
157
|
project: z.string().min(1),
|
|
158
|
-
|
|
158
|
+
// Omitted → a QUERY: report current state and mutate nothing (q-1abbbdbf).
|
|
159
|
+
// `/coord-away` used to be a bare toggle read by a human deciding which
|
|
160
|
+
// direction to call it — the incident this fixes is David typing it
|
|
161
|
+
// meaning ARM while it was already armed, and the aide independently
|
|
162
|
+
// reading the same typed command as RELEASE, with no state visible to
|
|
163
|
+
// either of them before acting. State-it-aloud-first only works if there
|
|
164
|
+
// is a state to read that does not itself flip the switch.
|
|
165
|
+
on: z.boolean().optional(),
|
|
159
166
|
/** The agent leading in David's absence — the aide. */
|
|
160
167
|
leadId: z.string().optional(),
|
|
161
168
|
/** Repo-relative path where decisions taken during the absence are written. */
|
|
@@ -184,7 +191,7 @@ async function measureCoverage(repo?: string): Promise<AwayCoverage | null> {
|
|
|
184
191
|
|
|
185
192
|
export async function coordAwayTool(args: {
|
|
186
193
|
project: string;
|
|
187
|
-
on
|
|
194
|
+
on?: boolean;
|
|
188
195
|
leadId?: string;
|
|
189
196
|
decisionLog?: string;
|
|
190
197
|
until?: string;
|
|
@@ -195,6 +202,38 @@ export async function coordAwayTool(args: {
|
|
|
195
202
|
const state = readAway();
|
|
196
203
|
const prior = state[args.project];
|
|
197
204
|
|
|
205
|
+
// QUERY — `on` omitted. Reads, never writes: no coverage measurement, no
|
|
206
|
+
// refusal, no side effect. This is the call a card makes to state current
|
|
207
|
+
// status aloud BEFORE deciding which direction to arm/release, so the
|
|
208
|
+
// decision is never made blind the way it was on 2026-08-30/31.
|
|
209
|
+
if (args.on === undefined) {
|
|
210
|
+
if (!prior?.on) {
|
|
211
|
+
return {
|
|
212
|
+
ok: true as const,
|
|
213
|
+
query: true as const,
|
|
214
|
+
project: args.project,
|
|
215
|
+
armed: false,
|
|
216
|
+
state: prior ?? null,
|
|
217
|
+
summary: prior
|
|
218
|
+
? `'${args.project}' is NOT armed — last released ${prior.at}.`
|
|
219
|
+
: `'${args.project}' is NOT armed — never armed.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
ok: true as const,
|
|
224
|
+
query: true as const,
|
|
225
|
+
project: args.project,
|
|
226
|
+
armed: true,
|
|
227
|
+
state: prior,
|
|
228
|
+
summary:
|
|
229
|
+
`'${args.project}' is ARMED — '${prior.leadId}' leads since ${prior.at}` +
|
|
230
|
+
`${prior.until ? ` until ${prior.until}` : ""}. ` +
|
|
231
|
+
`Coverage ${prior.coverage.measurable}/${prior.coverage.checked}` +
|
|
232
|
+
`${prior.blindAcknowledged ? " (ACKNOWLEDGED BLIND)" : ""}. ` +
|
|
233
|
+
`Decisions logged to ${prior.decisionLog}.`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
198
237
|
if (args.on) {
|
|
199
238
|
// A LEAD WITH NO NAME IS AN ABSENCE DESCRIBED AS COVERED, which is strictly
|
|
200
239
|
// worse than an absence everyone can see is uncovered.
|
|
@@ -261,8 +300,12 @@ export async function coordAwayTool(args: {
|
|
|
261
300
|
state: state[args.project],
|
|
262
301
|
refused: LEAD_REFUSED,
|
|
263
302
|
parked: PARKED_CATEGORIES,
|
|
303
|
+
// A confirmation naming the transition, not just the imperative
|
|
304
|
+
// direction (q-1abbbdbf) — "ON" and "already ON" read the same at a
|
|
305
|
+
// glance; "OFF → ARMED" cannot be misread as a no-op or a repeat.
|
|
306
|
+
transition: "OFF → ARMED" as const,
|
|
264
307
|
announce:
|
|
265
|
-
`AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
|
|
308
|
+
`OFF → ARMED. AGENT_ACTION: coord-away ON — David is away${args.until ? ` until ${args.until}` : ""}; '${args.leadId}' LEADS. ` +
|
|
266
309
|
`Decides: planning · priority · curation · roadmap · canon · releases under standing authorisation. ` +
|
|
267
310
|
`Never: merges, gates, or takes a code lane. Parks for David: ${PARKED_CATEGORIES.join(" · ")}. ` +
|
|
268
311
|
`Decisions logged to ${args.decisionLog}. ` +
|
|
@@ -280,8 +323,9 @@ export async function coordAwayTool(args: {
|
|
|
280
323
|
return {
|
|
281
324
|
ok: true as const,
|
|
282
325
|
state: state[args.project],
|
|
326
|
+
transition: "ARMED → RELEASED" as const,
|
|
283
327
|
announce:
|
|
284
|
-
`AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
|
|
328
|
+
`ARMED → RELEASED. AGENT_ACTION: coord-away RELEASED on '${args.project}' — David is back; '${prior.leadId}' stands down as lead. ` +
|
|
285
329
|
`Anything parked during the absence is still parked: release does not authorise it. Decisions taken: ${prior.decisionLog}.`,
|
|
286
330
|
};
|
|
287
331
|
}
|
package/src/tools/board-ref.ts
CHANGED
|
@@ -65,6 +65,25 @@ const git = (repo: string, args: string[]): string | null => {
|
|
|
65
65
|
};
|
|
66
66
|
const resolves = (repo: string, ref: string): boolean => !!git(repo, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
67
67
|
|
|
68
|
+
// Task 5.3/22.1's confirmation half: `docs/*` — a real historical cell value
|
|
69
|
+
// — is a GLOB, and `git check-ref-format` refuses it as a branch name (a `*`
|
|
70
|
+
// is not legal in a ref). A plain path like `docs/QUEUE.md` is, frustratingly,
|
|
71
|
+
// ALSO a syntactically valid ref name (slashes and dots are both legal), so
|
|
72
|
+
// this catches the glob shape precisely and does not claim to catch every
|
|
73
|
+
// path — an honest partial fix is the property this task's own title asks
|
|
74
|
+
// for ("every classifier can say I cannot tell"), not a heuristic that
|
|
75
|
+
// pretends to be exhaustive.
|
|
76
|
+
// Pure syntax check — never needs to run INSIDE a repo, so no `cwd` (unlike
|
|
77
|
+
// every other helper here, which resolves refs against `repo`'s history).
|
|
78
|
+
const isSyntacticallyValidRef = (name: string): boolean => {
|
|
79
|
+
try {
|
|
80
|
+
execFileSync("git", ["check-ref-format", "--branch", name], { stdio: ["ignore", "pipe", "ignore"] });
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
68
87
|
/** The `\`ref\`` inside a `Branch · Worktree` cell, or "". */
|
|
69
88
|
export function refInCell(cell: string): string {
|
|
70
89
|
return (String(cell ?? "").match(/`([^`]+)`/)?.[1] ?? "").trim();
|
|
@@ -91,6 +110,19 @@ export function classifyBoardRef(
|
|
|
91
110
|
|
|
92
111
|
const bare = raw.replace(/^origin\//, "");
|
|
93
112
|
|
|
113
|
+
// Task 5.3/22.1: CHECKED FIRST AND UNCONDITIONALLY — the original defect
|
|
114
|
+
// (`docs/*`) was previously only caught in a repo with NO origin remote at
|
|
115
|
+
// all (further down); confirmed live that in a repo WITH an origin — the
|
|
116
|
+
// normal case for every worktree this fleet actually runs — the same
|
|
117
|
+
// value fell through to the "unpushed" branch instead and was reported as
|
|
118
|
+
// "the branch has not been pushed", which is a confidently WRONG diagnosis
|
|
119
|
+
// for a path. `git check-ref-format` answers "could this even syntactically
|
|
120
|
+
// be a branch name" without touching the repo's history at all, so a glob
|
|
121
|
+
// is caught regardless of what else is true about this checkout.
|
|
122
|
+
if (!isSyntacticallyValidRef(bare)) {
|
|
123
|
+
return { kind: "path", why: `'${raw}' is not a syntactically valid branch name — it is a path or glob` };
|
|
124
|
+
}
|
|
125
|
+
|
|
94
126
|
// A SHARED REF FIRST, because `main` resolves and would otherwise be measured.
|
|
95
127
|
// Named explicitly rather than inferred from scoping, so the reason a reader
|
|
96
128
|
// gets is the real one.
|
|
@@ -152,11 +184,24 @@ export function classifyBoardRef(
|
|
|
152
184
|
`whether it is measurable depends on who last ran \`git fetch\`, which is a property of the reader rather than of the work.`,
|
|
153
185
|
};
|
|
154
186
|
}
|
|
187
|
+
// Task 5.3/22.2's fourth cause: "a pushed branch absent from this
|
|
188
|
+
// checkout" is NOT the same fact as "never pushed", and this check has
|
|
189
|
+
// no way to tell them apart without a network call it deliberately does
|
|
190
|
+
// not make (every other verdict here is a local git op only — adding
|
|
191
|
+
// one here would trade the offline property for one more cause). SAYING
|
|
192
|
+
// SO is the fix Task 22's own title asks for: a classifier that names
|
|
193
|
+
// an ABSENCE of evidence as a specific, confident cause is the exact
|
|
194
|
+
// shape "it is a path or glob" was wrong in — just moved one branch
|
|
195
|
+
// over. `'${remote}' does not exist` is the one fact this DOES know;
|
|
196
|
+
// everything past it is now framed as what it cannot distinguish rather
|
|
197
|
+
// than a guess dressed as a finding.
|
|
155
198
|
return {
|
|
156
199
|
kind: "unpushed",
|
|
157
200
|
why:
|
|
158
|
-
`'${remote}' does not
|
|
159
|
-
`
|
|
201
|
+
`'${remote}' does not resolve in THIS checkout — either the branch has never been pushed, or it WAS pushed and this ` +
|
|
202
|
+
`checkout's remote-tracking ref is simply stale (nobody has run \`git fetch\` here since). Cannot distinguish the two ` +
|
|
203
|
+
`without asking the remote, which this check deliberately does not do. Either way there is no LOCAL evidence of ` +
|
|
204
|
+
`activity, so it is not scored as a stall.`,
|
|
160
205
|
};
|
|
161
206
|
}
|
|
162
207
|
|