agent-coord-mcp 0.26.19 → 0.26.20
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/server.js +21 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/records.js +242 -64
- 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/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/server.ts +21 -0
- package/src/tools/records.ts +221 -34
- 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/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
package/src/tools/transport.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
2
|
+
import {
|
|
3
|
+
TMUX_PUSH,
|
|
4
|
+
registerTmuxHost,
|
|
5
|
+
type TmuxHost,
|
|
6
|
+
isLocallyProbeable,
|
|
7
|
+
isTmuxKind,
|
|
8
|
+
paneExists,
|
|
9
|
+
probePane,
|
|
10
|
+
tmuxVersion,
|
|
11
|
+
targetOf,
|
|
12
|
+
tmuxAvailable,
|
|
13
|
+
} from "../transports/index.js";
|
|
2
14
|
import { newestMtimeUnder, onDiskBuildMtime, onDiskSourceMtime, SERVER_BUILD_MTIME, SERVER_BUILD_SHA, BUILD_DIR } from "../build.js";
|
|
3
15
|
import { prefixOf, prefixVerdict } from "../prefix.js";
|
|
4
16
|
import { execFileSync } from "node:child_process";
|
|
@@ -119,17 +131,11 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
119
131
|
let paneAlive: boolean | undefined;
|
|
120
132
|
if (marker) {
|
|
121
133
|
transportLive = isMarkerLive(marker, reg, now);
|
|
122
|
-
if (transportLive && marker.transport
|
|
134
|
+
if (transportLive && isLocallyProbeable(marker.transport) && targetOf(marker)) {
|
|
123
135
|
// The pusher can outlive its pane (agent window closed) — probe the pane.
|
|
124
|
-
// `has-session`
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
// pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
|
|
128
|
-
// tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
|
|
129
|
-
// Positive control, both directions: bogus target -> has-session exit 1,
|
|
130
|
-
// display-message exit 0; live pane -> both exit 0.
|
|
131
|
-
const probe = spawnSync("tmux", ["has-session", "-t", marker.tmuxTarget]);
|
|
132
|
-
paneAlive = probe.status === 0;
|
|
136
|
+
// Why `has-session` and not `display-message`, with the positive control
|
|
137
|
+
// both ways, is documented once on `paneExists`.
|
|
138
|
+
paneAlive = paneExists(targetOf(marker)!);
|
|
133
139
|
}
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -146,7 +152,7 @@ export async function pingTool(args: { from: string; to: string; echo?: boolean
|
|
|
146
152
|
// same shape as `stall_clock_status` before Task 13.1. A REMOTE pusher, or
|
|
147
153
|
// an agent with no probeable marker at all, has no pid to fall back on —
|
|
148
154
|
// there heartbeat genuinely IS the liveness mechanism, unchanged.
|
|
149
|
-
const heartbeatIsValidSignal = !marker || marker.transport
|
|
155
|
+
const heartbeatIsValidSignal = !marker || !isLocallyProbeable(marker.transport);
|
|
150
156
|
const alive = reachable || (heartbeatIsValidSignal && heartbeatFresh);
|
|
151
157
|
|
|
152
158
|
let echoSent = false;
|
|
@@ -196,7 +202,7 @@ export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
|
|
|
196
202
|
// Transports whose pusher can actually TYPE a slash command into a live CLI.
|
|
197
203
|
// A control command is meaningless to a plain MCP poller, so send_command is
|
|
198
204
|
// gated to agents currently attached over one of these.
|
|
199
|
-
|
|
205
|
+
|
|
200
206
|
|
|
201
207
|
// Normalize "clear" / "/clear" / " /Clear " → "clear"; null if not allowlisted.
|
|
202
208
|
function normalizeControlCommand(raw: string): string | null {
|
|
@@ -208,7 +214,7 @@ function normalizeControlCommand(raw: string): string | null {
|
|
|
208
214
|
async function liveTmuxTargets(): Promise<Map<string, TransportMarker>> {
|
|
209
215
|
const all = await loadLiveTransports();
|
|
210
216
|
const out = new Map<string, TransportMarker>();
|
|
211
|
-
for (const [id, m] of all) if (
|
|
217
|
+
for (const [id, m] of all) if (isTmuxKind(m.transport)) out.set(id, m);
|
|
212
218
|
return out;
|
|
213
219
|
}
|
|
214
220
|
|
|
@@ -645,19 +651,13 @@ export async function attachAgentTool(args: {
|
|
|
645
651
|
};
|
|
646
652
|
}
|
|
647
653
|
|
|
648
|
-
// Validate target exists.
|
|
649
|
-
//
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
// pane alive. Pinned to the BEHAVIOUR, not a version: measured identical on
|
|
653
|
-
// tmux 3.6b and 3.7b, and a version-pinned claim rots on the next upgrade.
|
|
654
|
-
// Positive control, both directions: bogus target -> has-session exit 1,
|
|
655
|
-
// display-message exit 0; live pane -> both exit 0.
|
|
656
|
-
const probe = spawnSync("tmux", ["has-session", "-t", target]);
|
|
657
|
-
if (probe.status !== 0) {
|
|
654
|
+
// Validate target exists. The probe's discriminating power, and the control
|
|
655
|
+
// that proves it, are documented once on `paneExists`.
|
|
656
|
+
const targetProbe = probePane(target);
|
|
657
|
+
if (!targetProbe.exists) {
|
|
658
658
|
return {
|
|
659
659
|
ok: false,
|
|
660
|
-
error: `tmux target '${target}' not found: ${
|
|
660
|
+
error: `tmux target '${target}' not found: ${targetProbe.stderr}`,
|
|
661
661
|
};
|
|
662
662
|
}
|
|
663
663
|
|
|
@@ -723,8 +723,14 @@ export async function attachAgentTool(args: {
|
|
|
723
723
|
const scriptMtime = newestPusherSourceMtime();
|
|
724
724
|
const marker: TransportMarker = {
|
|
725
725
|
agentId: args.agentId,
|
|
726
|
-
transport:
|
|
726
|
+
transport: TMUX_PUSH,
|
|
727
727
|
pid,
|
|
728
|
+
// DUAL-WRITTEN, and the duplication is the point. `target` is what every
|
|
729
|
+
// consumer now reads (`targetOf`); `tmuxTarget` is what the code a merge
|
|
730
|
+
// revert restores reads. Writing only the new field would leave markers the
|
|
731
|
+
// old server cannot parse, and that failure does not degrade gracefully —
|
|
732
|
+
// it silences every attached lane at once.
|
|
733
|
+
target,
|
|
728
734
|
tmuxTarget: target,
|
|
729
735
|
since: Date.now(),
|
|
730
736
|
scriptMtime,
|
|
@@ -749,7 +755,7 @@ export async function attachAgentTool(args: {
|
|
|
749
755
|
return {
|
|
750
756
|
ok: true,
|
|
751
757
|
agentId: args.agentId,
|
|
752
|
-
transport:
|
|
758
|
+
transport: TMUX_PUSH,
|
|
753
759
|
tmuxTarget: target,
|
|
754
760
|
pid,
|
|
755
761
|
log,
|
|
@@ -1171,6 +1177,53 @@ async function scanStaleLocks(olderThanMs: number, now: number): Promise<{ path:
|
|
|
1171
1177
|
return out;
|
|
1172
1178
|
}
|
|
1173
1179
|
|
|
1180
|
+
/* ── wiring the seam's TMUX HOST (Phase 5.4 Task 3) ─────────────────────────── */
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* The process-layer half of `TmuxTransport`, supplied by the module that owns
|
|
1184
|
+
* pusher spawn, receipts and marker files.
|
|
1185
|
+
*
|
|
1186
|
+
* ⚠ SCOPE, STATED RATHER THAN IMPLIED: Task 3 puts the seam in the IDENTITY and
|
|
1187
|
+
* DIAGNOSIS path — `capabilities` asks the live transport what it is, and the
|
|
1188
|
+
* mixed-fleet check reads markers through it. DELIVERY STILL RUNS THROUGH THE
|
|
1189
|
+
* EXISTING CODE PATHS. Task 2's gate was that nothing changes, and rerouting
|
|
1190
|
+
* every push through a new object would change the thing most likely to break
|
|
1191
|
+
* quietly. So the four delivery methods below THROW rather than no-op: a host
|
|
1192
|
+
* that silently accepted a push and dropped it would be the one failure this
|
|
1193
|
+
* fleet cannot observe, and an explicit throw is reachable only from code that
|
|
1194
|
+
* has not been written yet.
|
|
1195
|
+
*/
|
|
1196
|
+
const TMUX_HOST: TmuxHost = {
|
|
1197
|
+
attach: async () => {
|
|
1198
|
+
throw new Error("TmuxTransport.attach is not the delivery path yet — call attachAgentTool (Phase 5.4 Task 3 wires identity only)");
|
|
1199
|
+
},
|
|
1200
|
+
detach: async () => {
|
|
1201
|
+
throw new Error("TmuxTransport.detach is not the delivery path yet — call detachAgentTool");
|
|
1202
|
+
},
|
|
1203
|
+
push: async () => {
|
|
1204
|
+
throw new Error("TmuxTransport.push is not the delivery path yet — delivery runs through the pusher process");
|
|
1205
|
+
},
|
|
1206
|
+
sendControl: async () => {
|
|
1207
|
+
throw new Error("TmuxTransport.sendControl is not the delivery path yet — call sendCommandTool");
|
|
1208
|
+
},
|
|
1209
|
+
/**
|
|
1210
|
+
* Is the pusher behind this marker still running? Reuses `isPusherProcess`,
|
|
1211
|
+
* which checks the COMMAND of the pid rather than merely that a pid exists —
|
|
1212
|
+
* a recycled pid belonging to something else is not a live pusher.
|
|
1213
|
+
*/
|
|
1214
|
+
pusherAlive: (marker) => isPusherProcess(marker.pid),
|
|
1215
|
+
killPusher: (marker) => {
|
|
1216
|
+
try {
|
|
1217
|
+
process.kill(marker.pid, "SIGTERM");
|
|
1218
|
+
return true;
|
|
1219
|
+
} catch {
|
|
1220
|
+
return false;
|
|
1221
|
+
}
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
|
|
1225
|
+
registerTmuxHost(TMUX_HOST);
|
|
1226
|
+
|
|
1174
1227
|
export const doctorSchema = {
|
|
1175
1228
|
fix: z.boolean().optional(),
|
|
1176
1229
|
maxFileBytes: z.number().int().positive().optional(),
|
|
@@ -1231,7 +1284,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1231
1284
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
1232
1285
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
1233
1286
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
1234
|
-
if (marker.transport
|
|
1287
|
+
if (!isLocallyProbeable(marker.transport)) continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1235
1288
|
if (marker.scriptMtime === undefined) {
|
|
1236
1289
|
// ABSENCE IS NOT EXEMPTION. The field's own writer once dropped it,
|
|
1237
1290
|
// and the silent skip here meant the check was disabled by the very
|
|
@@ -1339,7 +1392,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1339
1392
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
1340
1393
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
1341
1394
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
1342
|
-
if (marker.transport
|
|
1395
|
+
if (!isLocallyProbeable(marker.transport)) continue; // remote = can't verify (documented limit: can't stat another host)
|
|
1343
1396
|
if (marker.serverBuildMtime === undefined) {
|
|
1344
1397
|
// ABSENCE IS NOT EXEMPTION — flipped in the same commit as the
|
|
1345
1398
|
// scriptMtime absence above, so the two checks can never disagree
|
|
@@ -1434,7 +1487,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1434
1487
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
1435
1488
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
1436
1489
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
1437
|
-
if (marker.transport
|
|
1490
|
+
if (!isLocallyProbeable(marker.transport)) continue; // remote: the script lives on another host
|
|
1438
1491
|
const { scriptMtime, serverBuildMtime, agentId } = marker;
|
|
1439
1492
|
if (scriptMtime === undefined || serverBuildMtime === undefined || onDiskScript === undefined || onDiskServer === undefined) {
|
|
1440
1493
|
// A pane missing either stamp cannot be classified. Saying so is the
|
|
@@ -1475,20 +1528,20 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1475
1528
|
{
|
|
1476
1529
|
// Without a tmux binary we can't tell "wedged" from "can't probe" — skip
|
|
1477
1530
|
// rather than flag every local marker as dead.
|
|
1478
|
-
const
|
|
1531
|
+
const tmuxIsAvailable = tmuxAvailable();
|
|
1479
1532
|
const wedged: { agentId: string; pid: number; file: string; target: string; isPusher: boolean }[] = [];
|
|
1480
|
-
if (
|
|
1533
|
+
if (tmuxIsAvailable) {
|
|
1481
1534
|
for (const fname of await listTransportFiles()) {
|
|
1482
1535
|
const file = path.join(TRANSPORT_DIR, fname);
|
|
1483
1536
|
const marker = await readJson<TransportMarker | null>(file, null);
|
|
1484
1537
|
if (!marker || !isMarkerLive(marker, reg, now)) continue;
|
|
1485
|
-
if (marker.transport
|
|
1538
|
+
if (!isLocallyProbeable(marker.transport)) continue; // remote = no local pane to probe
|
|
1486
1539
|
if (!marker.tmuxTarget) continue; // no target recorded, can't probe
|
|
1487
1540
|
// has-session actually validates the target and fails on a dead
|
|
1488
1541
|
// pane/session; `display-message -p -t <target> <literal>` does NOT
|
|
1489
1542
|
// (tmux 3.6b exits 0 for any target, even a just-killed one, when
|
|
1490
1543
|
// the format string has no #{...} needing that target resolved).
|
|
1491
|
-
const probe =
|
|
1544
|
+
const probe = { status: paneExists(targetOf(marker)!) ? 0 : 1 };
|
|
1492
1545
|
if (probe.status === 0) continue; // pane alive
|
|
1493
1546
|
// The marker's pid being alive does not make it OUR pid — see
|
|
1494
1547
|
// isPusherProcess. Record the verdict now so `fix` only ever signals
|
|
@@ -1523,7 +1576,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1523
1576
|
level: wedged.length ? "warn" : "ok",
|
|
1524
1577
|
detail: wedged.length
|
|
1525
1578
|
? `${wedged.length} local pusher(s) alive (pid) but their tmux pane is gone — looks attached, delivers nothing. ${fix ? "Reaped (SIGTERM + marker cleared)." : "Run doctor with fix:true to SIGTERM and clear the marker."}`
|
|
1526
|
-
:
|
|
1579
|
+
: tmuxIsAvailable
|
|
1527
1580
|
? "no wedged local pushers (pid-alive, pane-dead)"
|
|
1528
1581
|
: "tmux not available — skipped wedged-pusher pane probe",
|
|
1529
1582
|
fixable: true,
|
|
@@ -1768,9 +1821,8 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1768
1821
|
// Without a tmux binary we cannot probe a pane at all — every stale
|
|
1769
1822
|
// agent is reported as an orphan candidate rather than silently split,
|
|
1770
1823
|
// same posture `wedged-local-pushers` takes.
|
|
1771
|
-
const
|
|
1772
|
-
const paneAlive = (target: string): boolean =>
|
|
1773
|
-
tmuxAvailable && spawnSync("tmux", ["has-session", "-t", target]).status === 0;
|
|
1824
|
+
const tmuxIsAvailable = tmuxAvailable();
|
|
1825
|
+
const paneAlive = (target: string): boolean => tmuxIsAvailable && paneExists(target);
|
|
1774
1826
|
|
|
1775
1827
|
const paneConfirmed: string[] = [];
|
|
1776
1828
|
const orphans: string[] = [];
|
|
@@ -1779,8 +1831,9 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1779
1831
|
if (now - a.lastHeartbeat <= EVICT_MS) continue;
|
|
1780
1832
|
const age = `${Math.floor((now - a.lastHeartbeat) / 3600000)}h`;
|
|
1781
1833
|
const marker = markerByAgent.get(id);
|
|
1782
|
-
|
|
1783
|
-
|
|
1834
|
+
const markerTarget = marker ? targetOf(marker) : undefined;
|
|
1835
|
+
if (isLocallyProbeable(marker?.transport) && markerTarget && paneAlive(markerTarget)) {
|
|
1836
|
+
paneConfirmed.push(`${id} (${age}, pane '${markerTarget}' still current)`);
|
|
1784
1837
|
} else {
|
|
1785
1838
|
orphans.push(`${id} (${age})`);
|
|
1786
1839
|
}
|
|
@@ -1808,7 +1861,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1808
1861
|
level: orphans.length ? "warn" : "ok",
|
|
1809
1862
|
detail: orphans.length
|
|
1810
1863
|
? `${orphans.length} stale agent(s) have no live tmux pane behind them — permanent, will not clear on their own (unregister or let eviction drop them)`
|
|
1811
|
-
:
|
|
1864
|
+
: tmuxIsAvailable
|
|
1812
1865
|
? `no orphans among ${stale.length} stale agent(s)${stale.length ? " — all pane-confirmed current" : ""}`
|
|
1813
1866
|
: "tmux not available — could not distinguish orphans from pane-confirmed stale agents",
|
|
1814
1867
|
fixable: false,
|
|
@@ -2026,8 +2079,8 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
2026
2079
|
// identity is walk-up-to-.git + rev-parse (kit monorepo root); omitted
|
|
2027
2080
|
// entirely when there is no checkout — never invented.
|
|
2028
2081
|
{
|
|
2029
|
-
const
|
|
2030
|
-
const tmuxOk =
|
|
2082
|
+
const tmuxReported = tmuxVersion();
|
|
2083
|
+
const tmuxOk = tmuxReported !== undefined;
|
|
2031
2084
|
const ident = resolveServerIdentity();
|
|
2032
2085
|
const loc = ident.branch && ident.sha
|
|
2033
2086
|
? `path=${ident.path} version=${ident.version} ${ident.branch}@${ident.sha.slice(0, 12)}`
|
|
@@ -2045,7 +2098,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
2045
2098
|
check: "environment",
|
|
2046
2099
|
level: tmuxOk ? "ok" : "warn",
|
|
2047
2100
|
detail: tmuxOk
|
|
2048
|
-
? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${
|
|
2101
|
+
? `root=${ROOT}; node=${process.execPath}; ${loc}; tmux=${tmuxReported || "present"}`
|
|
2049
2102
|
: `root=${ROOT}; node=${process.execPath}; ${loc}; tmux NOT on PATH — the tmux-push transport will not work`,
|
|
2050
2103
|
fixable: false,
|
|
2051
2104
|
items,
|
package/src/tools/work.ts
CHANGED
|
@@ -139,8 +139,21 @@ function importedSummary(d: StoredDoc) {
|
|
|
139
139
|
board: ["board"],
|
|
140
140
|
legacy: ["queue", "done"],
|
|
141
141
|
} as const;
|
|
142
|
+
// THE AXIS IS PASSED, NOT JUST ITERATED. It was already in scope here and was
|
|
143
|
+
// not handed to the predicate, so seam 0.1.16's queue-axis fix (#234) changed
|
|
144
|
+
// nothing any caller could observe: a pruned-but-healthy QUEUE.md kept
|
|
145
|
+
// reporting `unparsed: ["queue"]` because the predicate fell back to measuring
|
|
146
|
+
// authored CONTENT, and a queue keeps its headings and prose by design.
|
|
147
|
+
//
|
|
148
|
+
// Without the argument the queue axis asks "is there any prose here?" — which
|
|
149
|
+
// on a queue document is always yes. With it, it asks "did somebody write a
|
|
150
|
+
// ROW that failed to parse?", which is the question the zero actually needs.
|
|
151
|
+
// Every axis is passed its own name; only "queue" is treated differently
|
|
152
|
+
// inside the predicate. `done` and `board` keep the content measure
|
|
153
|
+
// deliberately — prose under an empty done log IS a fair reason to doubt that
|
|
154
|
+
// zero — so this is a narrowing of one axis, not a relaxation of all three.
|
|
142
155
|
const unparsed = (AXES_BY_KIND[d.kind] as readonly (keyof typeof counts)[]).filter((axis) =>
|
|
143
|
-
zeroIsUnparsed(counts[axis], d.source),
|
|
156
|
+
zeroIsUnparsed(counts[axis], d.source, axis),
|
|
144
157
|
);
|
|
145
158
|
return {
|
|
146
159
|
path: d.path,
|
|
@@ -188,6 +201,41 @@ async function loadState(project: string): Promise<WorkState | null> {
|
|
|
188
201
|
return readJson<WorkState | null>(workFile(project), null);
|
|
189
202
|
}
|
|
190
203
|
|
|
204
|
+
/**
|
|
205
|
+
* WHICH STORED DOCS HAVE BEEN OVERTAKEN BY THE FILE ON DISK.
|
|
206
|
+
*
|
|
207
|
+
* THE FRESHNESS OF A RESPONSE IS THE FRESHNESS OF ITS STALEST FIELD, and before
|
|
208
|
+
* this the store was trusted because it existed. Measured on this fleet: a store
|
|
209
|
+
* imported at 17:29 already disagreed with 3 of its 4 documents by 17:42 —
|
|
210
|
+
* **thirteen minutes.** On a bus where several seats write coordination docs, the
|
|
211
|
+
* staleness window is minutes, not the weeks a dated store suggests.
|
|
212
|
+
*
|
|
213
|
+
* The instrument is mtime, and its limit is stated rather than hidden: an edit
|
|
214
|
+
* that PRESERVES mtime is not detected. mtime is used because the cheap question
|
|
215
|
+
* ("might the store be stale?") must not cost a full re-read of ~800KB of
|
|
216
|
+
* documents on every call; when the answer is yes, the re-read happens anyway.
|
|
217
|
+
* A missing file counts as stale — it cannot be compared, and "could not check"
|
|
218
|
+
* is not "checked and clean".
|
|
219
|
+
*/
|
|
220
|
+
async function staleDocs(state: WorkState): Promise<{ path: string; why: string }[]> {
|
|
221
|
+
const out: { path: string; why: string }[] = [];
|
|
222
|
+
for (const d of state.docs) {
|
|
223
|
+
const full = path.join(state.repo, d.path);
|
|
224
|
+
try {
|
|
225
|
+
const st = await fsp.stat(full);
|
|
226
|
+
if (st.mtimeMs > state.importedAt) {
|
|
227
|
+
out.push({
|
|
228
|
+
path: d.path,
|
|
229
|
+
why: `modified ${new Date(st.mtimeMs).toISOString()}, after the store was imported at ${new Date(state.importedAt).toISOString()}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch (e) {
|
|
233
|
+
out.push({ path: d.path, why: `cannot be read (${(e as Error).message}) — unverifiable, not assumed clean` });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
|
|
191
239
|
async function saveState(state: WorkState): Promise<void> {
|
|
192
240
|
await fsp.mkdir(WORK_DIR, { recursive: true });
|
|
193
241
|
await fsp.writeFile(workFile(state.project), JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
@@ -293,6 +341,13 @@ export async function listWorkTool(args: {
|
|
|
293
341
|
// rather than in a comment.
|
|
294
342
|
let state = await loadState(args.project);
|
|
295
343
|
let source: "store" | "markdown" = "store";
|
|
344
|
+
// A STALE STORE IS NEVER SERVED SILENTLY. `staleStore` is present in the
|
|
345
|
+
// response whenever the store lost a race with the documents, so the caller
|
|
346
|
+
// learns it from the ANSWER rather than from a `source` field they would have
|
|
347
|
+
// to know to check. The confound this removes: `issues` carried live-looking
|
|
348
|
+
// diagnostics beside a stale queue, so the field that made a careful reader
|
|
349
|
+
// trust the payload was the one field that was current.
|
|
350
|
+
let staleStore: { reparsed: boolean; docs: { path: string; why: string }[]; note: string } | undefined;
|
|
296
351
|
if (!state) {
|
|
297
352
|
const imported = await importFromDisk(args.project, args.repo ?? process.cwd());
|
|
298
353
|
if (!imported) {
|
|
@@ -300,6 +355,33 @@ export async function listWorkTool(args: {
|
|
|
300
355
|
}
|
|
301
356
|
state = imported;
|
|
302
357
|
source = "markdown";
|
|
358
|
+
} else {
|
|
359
|
+
const stale = await staleDocs(state);
|
|
360
|
+
if (stale.length) {
|
|
361
|
+
const fresh = await importFromDisk(state.project, state.repo);
|
|
362
|
+
if (fresh) {
|
|
363
|
+
state = fresh;
|
|
364
|
+
source = "markdown";
|
|
365
|
+
staleStore = {
|
|
366
|
+
reparsed: true,
|
|
367
|
+
docs: stale,
|
|
368
|
+
note:
|
|
369
|
+
`the store was older than ${stale.length} of its document(s) and was NOT used — these rows were re-parsed from disk. ` +
|
|
370
|
+
`Every field below therefore shares one provenance.`,
|
|
371
|
+
};
|
|
372
|
+
} else {
|
|
373
|
+
// Cannot re-read, so the stale store is all there is. It is still
|
|
374
|
+
// reported, because an answer that cannot be refreshed is the one most
|
|
375
|
+
// in need of saying so.
|
|
376
|
+
staleStore = {
|
|
377
|
+
reparsed: false,
|
|
378
|
+
docs: stale,
|
|
379
|
+
note:
|
|
380
|
+
`the store is older than ${stale.length} of its document(s) and could NOT be re-parsed from disk — ` +
|
|
381
|
+
`the rows below are as stale as the store and must not be read as current.`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
}
|
|
303
385
|
}
|
|
304
386
|
|
|
305
387
|
const queue: QueueItem[] = [];
|
|
@@ -325,9 +407,9 @@ export async function listWorkTool(args: {
|
|
|
325
407
|
// the caller does not have to know which kind its own id belongs to.
|
|
326
408
|
if (args.id !== undefined) {
|
|
327
409
|
const queueHit = queue.find((q) => q.id === args.id);
|
|
328
|
-
if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "queue" as const, item: queueHit };
|
|
410
|
+
if (queueHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "queue" as const, item: queueHit };
|
|
329
411
|
const doneHit = done.find((d) => d.id === args.id);
|
|
330
|
-
if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, kind: "done" as const, item: doneHit };
|
|
412
|
+
if (doneHit) return { ok: true as const, project: state.project, repo: state.repo, source, ...(staleStore ? { staleStore } : {}), kind: "done" as const, item: doneHit };
|
|
331
413
|
return { ok: false as const, error: `no queue item or DONE entry with id '${args.id}' in project '${state.project}'` };
|
|
332
414
|
}
|
|
333
415
|
|
|
@@ -339,6 +421,16 @@ export async function listWorkTool(args: {
|
|
|
339
421
|
project: state.project,
|
|
340
422
|
repo: state.repo,
|
|
341
423
|
source,
|
|
424
|
+
...(staleStore ? { staleStore } : {}),
|
|
425
|
+
// PROVENANCE OF THE WHOLE PAYLOAD, including the instrument and its limit.
|
|
426
|
+
// Done-def 4: if any field can outpace another, the response says so rather
|
|
427
|
+
// than a comment saying it.
|
|
428
|
+
freshness: {
|
|
429
|
+
source,
|
|
430
|
+
importedAt: new Date(state.importedAt).toISOString(),
|
|
431
|
+
checkedAgainst: "file mtime vs store importedAt",
|
|
432
|
+
limit: "an edit that preserves mtime is not detected; a re-parse is triggered only when mtime is newer",
|
|
433
|
+
},
|
|
342
434
|
// IDENTITY ONLY (Task 15.1) — id, priority, a bounded headline, and
|
|
343
435
|
// blocked-by; never the full item text. Call again with `id` for one
|
|
344
436
|
// row's body. This is the change that makes the tool cheap: the same
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHICH TRANSPORT THIS FLEET IS CONFIGURED TO USE (Phase 5.4 Task 3.1–3.2).
|
|
3
|
+
*
|
|
4
|
+
* Whole-fleet, read ONCE at startup, per David 2026-09-11 — no per-seat
|
|
5
|
+
* branching in `send_command` or `attach`. Memoised for that reason and not for
|
|
6
|
+
* speed: a value that can be re-read mid-process is a value that can change
|
|
7
|
+
* mid-process, and then two calls in one session disagree about what the fleet
|
|
8
|
+
* is doing.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to `tmux-push`.
|
|
11
|
+
*
|
|
12
|
+
* The reason is not tidiness. A SILENT FALLBACK AND A CORRECT DEFAULT PRODUCE
|
|
13
|
+
* IDENTICAL EVIDENCE: both give you a fleet on tmux with nothing in any log, so
|
|
14
|
+
* a typo in the config reads exactly like a deliberate default, and the person
|
|
15
|
+
* who typed `heardr` spends the afternoon asking why their transport change did
|
|
16
|
+
* nothing. Refusing is louder than the bug it prevents.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { ROOT } from "../store.js";
|
|
21
|
+
import { TMUX_PUSH, TRANSPORT_KINDS, type TransportKind } from "./types.js";
|
|
22
|
+
|
|
23
|
+
/** `$AGENT_COORD_DIR/config.json`, the fleet-wide file. */
|
|
24
|
+
export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
|
|
25
|
+
export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* PRECEDENCE, documented here because 3.2 asks for a decision and not a
|
|
29
|
+
* preference: **config file > env > default**.
|
|
30
|
+
*
|
|
31
|
+
* The file wins because it is the FLEET's statement and is reviewable — it sits
|
|
32
|
+
* on disk where every seat reads the same bytes, and a wrong value can be
|
|
33
|
+
* corrected in one place. An env var is per-process: it is the right tool for
|
|
34
|
+
* one seat to deviate deliberately (a test, a bisect), and the wrong tool for
|
|
35
|
+
* stating what the fleet does, because nothing can see it from outside that
|
|
36
|
+
* process. So the narrower, less visible source loses to the broader one, and
|
|
37
|
+
* `source` is reported so a surprising answer can be traced to its origin
|
|
38
|
+
* rather than guessed at.
|
|
39
|
+
*/
|
|
40
|
+
export type ConfiguredTransport = {
|
|
41
|
+
kind: TransportKind;
|
|
42
|
+
source: "config" | "env" | "default";
|
|
43
|
+
/** Where the value came from, for an error message a human can act on. */
|
|
44
|
+
origin: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function refuse(value: string, origin: string): never {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[agent-coord-mcp] unknown transport ${JSON.stringify(value)} from ${origin}. ` +
|
|
50
|
+
`Valid: ${TRANSPORT_KINDS.join(", ")}. ` +
|
|
51
|
+
`REFUSING AT STARTUP rather than falling back to "${TMUX_PUSH}" — a silent fallback and a correct ` +
|
|
52
|
+
`default leave identical evidence, so a typo here would look exactly like a working default and the ` +
|
|
53
|
+
`transport change would appear to do nothing. Fix the value or remove it to get the default.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asKind(value: unknown, origin: string): TransportKind {
|
|
58
|
+
if (typeof value !== "string" || value.length === 0) refuse(String(value), origin);
|
|
59
|
+
const match = TRANSPORT_KINDS.find((k) => k === value);
|
|
60
|
+
if (!match) refuse(value, origin);
|
|
61
|
+
return match;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let cached: ConfiguredTransport | undefined;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the fleet's transport. Throws on an unknown value — call it once at
|
|
68
|
+
* startup so the refusal lands before any agent attaches.
|
|
69
|
+
*/
|
|
70
|
+
export function configuredTransport(): ConfiguredTransport {
|
|
71
|
+
if (cached) return cached;
|
|
72
|
+
|
|
73
|
+
if (existsSync(TRANSPORT_CONFIG_FILE)) {
|
|
74
|
+
let parsed: unknown;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(readFileSync(TRANSPORT_CONFIG_FILE, "utf8"));
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// A CONFIG FILE THAT CANNOT BE PARSED IS NOT AN ABSENT ONE. Treating it as
|
|
79
|
+
// absent would silently use the default while a file sits there stating
|
|
80
|
+
// otherwise — the same two-states-one-evidence defect as the fallback.
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[agent-coord-mcp] ${TRANSPORT_CONFIG_FILE} is unreadable (${(e as Error).message}). ` +
|
|
83
|
+
`REFUSING rather than treating it as absent: a file that exists and cannot be read is not the ` +
|
|
84
|
+
`same as no file, and defaulting here would hide a stated intent behind a working fleet.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const raw = (parsed as { transport?: unknown } | null)?.transport;
|
|
88
|
+
if (raw !== undefined) {
|
|
89
|
+
cached = { kind: asKind(raw, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "config", origin: TRANSPORT_CONFIG_FILE };
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const env = process.env[TRANSPORT_ENV_VAR];
|
|
95
|
+
if (env !== undefined && env !== "") {
|
|
96
|
+
cached = { kind: asKind(env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
|
|
97
|
+
return cached;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
cached = { kind: TMUX_PUSH, source: "default", origin: `built-in default (${TMUX_PUSH})` };
|
|
101
|
+
return cached;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Drop the memo. FOR TESTS ONLY — the whole point of reading once is that
|
|
106
|
+
* production code cannot do this.
|
|
107
|
+
*/
|
|
108
|
+
export function resetConfiguredTransportForTests(): void {
|
|
109
|
+
cached = undefined;
|
|
110
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE REGISTRY — the one place a transport kind is turned into an implementation.
|
|
3
|
+
*
|
|
4
|
+
* `resolveTransport` is deliberately total over `TransportKind`: adding a kind to
|
|
5
|
+
* the union without registering it is a compile error here rather than a silent
|
|
6
|
+
* fall-through at a call site. Until Task 3, tmux is the only registered
|
|
7
|
+
* implementation and that is the rollback plan — the seam lands behind no config.
|
|
8
|
+
*/
|
|
9
|
+
import { HERDR, TMUX_PUSH, TMUX_PUSH_REMOTE, type Transport, type TransportKind } from "./types.js";
|
|
10
|
+
import { TmuxTransport, type TmuxHost } from "./tmux.js";
|
|
11
|
+
import { configuredTransport } from "./config.js";
|
|
12
|
+
|
|
13
|
+
export * from "./types.js";
|
|
14
|
+
export * from "./config.js";
|
|
15
|
+
export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
|
|
16
|
+
|
|
17
|
+
let host: TmuxHost | undefined;
|
|
18
|
+
|
|
19
|
+
/** Wire the process-layer implementation in once, at module init. */
|
|
20
|
+
export function registerTmuxHost(h: TmuxHost): void {
|
|
21
|
+
host = h;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveTransport(kind: TransportKind): Transport {
|
|
25
|
+
if (!host) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"transport host not registered — call registerTmuxHost() before resolveTransport(); " +
|
|
28
|
+
"this is a wiring error, not a runtime condition",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case TMUX_PUSH:
|
|
33
|
+
case TMUX_PUSH_REMOTE:
|
|
34
|
+
return new TmuxTransport(host, kind);
|
|
35
|
+
case HERDR:
|
|
36
|
+
// Task 4. Named so the union stays total and the gap is a stated absence
|
|
37
|
+
// rather than a default that silently behaves like tmux.
|
|
38
|
+
throw new Error('transport "herdr" is not implemented yet (Phase 5.4 Task 4)');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ── the ACTIVE transport, and why `running` is not read from config ────────── */
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The instance this process would actually use to deliver.
|
|
46
|
+
*
|
|
47
|
+
* Kept as an OBJECT rather than re-derived from the config value on each call,
|
|
48
|
+
* and that is the whole design. If `running` were computed by reading the config
|
|
49
|
+
* and resolving it, then `configured` and `running` would be two names for one
|
|
50
|
+
* fact and `agrees` could never be false — a check that cannot fail. The active
|
|
51
|
+
* instance is what startup actually installed, so the two can genuinely differ:
|
|
52
|
+
* a startup that refused, a process that never wired one, a test that installed
|
|
53
|
+
* another, a future path that falls back.
|
|
54
|
+
*/
|
|
55
|
+
let active: Transport | undefined;
|
|
56
|
+
|
|
57
|
+
export function setActiveTransport(t: Transport): void {
|
|
58
|
+
active = t;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** FOR TESTS ONLY — production wires the active transport once, at startup. */
|
|
62
|
+
export function clearActiveTransportForTests(): void {
|
|
63
|
+
active = undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function activeTransport(): Transport | undefined {
|
|
67
|
+
return active;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Wire the transport this fleet is configured for. Called once at startup, and
|
|
72
|
+
* it is where an unknown config value turns into a refusal.
|
|
73
|
+
*/
|
|
74
|
+
export function initTransportFromConfig(): { kind: TransportKind; source: string } {
|
|
75
|
+
const conf = configuredTransport();
|
|
76
|
+
setActiveTransport(resolveTransport(conf.kind));
|
|
77
|
+
return { kind: conf.kind, source: conf.source };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* WHAT THIS PROCESS IS ACTUALLY RUNNING, answered by CALLING the transport.
|
|
82
|
+
*
|
|
83
|
+
* *A config value is a label someone typed.* This asks the live object instead:
|
|
84
|
+
* it calls `available()` and puts a synthetic marker through `probe()`, and
|
|
85
|
+
* reports what came back as the evidence beside the answer. The probe is chosen
|
|
86
|
+
* to be discriminating rather than decorative — a tmux transport answers a
|
|
87
|
+
* bogus pane id with a reason that names the pane, and an implementation that
|
|
88
|
+
* does not talk to panes cannot produce that.
|
|
89
|
+
*
|
|
90
|
+
* Returns `undefined` for `kind` when nothing is wired, which is NOT the same as
|
|
91
|
+
* "tmux by default": a process with no transport delivers nothing, and reporting
|
|
92
|
+
* a default here would be the exact substitution this function exists to refuse.
|
|
93
|
+
*/
|
|
94
|
+
export async function runningTransport(): Promise<{
|
|
95
|
+
kind: TransportKind | undefined;
|
|
96
|
+
evidence: string;
|
|
97
|
+
}> {
|
|
98
|
+
const t = active;
|
|
99
|
+
if (!t) {
|
|
100
|
+
return {
|
|
101
|
+
kind: undefined,
|
|
102
|
+
evidence: "no transport is wired in this process — nothing was called, and no default is assumed",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const availability = (() => {
|
|
106
|
+
try {
|
|
107
|
+
return `available()=${t.available()}`;
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return `available() threw: ${(e as Error).message}`;
|
|
110
|
+
}
|
|
111
|
+
})();
|
|
112
|
+
let probeEvidence: string;
|
|
113
|
+
try {
|
|
114
|
+
const live = await t.probe({
|
|
115
|
+
agentId: "__capability_probe__",
|
|
116
|
+
transport: t.kind,
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
target: "__no_such_target__",
|
|
119
|
+
since: Date.now(),
|
|
120
|
+
});
|
|
121
|
+
probeEvidence = `probe(bogus target)=${live.state}${live.state === "live" ? "" : `: ${live.reason}`}`;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
probeEvidence = `probe threw: ${(e as Error).message}`;
|
|
124
|
+
}
|
|
125
|
+
return { kind: t.kind, evidence: `${availability}, ${probeEvidence}` };
|
|
126
|
+
}
|