agent-coord-mcp 0.26.25 → 0.26.26
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 +31 -70
- package/dist/capabilities.js +41 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +13 -3
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-tail.js +26 -4
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/transport.js +16 -148
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +22 -11
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +16 -0
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +3 -2
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- package/dist/transports/types.js.map +1 -1
- package/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-seat.mjs +96 -0
- package/scripts/coord-token.mjs +79 -4
- package/scripts/stop-agent.sh +8 -4
- package/src/capabilities.ts +42 -3
- package/src/server.ts +12 -3
- package/src/tools/herdr-tail.ts +37 -5
- package/src/tools/transport.ts +14 -152
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +18 -0
- package/src/transports/index.ts +3 -2
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +19 -4
- package/hooks/tmux-pusher.mjs +0 -971
- package/scripts/spawn-agent.sh +0 -94
package/src/server.ts
CHANGED
|
@@ -845,9 +845,8 @@ async function main() {
|
|
|
845
845
|
// branching in send_command or attach.
|
|
846
846
|
try {
|
|
847
847
|
const t = initTransportFromConfig();
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
}
|
|
848
|
+
// ⟨q-ec020f6a⟩: no built-in default any more — every resolution has a real source, always logged.
|
|
849
|
+
console.error(`[agent-coord-mcp] transport: ${t.kind} (from ${t.source})`);
|
|
851
850
|
// ⛔ A HERDR SEAT MUST TAIL ITS OWN INBOX, OR IT IS DEAF TO EVERY TMUX SENDER.
|
|
852
851
|
// The send-time path can only see the SENDER's transport (a module-level singleton), so a
|
|
853
852
|
// tmux seat's server can never type into a herdr pane. The duty belongs to the seat that
|
|
@@ -1077,6 +1076,16 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1077
1076
|
const tokenMap = getTokenMap();
|
|
1078
1077
|
if (tokenMap) {
|
|
1079
1078
|
const agent = tokenMap.get(bearer);
|
|
1079
|
+
// ⛔ THE ONLY PLACE A PRE-BOUND SEAT CAN GET A TAIL. The `startTailOnBind` sites in the tool
|
|
1080
|
+
// path all sit behind `bound === undefined` — the TOFU claim. Here identity arrives already
|
|
1081
|
+
// decided by the bearer, that branch is unreachable, and before this line NO tail ever
|
|
1082
|
+
// started under HTTP (⟨q-cdb5b007⟩: `herdrTail: []`, and send-time delivery deferring to a
|
|
1083
|
+
// tail that does not exist drops the message permanently and silently).
|
|
1084
|
+
//
|
|
1085
|
+
// Deliberately lazy rather than a loop over the whole token map at boot: only a seat that
|
|
1086
|
+
// actually connects gets a tail, so a 20-agent map does not spawn 20 timers for seats that
|
|
1087
|
+
// may not exist. Idempotent — steady state is one Map lookup per request.
|
|
1088
|
+
if (agent) startTailOnBind(agent);
|
|
1080
1089
|
return agent ? { ok: true, agent } : { ok: false };
|
|
1081
1090
|
}
|
|
1082
1091
|
// Advisory mode: only check the shared bearer matches.
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -63,8 +63,18 @@ export type TailContext = {
|
|
|
63
63
|
hw: Map<string, number>;
|
|
64
64
|
/** Last seen size per source: a source that has not grown costs one stat. */
|
|
65
65
|
sizes: Map<string, number>;
|
|
66
|
+
/**
|
|
67
|
+
* ⛔ START AT EOF, NEVER AT WHATEVER THE CURSOR FILE HAPPENS TO SAY. A tail is a LIVE push
|
|
68
|
+
* mechanism, not a mail reader: nothing that predates its start belongs on the pane. Measured
|
|
69
|
+
* 2026-09-17 — a daemon restart reset every push cursor to ~0, so a tail starting from disk
|
|
70
|
+
* state would have typed a 3.4MB inbox into one seat's pane. `read_messages` still serves that
|
|
71
|
+
* history on demand; that is the verb for it.
|
|
72
|
+
*/
|
|
73
|
+
seedEof: boolean;
|
|
74
|
+
/** Sources already seeded — a room discovered on a later refresh is seeded on ITS first sight, not skipped. */
|
|
75
|
+
seeded: Set<string>;
|
|
66
76
|
};
|
|
67
|
-
export const newContext = (): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map() });
|
|
77
|
+
export const newContext = (seedEof = false): TailContext => ({ tick: 0, marker: null, rooms: [], hw: new Map(), sizes: new Map(), seedEof, seeded: new Set() });
|
|
68
78
|
|
|
69
79
|
const keyOf = (s: { kind: string; chan?: string }) => (s.kind === "dm" ? "dm" : `room:${s.chan}`);
|
|
70
80
|
const sizeOf = (file: string): number => {
|
|
@@ -127,6 +137,15 @@ export async function tailOnce(
|
|
|
127
137
|
// ⭐ THE IDLE PATH: a source that has not grown since the last tick costs this one stat.
|
|
128
138
|
if (ctx.sizes.get(key) === size) continue;
|
|
129
139
|
ctx.sizes.set(key, size);
|
|
140
|
+
// ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
|
|
141
|
+
// the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
|
|
142
|
+
// cursor file that a restart may have reset. Per source, so a room joined later seeds on its
|
|
143
|
+
// own first sight instead of replaying its history.
|
|
144
|
+
if (ctx.seedEof && !ctx.seeded.has(key)) {
|
|
145
|
+
ctx.seeded.add(key);
|
|
146
|
+
ctx.hw.set(key, size);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
130
149
|
const from = await offsetOf(agentId, src, ctx);
|
|
131
150
|
if (size <= from) continue;
|
|
132
151
|
|
|
@@ -208,12 +227,12 @@ export function stopAllHerdrTails(): void {
|
|
|
208
227
|
*/
|
|
209
228
|
export function ensureHerdrTail(
|
|
210
229
|
agentId: string,
|
|
211
|
-
opts: { transport?: Transport; pollMs?: number; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
|
|
230
|
+
opts: { transport?: Transport; pollMs?: number; seedEof?: boolean; tail?: (id: string, ctx: TailContext) => Promise<TailOutcome> } = {},
|
|
212
231
|
): { started: boolean; running: boolean; why: string } {
|
|
213
232
|
const t = opts.transport ?? activeTransport();
|
|
214
233
|
if (!t || t.kind !== HERDR) return { started: false, running: false, why: `transport is ${t?.kind ?? "unwired"}, not herdr — a tmux seat is woken by its pusher` };
|
|
215
234
|
if (running.has(agentId)) return { started: false, running: true, why: "already tailing this agent in this process" };
|
|
216
|
-
const ctx = newContext();
|
|
235
|
+
const ctx = newContext(opts.seedEof === true);
|
|
217
236
|
const run = opts.tail ?? ((id: string, c: TailContext) => tailOnce(id, { transport: t, ctx: c }));
|
|
218
237
|
const rec: TailRecord = { agentId, startedAt: new Date().toISOString(), ticks: 0, delivered: 0, held: 0, lastTickAt: null, lastWhy: null, stop: () => {} };
|
|
219
238
|
let inFlight = false;
|
|
@@ -237,8 +256,21 @@ export function ensureHerdrTail(
|
|
|
237
256
|
return { started: true, running: true, why: `tailing ${agentId}'s inbox and rooms every ${opts.pollMs ?? DEFAULT_POLL_MS}ms` };
|
|
238
257
|
}
|
|
239
258
|
|
|
240
|
-
/**
|
|
259
|
+
/**
|
|
260
|
+
* The hook the server calls at EVERY identity binding — `join`, a gated first claim, the env var,
|
|
261
|
+
* or (HTTP) an authenticated request from a PRE-BOUND identity.
|
|
262
|
+
*
|
|
263
|
+
* ⛔ THE BINDING TRANSITION IS NOT REACHABLE UNDER HTTP, WHICH IS WHY THIS ALSO HANGS OFF AUTH.
|
|
264
|
+
* The `bound === undefined` sites below only fire when a session CLAIMS an id. A token-authenticated
|
|
265
|
+
* daemon resolves identity per request and reports `pre-bound (N agents)`, so `bound` is never
|
|
266
|
+
* undefined and NO tail ever started — measured 2026-09-17 as `herdrTail: []` on a freshly
|
|
267
|
+
* restarted daemon whose seats had re-joined after it. With the send-time path deferring owed
|
|
268
|
+
* messages to "the recipient's own tail", a tail that never exists makes that deferral a permanent
|
|
269
|
+
* silent drop: one message behind and the seat is deaf for good (⟨q-cdb5b007⟩).
|
|
270
|
+
*
|
|
271
|
+
* Idempotent, so the per-request call costs one Map lookup once the tail is up.
|
|
272
|
+
*/
|
|
241
273
|
export function startTailOnBind(agentId: string, log: (line: string) => void = (l) => console.error(l)): void {
|
|
242
|
-
const r = ensureHerdrTail(agentId);
|
|
274
|
+
const r = ensureHerdrTail(agentId, { seedEof: true });
|
|
243
275
|
if (r.started) log(`[agent-coord-mcp] herdr tail: ${r.why}`);
|
|
244
276
|
}
|
package/src/tools/transport.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
|
|
2
2
|
import {
|
|
3
|
-
TMUX_PUSH,
|
|
4
3
|
registerTmuxHost,
|
|
5
4
|
activeTransport,
|
|
6
5
|
HERDR,
|
|
@@ -8,7 +7,6 @@ import {
|
|
|
8
7
|
isLocallyProbeable,
|
|
9
8
|
isTmuxKind,
|
|
10
9
|
paneExists,
|
|
11
|
-
probePane,
|
|
12
10
|
tmuxVersion,
|
|
13
11
|
targetOf,
|
|
14
12
|
tmuxAvailable,
|
|
@@ -30,9 +28,9 @@ import { resolveServerIdentity } from "../server-identity.js";
|
|
|
30
28
|
import { capabilitiesTool } from "../capabilities.js";
|
|
31
29
|
import { sendMessageTool, readMessagesTool } from "./messaging.js";
|
|
32
30
|
import { randomUUID } from "node:crypto";
|
|
33
|
-
import { existsSync,
|
|
31
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
34
32
|
import { promises as fsp } from "node:fs";
|
|
35
|
-
import {
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
36
34
|
import { fileURLToPath } from "node:url";
|
|
37
35
|
import { z } from "zod";
|
|
38
36
|
import { seatBuildOf, installedFrom, psReader } from "./seat-build.js";
|
|
@@ -691,158 +689,22 @@ export async function attachAgentTool(args: {
|
|
|
691
689
|
note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
|
|
692
690
|
};
|
|
693
691
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
// Validate target exists. The probe's discriminating power, and the control
|
|
704
|
-
// that proves it, are documented once on `paneExists`.
|
|
705
|
-
const targetProbe = probePane(target);
|
|
706
|
-
if (!targetProbe.exists) {
|
|
707
|
-
return {
|
|
708
|
-
ok: false,
|
|
709
|
-
error: `tmux target '${target}' not found: ${targetProbe.stderr}`,
|
|
710
|
-
};
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// If something's already attached, refuse rather than spawn a second pusher.
|
|
714
|
-
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
715
|
-
if (markerHoldsLiveProcess(existing)) {
|
|
716
|
-
return {
|
|
717
|
-
ok: false,
|
|
718
|
-
error: `agent '${args.agentId}' already has a live ${existing!.transport} attached (pid ${existing!.pid}). Call detach_agent first.`,
|
|
719
|
-
existing,
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
// Clean up dead marker, if any.
|
|
723
|
-
if (existing) await deleteFile(transportFile(args.agentId));
|
|
724
|
-
|
|
725
|
-
const pusher = resolvePusherPath();
|
|
726
|
-
if (!existsSync(pusher)) {
|
|
727
|
-
return { ok: false, error: `tmux-pusher not found at ${pusher}` };
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Detached spawn so the pusher outlives this MCP request/process.
|
|
731
|
-
const log = logFile(args.agentId, "pusher");
|
|
732
|
-
await fsp.mkdir(path.dirname(log), { recursive: true });
|
|
733
|
-
await fsp.mkdir(path.dirname(pidFile(args.agentId, "pusher")), { recursive: true });
|
|
734
|
-
await fsp.mkdir(path.dirname(transportFile(args.agentId)), { recursive: true });
|
|
735
|
-
const logFd = openSync(log, "a");
|
|
736
|
-
// Default: deliver room broadcasts too. The bus is chat-first — silence on
|
|
737
|
-
// a room post is a worse failure mode than a slightly noisier pane. Callers
|
|
738
|
-
// who want DM-only can pass includeRoom:false explicitly.
|
|
739
|
-
const includeRoom = args.includeRoom !== false;
|
|
740
|
-
// Use the exact node binary running this server, not bare "node" — the MCP
|
|
741
|
-
// server is often launched via an absolute path (nvm/Homebrew/bundled
|
|
742
|
-
// runtime) that isn't on the spawned child's PATH, which would silently fail
|
|
743
|
-
// the pusher launch ("attached but nothing arrives").
|
|
744
|
-
// `--agent <id>` is inert to the pusher (env stays authoritative) but puts
|
|
745
|
-
// the agentId in argv, so a pattern kill can be scoped to ONE pusher
|
|
746
|
-
// (`pkill -f "tmux-pusher.mjs --agent <id>"`). Without it the only matchable
|
|
747
|
-
// pattern was the script path, and a `pkill -f tmux-pusher.mjs` during one
|
|
748
|
-
// agent's cleanup silently detached every live agent on the bus (2026-07-28).
|
|
749
|
-
const child = spawn(process.execPath, [pusher, "--agent", args.agentId], {
|
|
750
|
-
detached: true,
|
|
751
|
-
stdio: ["ignore", logFd, logFd],
|
|
752
|
-
env: {
|
|
753
|
-
...process.env,
|
|
754
|
-
AGENT_COORD_ID: args.agentId,
|
|
755
|
-
AGENT_COORD_TMUX_TARGET: target,
|
|
756
|
-
...(includeRoom ? { AGENT_COORD_INCLUDE_ROOM: "1" } : {}),
|
|
757
|
-
...(args.allowlist && args.allowlist.length > 0
|
|
758
|
-
? { AGENT_COORD_ALLOWLIST: args.allowlist.join(",") }
|
|
759
|
-
: {}),
|
|
760
|
-
...(args.debounceMs ? { AGENT_COORD_DEBOUNCE_MS: String(args.debounceMs) } : {}),
|
|
761
|
-
},
|
|
762
|
-
});
|
|
763
|
-
child.unref();
|
|
764
|
-
const pid = child.pid;
|
|
765
|
-
if (!pid) return { ok: false, error: "spawn returned no pid" };
|
|
766
|
-
|
|
767
|
-
// Write pid file (for scripts) and transport marker (for list_agents).
|
|
768
|
-
await fsp.writeFile(pidFile(args.agentId, "pusher"), String(pid), "utf8");
|
|
769
|
-
// Stamp the pusher source's freshness so doctor() can flag a stale daemon if
|
|
770
|
-
// it outlives a later upgrade of the on-disk code (see v0.8.1 → v0.8.2 bug
|
|
771
|
-
// report: control commands silently dropped by pre-v0.8 in-memory code).
|
|
772
|
-
const scriptMtime = newestPusherSourceMtime();
|
|
773
|
-
const marker: TransportMarker = {
|
|
774
|
-
agentId: args.agentId,
|
|
775
|
-
transport: TMUX_PUSH,
|
|
776
|
-
pid,
|
|
777
|
-
// DUAL-WRITTEN, and the duplication is the point. `target` is what every
|
|
778
|
-
// consumer now reads (`targetOf`); `tmuxTarget` is what the code a merge
|
|
779
|
-
// revert restores reads. Writing only the new field would leave markers the
|
|
780
|
-
// old server cannot parse, and that failure does not degrade gracefully —
|
|
781
|
-
// it silences every attached lane at once.
|
|
782
|
-
target,
|
|
783
|
-
tmuxTarget: target,
|
|
784
|
-
since: Date.now(),
|
|
785
|
-
scriptMtime,
|
|
786
|
-
// Provenance: the build identity THIS server loaded at startup — not a
|
|
787
|
-
// fresh stat of dist/, because the code doing the stamping is the loaded
|
|
788
|
-
// code, and after an in-place rebuild the two differ (that difference is
|
|
789
|
-
// exactly what doctor's provenance check exists to surface).
|
|
790
|
-
serverBuildMtime: SERVER_BUILD_MTIME,
|
|
791
|
-
// WHAT this transport carries, recorded by the code that decides it.
|
|
792
|
-
// `includeRoom` is what the pusher is actually spawned with a few lines
|
|
793
|
-
// above, so the marker cannot claim a capability the process was not
|
|
794
|
-
// given — the marker and the spawn come from one value, not two.
|
|
795
|
-
rooms: includeRoom,
|
|
796
|
-
};
|
|
797
|
-
// Use updateJson so it lockfile-protects and creates the file atomically.
|
|
798
|
-
await updateJson<TransportMarker>(transportFile(args.agentId), marker, () => marker);
|
|
799
|
-
|
|
800
|
-
// Best-effort scan for a peek-coord.mjs hook wired to the same agentId —
|
|
801
|
-
// both consumers share the cursor file and would race / double-deliver.
|
|
802
|
-
const conflictingHook = await detectPeekCoordHook(args.agentId);
|
|
803
|
-
|
|
692
|
+
// ⟨q-ec020f6a⟩ slice C — local tmux-push DELETED outright: 0 of 13 live seats used it,
|
|
693
|
+
// and keeping it meant a second delivery mechanism (hooks/tmux-pusher.mjs, now removed)
|
|
694
|
+
// plus a role-card precondition that halted every live herdr seat on a false requirement.
|
|
695
|
+
// attach_agent handles exactly one kind now. A remote seat never came through here in
|
|
696
|
+
// the first place — it registers via report_transport (scripts/coord-pusher.mjs) — so
|
|
697
|
+
// this refuses rather than silently trying (and failing) to spawn a script that no
|
|
698
|
+
// longer exists.
|
|
804
699
|
return {
|
|
805
|
-
ok:
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
log,
|
|
811
|
-
...(conflictingHook
|
|
812
|
-
? {
|
|
813
|
-
warnings: [
|
|
814
|
-
`peek-coord.mjs hook for agentId='${args.agentId}' detected in ${conflictingHook}. ` +
|
|
815
|
-
`Running both transports causes double-delivery — disable one. ` +
|
|
816
|
-
`Recommend removing the peek-coord hook entry since tmux-push supersedes it.`,
|
|
817
|
-
],
|
|
818
|
-
}
|
|
819
|
-
: {}),
|
|
700
|
+
ok: false,
|
|
701
|
+
error:
|
|
702
|
+
`attach_agent only attaches the herdr transport now (this fleet's active transport is ` +
|
|
703
|
+
`${activeT ? `'${activeT.kind}'` : "unconfigured"}) — local tmux-push was removed (⟨q-ec020f6a⟩). ` +
|
|
704
|
+
`A remote seat registers via report_transport, not attach_agent.`,
|
|
820
705
|
};
|
|
821
706
|
}
|
|
822
707
|
|
|
823
|
-
async function detectPeekCoordHook(agentId: string): Promise<string | undefined> {
|
|
824
|
-
const home = process.env.HOME ?? "";
|
|
825
|
-
const cwd = process.cwd();
|
|
826
|
-
const candidates = [
|
|
827
|
-
path.join(home, ".claude", "settings.json"),
|
|
828
|
-
path.join(home, ".claude", "settings.local.json"),
|
|
829
|
-
path.join(cwd, ".claude", "settings.json"),
|
|
830
|
-
path.join(cwd, ".claude", "settings.local.json"),
|
|
831
|
-
];
|
|
832
|
-
for (const file of candidates) {
|
|
833
|
-
if (!existsSync(file)) continue;
|
|
834
|
-
try {
|
|
835
|
-
const raw = await fsp.readFile(file, "utf8");
|
|
836
|
-
if (raw.includes("peek-coord.mjs") && raw.includes(`AGENT_COORD_ID=${agentId}`)) {
|
|
837
|
-
return file;
|
|
838
|
-
}
|
|
839
|
-
} catch {
|
|
840
|
-
// unreadable, skip
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
return undefined;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
708
|
export const detachAgentSchema = {
|
|
847
709
|
agentId: z.string().min(1),
|
|
848
710
|
};
|
package/src/transports/config.ts
CHANGED
|
@@ -7,18 +7,27 @@
|
|
|
7
7
|
* mid-process, and then two calls in one session disagree about what the fleet
|
|
8
8
|
* is doing.
|
|
9
9
|
*
|
|
10
|
-
* ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to
|
|
10
|
+
* ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to anything.
|
|
11
11
|
*
|
|
12
12
|
* The reason is not tidiness. A SILENT FALLBACK AND A CORRECT DEFAULT PRODUCE
|
|
13
|
-
* IDENTICAL EVIDENCE: both give you a fleet on
|
|
14
|
-
* a typo in the config reads exactly like a deliberate default, and the
|
|
15
|
-
* who typed `heardr` spends the afternoon asking why their transport
|
|
16
|
-
* nothing. Refusing is louder than the bug it prevents.
|
|
13
|
+
* IDENTICAL EVIDENCE: both give you a fleet on some transport with nothing in any
|
|
14
|
+
* log, so a typo in the config reads exactly like a deliberate default, and the
|
|
15
|
+
* person who typed `heardr` spends the afternoon asking why their transport
|
|
16
|
+
* change did nothing. Refusing is louder than the bug it prevents.
|
|
17
|
+
*
|
|
18
|
+
* ⟨q-ec020f6a⟩ THERE IS NO BUILT-IN DEFAULT ANY MORE, for the identical reason.
|
|
19
|
+
* `tmux-push` (0 of 13 live seats) used to be it; deleting that kind without
|
|
20
|
+
* removing the fallback would have meant every unconfigured session refused
|
|
21
|
+
* with "unknown transport 'tmux-push'" — a config-file bug wearing a runtime
|
|
22
|
+
* bug's clothes. Picking a new implicit default (herdr, say) would be correct
|
|
23
|
+
* on THIS fleet today and wrong on the next machine that doesn't run herdr —
|
|
24
|
+
* the exact silent-guess failure ⟨q-e439e4ad⟩ spent a day fixing, moved one
|
|
25
|
+
* layer up. So: zero configuration REFUSES, naming the kinds that exist.
|
|
17
26
|
*/
|
|
18
27
|
import { existsSync, readFileSync } from "node:fs";
|
|
19
28
|
import path from "node:path";
|
|
20
29
|
import { ROOT } from "../store.js";
|
|
21
|
-
import {
|
|
30
|
+
import { TRANSPORT_KINDS, type TransportKind } from "./types.js";
|
|
22
31
|
|
|
23
32
|
/** `$AGENT_COORD_DIR/config.json`, the fleet-wide file. */
|
|
24
33
|
export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
|
|
@@ -39,7 +48,7 @@ export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
|
|
|
39
48
|
*/
|
|
40
49
|
export type ConfiguredTransport = {
|
|
41
50
|
kind: TransportKind;
|
|
42
|
-
source: "config" | "env"
|
|
51
|
+
source: "config" | "env";
|
|
43
52
|
/** Where the value came from, for an error message a human can act on. */
|
|
44
53
|
origin: string;
|
|
45
54
|
};
|
|
@@ -48,9 +57,9 @@ function refuse(value: string, origin: string): never {
|
|
|
48
57
|
throw new Error(
|
|
49
58
|
`[agent-coord-mcp] unknown transport ${JSON.stringify(value)} from ${origin}. ` +
|
|
50
59
|
`Valid: ${TRANSPORT_KINDS.join(", ")}. ` +
|
|
51
|
-
`REFUSING AT STARTUP rather than falling back to
|
|
52
|
-
`default leave identical evidence, so a typo here would look exactly like a working
|
|
53
|
-
`transport change would appear to do nothing. Fix the value or remove it to
|
|
60
|
+
`REFUSING AT STARTUP rather than falling back to anything — a silent fallback and a correct ` +
|
|
61
|
+
`default leave identical evidence, so a typo here would look exactly like a working value and the ` +
|
|
62
|
+
`transport change would appear to do nothing. Fix the value or remove it (⟨q-ec020f6a⟩: there is no default to fall back to).`,
|
|
54
63
|
);
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -97,8 +106,12 @@ export function configuredTransport(): ConfiguredTransport {
|
|
|
97
106
|
return cached;
|
|
98
107
|
}
|
|
99
108
|
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
throw new Error(
|
|
110
|
+
`[agent-coord-mcp] no transport configured — set $${TRANSPORT_ENV_VAR} or ${TRANSPORT_CONFIG_FILE} ` +
|
|
111
|
+
`("transport") to one of: ${TRANSPORT_KINDS.join(", ")}. ` +
|
|
112
|
+
`REFUSING rather than picking one: a default that is right for this fleet today is wrong for the ` +
|
|
113
|
+
`next machine that doesn't have it, and the two failures leave identical evidence.`,
|
|
114
|
+
);
|
|
102
115
|
}
|
|
103
116
|
|
|
104
117
|
/**
|
package/src/transports/herdr.ts
CHANGED
|
@@ -257,6 +257,24 @@ export class HerdrTransport implements Transport {
|
|
|
257
257
|
if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`);
|
|
258
258
|
let target = args.target ?? this.#env.HERDR_PANE_ID;
|
|
259
259
|
if (!target) {
|
|
260
|
+
// ⟨q-e439e4ad⟩ `herdr pane current` asks HERDR FOR WHATEVER PANE IS CURRENTLY
|
|
261
|
+
// FOCUSED ON THIS HOST — under stdio that is correct, because the server process
|
|
262
|
+
// itself lives inside the caller's own pane (the same relationship $TMUX_PANE has
|
|
263
|
+
// to a tmux server). AGENT_COORD_HTTP_PORT set means this process is instead the
|
|
264
|
+
// shared bus DAEMON: one long-lived process with no pane of its own, answering
|
|
265
|
+
// every seat on the host. "Current" there names whichever terminal a human last
|
|
266
|
+
// focused — measured attaching two different seats onto ANOTHER FLEET's panes,
|
|
267
|
+
// both calls returning `ok`. Same rule as transports/config.ts's unknown-transport
|
|
268
|
+
// refusal: a silent fallback and a correct default produce identical evidence, so
|
|
269
|
+
// this refuses instead of guessing.
|
|
270
|
+
if (this.#env.AGENT_COORD_HTTP_PORT) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`herdr target not provided for '${args.agentId}': this server is the shared HTTP daemon ` +
|
|
273
|
+
`(AGENT_COORD_HTTP_PORT set) and has no pane of its own, so \`herdr pane current\` would name ` +
|
|
274
|
+
`whichever pane last had focus on this host, not '${args.agentId}''s. Pass target explicitly ` +
|
|
275
|
+
`(e.g. 'w2:p1').`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
260
278
|
const cur = this.#run(["pane", "current"]);
|
|
261
279
|
target = paneOf(cur)?.pane_id;
|
|
262
280
|
}
|
package/src/transports/index.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* fall-through at a call site. Until Task 3, tmux is the only registered
|
|
7
7
|
* implementation and that is the rollback plan — the seam lands behind no config.
|
|
8
8
|
*/
|
|
9
|
-
import { HERDR,
|
|
9
|
+
import { HERDR, TMUX_PUSH_REMOTE, TRANSPORT_KINDS, type Transport, type TransportKind } from "./types.js";
|
|
10
10
|
import { TmuxTransport, type TmuxHost } from "./tmux.js";
|
|
11
11
|
import { HerdrTransport } from "./herdr.js";
|
|
12
12
|
import { configuredTransport } from "./config.js";
|
|
@@ -31,7 +31,8 @@ export function resolveTransport(kind: TransportKind): Transport {
|
|
|
31
31
|
);
|
|
32
32
|
}
|
|
33
33
|
switch (kind) {
|
|
34
|
-
|
|
34
|
+
// ⟨q-ec020f6a⟩ local tmux-push deleted outright — TmuxTransport now only ever
|
|
35
|
+
// backs the remote kind. Kept fully intact: only this construction site changed.
|
|
35
36
|
case TMUX_PUSH_REMOTE:
|
|
36
37
|
return new TmuxTransport(host, kind);
|
|
37
38
|
case HERDR:
|
package/src/transports/tmux.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { spawnSync } from "node:child_process";
|
|
16
16
|
import type { ControlCommand, Liveness, Transport, TransportMarker, TransportKind } from "./types.js";
|
|
17
|
-
import {
|
|
17
|
+
import { TMUX_PUSH_REMOTE, isLocallyProbeable, isTmuxKind, targetOf } from "./types.js";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The parts of delivery that belong to the PROCESS layer, not to tmux.
|
|
@@ -87,7 +87,10 @@ export class TmuxTransport implements Transport {
|
|
|
87
87
|
readonly kind: TransportKind;
|
|
88
88
|
#host: TmuxHost;
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// ⟨q-ec020f6a⟩ local tmux-push deleted: this class now only ever backs the remote kind,
|
|
91
|
+
// so the default (used only by tests that omit `kind`) follows that — everything else
|
|
92
|
+
// in this file is unchanged, per the slice's "keep TmuxTransport fully intact" scope.
|
|
93
|
+
constructor(host: TmuxHost, kind: TransportKind = TMUX_PUSH_REMOTE) {
|
|
91
94
|
this.#host = host;
|
|
92
95
|
this.kind = kind;
|
|
93
96
|
}
|
package/src/transports/types.ts
CHANGED
|
@@ -27,17 +27,32 @@
|
|
|
27
27
|
* instead of reading the field, and a `string` discriminant cannot tell the
|
|
28
28
|
* compiler that a branch was missed. Not a two-member union either — see (1).
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* ⟨q-ec020f6a⟩ LOCAL tmux-push was DELETED outright (0 of 13 live seats used it; keeping
|
|
32
|
+
* it meant a second delivery mechanism — hooks/tmux-pusher.mjs, now removed — plus a
|
|
33
|
+
* role-card precondition that halted every live herdr seat on a false requirement).
|
|
34
|
+
*
|
|
35
|
+
* `TMUX_PUSH` stays DEFINED AND EXPORTED, but OUT of `TransportKind`/`TRANSPORT_KINDS`, for
|
|
36
|
+
* exactly one reason: a marker written to disk before this change can still literally read
|
|
37
|
+
* `"tmux-push"`, and `isLocallyProbeable`/`isTmuxKind` below must keep classifying that
|
|
38
|
+
* HISTORICAL value correctly (never `"dead"` where it used to read `"unknown"`) even
|
|
39
|
+
* though nothing can construct a NEW one — `resolveTransport` has no case for it and
|
|
40
|
+
* `config.ts` refuses it. Classifying old data and constructing new data are different
|
|
41
|
+
* questions; removing it from the union answers only the second.
|
|
42
|
+
*/
|
|
30
43
|
export const TMUX_PUSH = "tmux-push" as const;
|
|
31
44
|
export const TMUX_PUSH_REMOTE = "tmux-push-remote" as const;
|
|
32
45
|
export const HERDR = "herdr" as const;
|
|
33
46
|
|
|
34
|
-
export type TransportKind = typeof
|
|
47
|
+
export type TransportKind = typeof TMUX_PUSH_REMOTE | typeof HERDR;
|
|
35
48
|
|
|
36
|
-
export const TRANSPORT_KINDS: readonly TransportKind[] = [
|
|
49
|
+
export const TRANSPORT_KINDS: readonly TransportKind[] = [TMUX_PUSH_REMOTE, HERDR] as const;
|
|
37
50
|
|
|
38
51
|
/**
|
|
39
|
-
* The tmux family
|
|
40
|
-
*
|
|
52
|
+
* The tmux family, for LIVENESS CLASSIFICATION of whatever a marker on disk says —
|
|
53
|
+
* including the historical, no-longer-constructible local kind (`TMUX_PUSH`, above).
|
|
54
|
+
* Both members are delivered by a pusher process typing into a pane; they differed in
|
|
55
|
+
* WHERE that pane was, which is why liveness splits below.
|
|
41
56
|
*
|
|
42
57
|
* THIS IS THE ONE PLACE THE TMUX LITERALS LIVE. Twelve call sites used to spell
|
|
43
58
|
* them; they now ask.
|