agent-coord-mcp 0.26.27 → 0.26.29
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 +18 -12
- package/dist/bind.js +40 -0
- package/dist/bind.js.map +1 -0
- package/dist/capabilities.js +91 -2
- package/dist/capabilities.js.map +1 -1
- package/dist/server.js +48 -15
- package/dist/server.js.map +1 -1
- package/dist/tools/herdr-tail.js +28 -2
- package/dist/tools/herdr-tail.js.map +1 -1
- package/dist/tools/stall.js +29 -1
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/transport.js +1 -1
- package/dist/transports/config.js +42 -25
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +79 -2
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +1 -1
- package/dist/transports/index.js.map +1 -1
- package/hooks/push-cursor.mjs +22 -6
- package/package.json +2 -1
- package/scripts/coord-stall-clock.mjs +26 -2
- package/src/bind.ts +40 -0
- package/src/capabilities.ts +101 -3
- package/src/server.ts +57 -18
- package/src/tools/herdr-tail.ts +39 -2
- package/src/tools/stall.ts +48 -3
- package/src/tools/transport.ts +1 -1
- package/src/transports/config.ts +56 -38
- package/src/transports/herdr.ts +101 -3
- package/src/transports/index.ts +1 -1
package/src/server.ts
CHANGED
|
@@ -118,6 +118,7 @@ import {
|
|
|
118
118
|
isPidAlive,
|
|
119
119
|
} from "./tools/index.js";
|
|
120
120
|
import { queueWriteSchema, queueWriteTool } from "./tools/queue-write.js";
|
|
121
|
+
import { parseBindList, isLoopbackAddr } from "./bind.js";
|
|
121
122
|
|
|
122
123
|
function jsonResult(data: unknown) {
|
|
123
124
|
return {
|
|
@@ -939,19 +940,27 @@ async function startHttp(port: number): Promise<void> {
|
|
|
939
940
|
"to pre-bind sessions to identities at connect time.",
|
|
940
941
|
);
|
|
941
942
|
}
|
|
942
|
-
|
|
943
|
+
// ⟨q-79ee2086⟩ A LIST, because one address forces a choice the deployment should not have to
|
|
944
|
+
// make. The bus was reachable on the Tailscale address OR the LAN and never both, so adding a
|
|
945
|
+
// second machine meant MOVING the bind and breaking every local seat's config at the same moment.
|
|
946
|
+
// A single value still parses to a one-element list, so existing deployments are unaffected.
|
|
947
|
+
const bindAddrs = parseBindList(process.env.AGENT_COORD_BIND);
|
|
943
948
|
const sharedExpected = sharedToken ? `Bearer ${sharedToken}` : null;
|
|
944
949
|
|
|
945
950
|
// Fail-closed network gate. A non-loopback bind is a real network listener, so
|
|
946
951
|
// it must have (a) enforced per-agent identity and (b) a secured transport. We
|
|
947
952
|
// refuse rather than warn: a shared/advisory token lets any node impersonate
|
|
948
953
|
// any agent, and plaintext leaks bearer tokens to anyone on the path.
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
954
|
+
//
|
|
955
|
+
// ⛔ EVALUATED OVER THE WHOLE LIST, NOT PER ADDRESS. `127.0.0.1,192.168.1.10` must NOT pass
|
|
956
|
+
// because one entry happens to be loopback — the listener on the second address is exposed
|
|
957
|
+
// whatever the first one is, and a gate that a caller can satisfy by prepending a loopback
|
|
958
|
+
// address is not a gate. The offending address is NAMED so the refusal is actionable.
|
|
959
|
+
const exposed = bindAddrs.filter((a) => !isLoopbackAddr(a));
|
|
960
|
+
if (exposed.length > 0) {
|
|
952
961
|
if (!bound) {
|
|
953
962
|
console.error(
|
|
954
|
-
`[agent-coord-mcp] refusing to bind ${
|
|
963
|
+
`[agent-coord-mcp] refusing to bind ${exposed.join(", ")} without per-agent tokens: a ` +
|
|
955
964
|
`shared/advisory token lets any node impersonate any agent. Create ` +
|
|
956
965
|
`~/agent-coord/tokens.json (per-agent, enforced identity) for network binds.`,
|
|
957
966
|
);
|
|
@@ -959,7 +968,7 @@ async function startHttp(port: number): Promise<void> {
|
|
|
959
968
|
}
|
|
960
969
|
if (process.env.AGENT_COORD_INSECURE !== "1") {
|
|
961
970
|
console.error(
|
|
962
|
-
`[agent-coord-mcp] refusing plaintext bind to ${
|
|
971
|
+
`[agent-coord-mcp] refusing plaintext bind to ${exposed.join(", ")}: bearer tokens would ` +
|
|
963
972
|
`travel in cleartext. Put the bus behind TLS or a private overlay ` +
|
|
964
973
|
`(Tailscale/WireGuard), then set AGENT_COORD_INSECURE=1 to acknowledge the ` +
|
|
965
974
|
`transport is secured out-of-band.`,
|
|
@@ -1092,7 +1101,11 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1092
1101
|
return sharedExpected && authHeader === sharedExpected ? { ok: true } : { ok: false };
|
|
1093
1102
|
}
|
|
1094
1103
|
|
|
1095
|
-
|
|
1104
|
+
// ⛔ ONE HANDLER, N SERVERS. Node binds a single address per `http.Server`, so a list of
|
|
1105
|
+
// addresses is a list of SERVERS — not a loop over `listen`. They share this closure, and with
|
|
1106
|
+
// it the session map and the bound-agent map, so a session created on one address is the same
|
|
1107
|
+
// session on another: the addresses are doors into one bus, not separate buses.
|
|
1108
|
+
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
|
1096
1109
|
try {
|
|
1097
1110
|
// Unauthenticated liveness probe so reverse proxies / orchestrators can
|
|
1098
1111
|
// health-check without needing a credential.
|
|
@@ -1150,18 +1163,44 @@ async function startHttp(port: number): Promise<void> {
|
|
|
1150
1163
|
res.end("internal error\n");
|
|
1151
1164
|
}
|
|
1152
1165
|
}
|
|
1153
|
-
}
|
|
1166
|
+
};
|
|
1154
1167
|
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1168
|
+
const mode = bound ? `pre-bound (${getTokenMap()?.size ?? 0} agents)` : "TOFU";
|
|
1169
|
+
await Promise.all(
|
|
1170
|
+
bindAddrs.map(
|
|
1171
|
+
(addr) =>
|
|
1172
|
+
new Promise<void>((resolve) => {
|
|
1173
|
+
const server = createServer(handler);
|
|
1174
|
+
// ⛔ FAIL CLOSED ON A BIND ERROR. A declared address that cannot be bound —
|
|
1175
|
+
// EADDRNOTAVAIL because the interface is not up yet, EADDRINUSE because something
|
|
1176
|
+
// else holds the port — must REFUSE, not leave the daemon serving the subset that
|
|
1177
|
+
// happened to work. A bus up on one of two declared addresses while saying nothing is
|
|
1178
|
+
// the silent-partial-success shape this codebase refuses everywhere else: the seats on
|
|
1179
|
+
// the missing address fail to connect and nothing explains why. launchd's KeepAlive
|
|
1180
|
+
// already covers the honest case (tailscaled has not brought utun0 up yet) by retrying.
|
|
1181
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
1182
|
+
console.error(
|
|
1183
|
+
`[agent-coord-mcp] refusing to start: cannot bind ${addr}:${port} (${err.code ?? err.message}). ` +
|
|
1184
|
+
`AGENT_COORD_BIND declares ${bindAddrs.length} address(es) and every one must bind — ` +
|
|
1185
|
+
`serving only some would leave seats on the others unable to connect, with nothing said.`,
|
|
1186
|
+
);
|
|
1187
|
+
process.exit(1);
|
|
1188
|
+
});
|
|
1189
|
+
server.listen(port, addr, () => {
|
|
1190
|
+
// One line PER ADDRESS: a single "http listening on …" would be read as the whole
|
|
1191
|
+
// picture, and the operator needs to see which doors are actually open.
|
|
1192
|
+
console.error(`[agent-coord-mcp] http listening on ${addr}:${port} — identity ${mode}`);
|
|
1193
|
+
if (!isLoopbackAddr(addr)) {
|
|
1194
|
+
console.error(
|
|
1195
|
+
`[agent-coord-mcp] WARNING: bound to ${addr} without TLS. Front with a TLS reverse proxy ` +
|
|
1196
|
+
`(or restrict to a private network e.g. Tailscale/WireGuard) before exposing publicly.`,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
resolve();
|
|
1200
|
+
});
|
|
1201
|
+
}),
|
|
1202
|
+
),
|
|
1203
|
+
);
|
|
1165
1204
|
}
|
|
1166
1205
|
|
|
1167
1206
|
main().catch((err) => {
|
package/src/tools/herdr-tail.ts
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
*/
|
|
40
40
|
import { readFileSync, statSync } from "node:fs";
|
|
41
41
|
import { inboxFile, roomFile, getRooms, transportFile } from "../store.js";
|
|
42
|
-
import { activeTransport, HERDR, type Transport, type TransportMarker } from "../transports/index.js";
|
|
42
|
+
import { activeTransport, HERDR, targetOf, type Transport, type TransportMarker } from "../transports/index.js";
|
|
43
43
|
import { renderForPane, advancePushCursor, readPushCursorFor, type Message } from "./herdr-delivery.js";
|
|
44
44
|
import { newMessagesIn } from "./jsonl-offsets.js";
|
|
45
45
|
|
|
@@ -103,9 +103,19 @@ export async function tailOnce(
|
|
|
103
103
|
ctx?: TailContext;
|
|
104
104
|
markerOf?: (agentId: string) => TransportMarker | null;
|
|
105
105
|
roomsOf?: () => Promise<Record<string, { members?: string[] }>>;
|
|
106
|
+
/**
|
|
107
|
+
* ⟨q-1ce5bc97⟩ step 1 — EVERY push outcome, logged, not just the ones that end up in
|
|
108
|
+
* `held`. Before this, bus-daemon.err.log recorded no push outcomes at all: a
|
|
109
|
+
* `delivered` and a silently-dropped `typed-unconfirmed` looked identical from the
|
|
110
|
+
* log, because nothing wrote either one. Injectable so a test can capture lines
|
|
111
|
+
* without a real stderr; defaults to `console.error` — the same sink every other
|
|
112
|
+
* diagnostic in this process already writes to.
|
|
113
|
+
*/
|
|
114
|
+
log?: (line: string) => void;
|
|
106
115
|
} = {},
|
|
107
116
|
): Promise<TailOutcome> {
|
|
108
117
|
const out: TailOutcome = { delivered: [], held: [] };
|
|
118
|
+
const log = opts.log ?? ((l: string) => console.error(l));
|
|
109
119
|
const t = opts.transport ?? activeTransport();
|
|
110
120
|
if (!t || t.kind !== HERDR) return { ...out, idle: "this server's transport is not herdr — the tail is for a herdr seat's own inbox" };
|
|
111
121
|
const ctx = opts.ctx ?? newContext();
|
|
@@ -117,6 +127,19 @@ export async function tailOnce(
|
|
|
117
127
|
// read its marker file on every tick while the stated idle cost said otherwise.
|
|
118
128
|
if (refresh) {
|
|
119
129
|
ctx.marker = (opts.markerOf ?? readOwnMarker)(agentId);
|
|
130
|
+
// ⟨q-d58ccdce⟩ ⛔⛆⛆ THIS BRANCH IS THE WHOLE DEFECT. `marker.rooms === false` makes
|
|
131
|
+
// `ctx.rooms` PERMANENTLY EMPTY — every room this agent belongs to is silently excluded
|
|
132
|
+
// from `sources` below, so the per-source loop never runs for a single one of them. That
|
|
133
|
+
// is a total, structural skip with no `continue` to log: #395's per-outcome push logging
|
|
134
|
+
// fires only for a source the loop actually reaches, and a room that is never a source
|
|
135
|
+
// produces zero log lines forever — indistinguishable from a room with nothing owed.
|
|
136
|
+
// Measured live 2026-09-19: every groundwork-kit-* seat's marker had `rooms:false` (a
|
|
137
|
+
// side effect of `attach_agent {..., includeRoom:false}`, called believing it only
|
|
138
|
+
// affected DM liveness), while every unaffected control seat had `rooms:true`. So THIS
|
|
139
|
+
// is the log line the class needed — not one more line inside a loop that never starts.
|
|
140
|
+
if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms === false) {
|
|
141
|
+
log(`[herdr-tail] rooms DISABLED for ${agentId} — marker.rooms=false, so no room is ever tailed regardless of membership (⟨q-d58ccdce⟩); re-attach with includeRoom:true (or omit it) to restore room delivery`);
|
|
142
|
+
}
|
|
120
143
|
if (ctx.marker && ctx.marker.transport === HERDR && ctx.marker.rooms !== false) {
|
|
121
144
|
try {
|
|
122
145
|
const all = await (opts.roomsOf ?? getRooms)();
|
|
@@ -140,13 +163,20 @@ export async function tailOnce(
|
|
|
140
163
|
// ⛔ FIRST SIGHT OF A SOURCE UNDER seedEof: adopt EOF and type NOTHING. This is the only place
|
|
141
164
|
// the starting offset is decided, and it is decided EXPLICITLY rather than inherited from a
|
|
142
165
|
// 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.
|
|
166
|
+
// own first sight instead of replaying its history. Logged (low-frequency: once per source
|
|
167
|
+
// per process) so a seat that never types into a newly-joined room is distinguishable from
|
|
168
|
+
// one that was never told the room existed.
|
|
144
169
|
if (ctx.seedEof && !ctx.seeded.has(key)) {
|
|
145
170
|
ctx.seeded.add(key);
|
|
146
171
|
ctx.hw.set(key, size);
|
|
172
|
+
log(`[herdr-tail] seeded ${key} at EOF (${size} bytes) for ${agentId} — first sight this process, nothing predating the tail will be typed`);
|
|
147
173
|
continue;
|
|
148
174
|
}
|
|
149
175
|
const from = await offsetOf(agentId, src, ctx);
|
|
176
|
+
// ⭐ NEGATIVE CONTROL FOR THE CLASS THIS ROW IS ABOUT: nothing is logged here when
|
|
177
|
+
// `size === from` — a source with nothing owed must stay silent, or every idle room on
|
|
178
|
+
// every tick becomes a line and the log that is supposed to make a real stall visible
|
|
179
|
+
// drowns in ones that are not.
|
|
150
180
|
if (size <= from) continue;
|
|
151
181
|
|
|
152
182
|
for (const { msg, end } of newMessagesIn(src.file, from)) {
|
|
@@ -155,6 +185,13 @@ export async function tailOnce(
|
|
|
155
185
|
const rendered = await renderForPane(msg as unknown as Message, agentId, where);
|
|
156
186
|
let r: { delivered: boolean; error?: string; verified?: boolean; safeToRetry?: boolean; outcome?: string };
|
|
157
187
|
try { r = await t.push(marker, rendered); } catch (e) { r = { delivered: false, safeToRetry: false, error: (e as Error).message }; }
|
|
188
|
+
// ⟨q-1ce5bc97⟩ step 1(b) — EVERY push outcome, logged, before any branch decides what to do
|
|
189
|
+
// with it. Not just the ones that end up `held`: a `delivered` needs a line too, or the
|
|
190
|
+
// log can never distinguish "nothing was owed" from "something was pushed and it worked".
|
|
191
|
+
log(
|
|
192
|
+
`[herdr-tail] push id=${String(msg.id ?? "?")} target=${targetOf(marker) ?? "?"} outcome=${r.outcome ?? (r.delivered ? "delivered" : "unknown")}` +
|
|
193
|
+
(r.error ? ` reason=${JSON.stringify(r.error)}` : ""),
|
|
194
|
+
);
|
|
158
195
|
if (!r.delivered && r.safeToRetry === true) {
|
|
159
196
|
// HELD: the transport sent NO key (no ready input box, a draft, a dialog, an unreadable pane).
|
|
160
197
|
// Nothing moves and the message is retried next tick, which costs only a screen read. Stop
|
package/src/tools/stall.ts
CHANGED
|
@@ -134,10 +134,20 @@ const haltFile = () => path.join(ROOT, "halt.json");
|
|
|
134
134
|
* board-owner bookkeeping: a row pointing at a landed branch, a routed item
|
|
135
135
|
* with no row, a merge with no verdict comment. Somebody must
|
|
136
136
|
* fix the RECORD; the lane's owner did nothing wrong.
|
|
137
|
+
* david ⟨q-11257590⟩ nobody IN THE FLEET can act on this: the coordinator
|
|
138
|
+
* seat itself is the thing missing, so there is no duty owner and no
|
|
139
|
+
* board-owner left to fix the record. David's own ruling: "coord
|
|
140
|
+
* should never be absent in reality... if we have a comms issue that
|
|
141
|
+
* just needs to be surfaced to me then i tackle it" — this audience
|
|
142
|
+
* exists so a relayer routes it to him and nowhere else.
|
|
137
143
|
*/
|
|
138
|
-
export type HitAudience = "duty" | "board-owner";
|
|
144
|
+
export type HitAudience = "duty" | "board-owner" | "david";
|
|
139
145
|
export const audienceOf = (kind: StallHitBody["kind"]): HitAudience =>
|
|
140
|
-
kind === "
|
|
146
|
+
kind === "coordinator-absent"
|
|
147
|
+
? "david"
|
|
148
|
+
: kind === "stale-row" || kind === "routed-without-row" || kind === "unverdicted-merge" || kind === "merge-window-write" || kind === "lane-left-population"
|
|
149
|
+
? "board-owner"
|
|
150
|
+
: "duty";
|
|
141
151
|
const withAudience = (hits: StallHitBody[]): StallHit[] => hits.map((h) => ({ ...h, audience: audienceOf(h.kind) }) as StallHit);
|
|
142
152
|
export type StallHit = StallHitBody & { audience: HitAudience };
|
|
143
153
|
export type StallHitBody =
|
|
@@ -226,7 +236,19 @@ export type StallHitBody =
|
|
|
226
236
|
* abandoned or genuinely unlanded — so the hit SAYS so, or a true alarm
|
|
227
237
|
* with a false implication teaches the reader to ignore the axis.
|
|
228
238
|
*/
|
|
229
|
-
| { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string }
|
|
239
|
+
| { kind: "unproposed-branch"; agentId: string; branch: string; head: string; ahead: number; minutes: number; note: string }
|
|
240
|
+
/**
|
|
241
|
+
* ⟨q-11257590⟩ — David's ruling: coordinator absence is a FAULT to be surfaced,
|
|
242
|
+
* never a state to be silently covered by a stand-in. `agentId` is read from the
|
|
243
|
+
* board's own Rooms table (`boardOwnerOf`), the same name every seat is told at
|
|
244
|
+
* `join` — never hardcoded, so a renamed coordinator seat is still found. Fires
|
|
245
|
+
* when the seat the board itself names has no registry entry at all, or has one
|
|
246
|
+
* but no currently-live transport marker (`loadLiveTransports`, which already
|
|
247
|
+
* verifies pid/pane/remote-heartbeat liveness for every transport kind — this
|
|
248
|
+
* reuses that verdict rather than re-deriving it). A present-and-active
|
|
249
|
+
* coordinator produces NO hit: the negative control this row's acceptance requires.
|
|
250
|
+
*/
|
|
251
|
+
| { kind: "coordinator-absent"; agentId: string; why: string };
|
|
230
252
|
|
|
231
253
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
232
254
|
* ⟨q-6f0a3d81⟩ — THE HANDOVER IS THE THING BEING MEASURED, AND EVERY INSTRUMENT
|
|
@@ -1345,6 +1367,29 @@ export async function stallCheckTool(
|
|
|
1345
1367
|
}));
|
|
1346
1368
|
const now = Date.now();
|
|
1347
1369
|
const hits: StallHitBody[] = [];
|
|
1370
|
+
// ⟨q-11257590⟩ — COORDINATOR ABSENCE IS A FAULT, SURFACED, NEVER COVERED. Read the
|
|
1371
|
+
// seat's own name from the board (`boardOwnerOf`, the Rooms table's "topic owner"
|
|
1372
|
+
// cell — the same identity every seat is told at `join`), not hardcoded, so a
|
|
1373
|
+
// renamed or re-elected coordinator is still found. `reg` and `liveTransports` are
|
|
1374
|
+
// already loaded above for the role rows; this reuses their verdicts rather than
|
|
1375
|
+
// re-deriving liveness. NO board owner named → nothing to check, not a hit: an
|
|
1376
|
+
// unnamed owner is a board-authoring gap, a different row's problem.
|
|
1377
|
+
const coordId = boardOwnerOf(boardText);
|
|
1378
|
+
if (coordId) {
|
|
1379
|
+
if (!reg[coordId]) {
|
|
1380
|
+
hits.push({
|
|
1381
|
+
kind: "coordinator-absent", agentId: coordId,
|
|
1382
|
+
why: `the board names '${coordId}' as coordinator (topic owner) but the bus has no registry entry for it — never joined, or evicted`,
|
|
1383
|
+
});
|
|
1384
|
+
} else if (!liveTransports.has(coordId)) {
|
|
1385
|
+
hits.push({
|
|
1386
|
+
kind: "coordinator-absent", agentId: coordId,
|
|
1387
|
+
why: `'${coordId}' is registered but has no live transport marker right now — registered once, unreachable now`,
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
// Present AND active → no hit. This is the row's required negative control: a
|
|
1391
|
+
// healthy coordinator must not surface, or this becomes a standing false alarm.
|
|
1392
|
+
}
|
|
1348
1393
|
/** The base's tip, for telling an empty claim-time push from a lane with commits. */
|
|
1349
1394
|
let baseTip: string | null = null;
|
|
1350
1395
|
for (const cand of ["origin/main", "origin/master", "main", "master"]) {
|
package/src/tools/transport.ts
CHANGED
|
@@ -1668,7 +1668,7 @@ export async function doctorTool(args: { fix?: boolean; maxFileBytes?: number })
|
|
|
1668
1668
|
// count makes EVERY push cursor on a non-trivial inbox read as past EOF — and because this
|
|
1669
1669
|
// check is `fixable`, the "clamp" then rewinds it to the count. ⟨q-f018dd51⟩: on 2026-09-17 a
|
|
1670
1670
|
// `doctor {fix:true}` did exactly that to 11 of 13 seats across two fleets in one call —
|
|
1671
|
-
//
|
|
1671
|
+
// one coordinator seat went from byte 3,413,506 to byte 992, i.e. to the START of its inbox.
|
|
1672
1672
|
// Nothing replayed only because the running tails held an in-process high-water mark; the
|
|
1673
1673
|
// damage was LATENT and would have fired on the next restart, when that mark is gone.
|
|
1674
1674
|
const boundOf = async (file: string) => (kind === "push" ? await byteSizeOf(file) : (await countFor(file)).parsed);
|
package/src/transports/config.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WHICH TRANSPORT THIS
|
|
2
|
+
* WHICH TRANSPORT A SEAT ON THIS MACHINE IS CONFIGURED TO USE (Phase 5.4 Task 3.1–3.2).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* ⟨q-d404a6f6⟩ THIS IS MACHINE-SCOPED, NOT FLEET-WIDE — the file lives at
|
|
5
|
+
* `$AGENT_COORD_DIR/config.json`, a path local to ONE box. A fleet now spans
|
|
6
|
+
* machines: `tmux-push-remote` exists precisely for a seat on another host, and
|
|
7
|
+
* that seat never reads THIS machine's config.json — it has (or lacks) its own.
|
|
8
|
+
* Calling this "the fleet's statement" was false and load-bearing: it read as
|
|
9
|
+
* an argument against ANY compiled default, when the argument that actually
|
|
10
|
+
* holds (below) is narrower — against a default picked HERE being assumed
|
|
11
|
+
* correct on a DIFFERENT machine. Per-process (read ONCE at startup, no
|
|
12
|
+
* per-seat branching in `send_command` or `attach` for the seats ON THIS BOX)
|
|
13
|
+
* is still exactly right; "whole-fleet" was the wrong word for it.
|
|
9
14
|
*
|
|
10
15
|
* ⛔ AN UNKNOWN VALUE REFUSES AT STARTUP. It does not fall back to anything.
|
|
11
16
|
*
|
|
12
17
|
* The reason is not tidiness. A SILENT FALLBACK AND A CORRECT DEFAULT PRODUCE
|
|
13
|
-
* IDENTICAL EVIDENCE: both give you a
|
|
18
|
+
* IDENTICAL EVIDENCE: both give you a seat on some transport with nothing in any
|
|
14
19
|
* log, so a typo in the config reads exactly like a deliberate default, and the
|
|
15
20
|
* person who typed `heardr` spends the afternoon asking why their transport
|
|
16
21
|
* change did nothing. Refusing is louder than the bug it prevents.
|
|
@@ -20,7 +25,7 @@
|
|
|
20
25
|
* removing the fallback would have meant every unconfigured session refused
|
|
21
26
|
* with "unknown transport 'tmux-push'" — a config-file bug wearing a runtime
|
|
22
27
|
* bug's clothes. Picking a new implicit default (herdr, say) would be correct
|
|
23
|
-
* on THIS
|
|
28
|
+
* on THIS machine today and wrong on the next one that doesn't run herdr —
|
|
24
29
|
* the exact silent-guess failure ⟨q-e439e4ad⟩ spent a day fixing, moved one
|
|
25
30
|
* layer up. So: zero configuration REFUSES, naming the kinds that exist.
|
|
26
31
|
*/
|
|
@@ -29,26 +34,27 @@ import path from "node:path";
|
|
|
29
34
|
import { ROOT } from "../store.js";
|
|
30
35
|
import { TRANSPORT_KINDS, type TransportKind } from "./types.js";
|
|
31
36
|
|
|
32
|
-
/** `$AGENT_COORD_DIR/config.json
|
|
37
|
+
/** `$AGENT_COORD_DIR/config.json` — MACHINE-scoped, not fleet-wide: see the file header. */
|
|
33
38
|
export const TRANSPORT_CONFIG_FILE = path.join(ROOT, "config.json");
|
|
34
39
|
export const TRANSPORT_ENV_VAR = "AGENT_COORD_TRANSPORT";
|
|
35
40
|
|
|
36
41
|
/**
|
|
37
42
|
* PRECEDENCE, documented here because 3.2 asks for a decision and not a
|
|
38
|
-
* preference: **config file > env
|
|
43
|
+
* preference: **machine config file > env**. (⟨q-ec020f6a⟩: no default follows
|
|
44
|
+
* either — see the file header.)
|
|
39
45
|
*
|
|
40
|
-
* The file wins because it is
|
|
41
|
-
* on disk where every seat reads the same bytes, and a wrong
|
|
42
|
-
* corrected in one place. An env var is per-process: it is the
|
|
43
|
-
* one seat to deviate deliberately (a test, a bisect), and the
|
|
44
|
-
*
|
|
45
|
-
* process. So the narrower, less visible
|
|
46
|
-
* `source` is reported so a surprising
|
|
47
|
-
* rather than guessed at.
|
|
46
|
+
* The file wins because it is this MACHINE's statement and is reviewable — it
|
|
47
|
+
* sits on disk where every seat ON THIS BOX reads the same bytes, and a wrong
|
|
48
|
+
* value can be corrected in one place. An env var is per-process: it is the
|
|
49
|
+
* right tool for one seat to deviate deliberately (a test, a bisect), and the
|
|
50
|
+
* wrong tool for stating what this machine's seats do by default, because
|
|
51
|
+
* nothing can see it from outside that process. So the narrower, less visible
|
|
52
|
+
* source loses to the broader one, and `source` is reported so a surprising
|
|
53
|
+
* answer can be traced to its origin rather than guessed at.
|
|
48
54
|
*/
|
|
49
55
|
export type ConfiguredTransport = {
|
|
50
56
|
kind: TransportKind;
|
|
51
|
-
source: "config" | "env";
|
|
57
|
+
source: "machine-config" | "env";
|
|
52
58
|
/** Where the value came from, for an error message a human can act on. */
|
|
53
59
|
origin: string;
|
|
54
60
|
};
|
|
@@ -73,12 +79,38 @@ function asKind(value: unknown, origin: string): TransportKind {
|
|
|
73
79
|
let cached: ConfiguredTransport | undefined;
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
|
-
*
|
|
82
|
+
* ⭐ ⟨q-9e0072b3⟩ THE PRECEDENCE DECISION, PULLED OUT SO IT CAN BE CALLED WITHOUT TOUCHING
|
|
83
|
+
* DISK OR `process.env`. `configuredTransport()` below is the only caller that supplies real
|
|
84
|
+
* values read from THIS machine; anything else — a test, or a capability probe that must prove
|
|
85
|
+
* a property of the CODE rather than a fact about one machine's config.json — can hand this
|
|
86
|
+
* CONSTRUCTED values instead. That is what makes it possible to probe "does the machine-config/
|
|
87
|
+
* env vocabulary exist and resolve correctly" without the probe itself becoming an undeclared
|
|
88
|
+
* read of a mutable, machine-local baseline (`check-baseline-declared` correctly caught the
|
|
89
|
+
* first version of that probe calling `configuredTransport()` directly for exactly this reason).
|
|
90
|
+
*/
|
|
91
|
+
export function resolveConfiguredTransport(input: { fileHasTransport: boolean; fileTransport: unknown; env: string | undefined }): ConfiguredTransport {
|
|
92
|
+
if (input.fileHasTransport) {
|
|
93
|
+
return { kind: asKind(input.fileTransport, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "machine-config", origin: TRANSPORT_CONFIG_FILE };
|
|
94
|
+
}
|
|
95
|
+
if (input.env !== undefined && input.env !== "") {
|
|
96
|
+
return { kind: asKind(input.env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
|
|
97
|
+
}
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[agent-coord-mcp] no transport configured — set $${TRANSPORT_ENV_VAR} or ${TRANSPORT_CONFIG_FILE} ` +
|
|
100
|
+
`("transport") to one of: ${TRANSPORT_KINDS.join(", ")}. ` +
|
|
101
|
+
`REFUSING rather than picking one: a default that is right for this machine today is wrong for the ` +
|
|
102
|
+
`next one that doesn't have it, and the two failures leave identical evidence.`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve THIS MACHINE's configured transport. Throws on an unknown value — call it once at
|
|
77
108
|
* startup so the refusal lands before any agent attaches.
|
|
78
109
|
*/
|
|
79
110
|
export function configuredTransport(): ConfiguredTransport {
|
|
80
111
|
if (cached) return cached;
|
|
81
112
|
|
|
113
|
+
let fileTransport: unknown;
|
|
82
114
|
if (existsSync(TRANSPORT_CONFIG_FILE)) {
|
|
83
115
|
let parsed: unknown;
|
|
84
116
|
try {
|
|
@@ -90,28 +122,14 @@ export function configuredTransport(): ConfiguredTransport {
|
|
|
90
122
|
throw new Error(
|
|
91
123
|
`[agent-coord-mcp] ${TRANSPORT_CONFIG_FILE} is unreadable (${(e as Error).message}). ` +
|
|
92
124
|
`REFUSING rather than treating it as absent: a file that exists and cannot be read is not the ` +
|
|
93
|
-
`same as no file, and defaulting here would hide a stated intent behind a
|
|
125
|
+
`same as no file, and defaulting here would hide a stated intent behind a seat that looked healthy.`,
|
|
94
126
|
);
|
|
95
127
|
}
|
|
96
|
-
|
|
97
|
-
if (raw !== undefined) {
|
|
98
|
-
cached = { kind: asKind(raw, `${TRANSPORT_CONFIG_FILE} ("transport")`), source: "config", origin: TRANSPORT_CONFIG_FILE };
|
|
99
|
-
return cached;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const env = process.env[TRANSPORT_ENV_VAR];
|
|
104
|
-
if (env !== undefined && env !== "") {
|
|
105
|
-
cached = { kind: asKind(env, `$${TRANSPORT_ENV_VAR}`), source: "env", origin: `$${TRANSPORT_ENV_VAR}` };
|
|
106
|
-
return cached;
|
|
128
|
+
fileTransport = (parsed as { transport?: unknown } | null)?.transport;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
);
|
|
131
|
+
cached = resolveConfiguredTransport({ fileHasTransport: fileTransport !== undefined, fileTransport, env: process.env[TRANSPORT_ENV_VAR] });
|
|
132
|
+
return cached;
|
|
115
133
|
}
|
|
116
134
|
|
|
117
135
|
/**
|
package/src/transports/herdr.ts
CHANGED
|
@@ -136,7 +136,13 @@ const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]);
|
|
|
136
136
|
* held — NO key was sent (no ready box, a draft, a menu, or the pane unreadable).
|
|
137
137
|
* Safe to retry.
|
|
138
138
|
* typed-unconfirmed — the text was typed but did not show in the box; Enter was NOT sent.
|
|
139
|
-
* pending — the text is still in the box after every Enter.
|
|
139
|
+
* pending — the text is still in the box after every Enter. ⟨q-1ce5bc97⟩ step 1: this
|
|
140
|
+
* is the MOST recoverable non-delivery there is — the payload is KNOWN to be
|
|
141
|
+
* sitting, verified, in the target's own box, not lost and not ambiguous.
|
|
142
|
+
* `safeToRetry: false` here turned a transient misread into a PERMANENT drop:
|
|
143
|
+
* the tail's held-path (`herdr-tail.ts`) is what retries safely (it advances
|
|
144
|
+
* nothing on a hold, and stops the source rather than typing past it), so
|
|
145
|
+
* refusing that path was refusing the one recovery this outcome has. NOW true.
|
|
140
146
|
* unverified — Enter was sent and the screen after it does not prove submission.
|
|
141
147
|
*/
|
|
142
148
|
export type PushOutcome = "delivered" | "held" | "typed-unconfirmed" | "pending" | "unverified";
|
|
@@ -164,8 +170,28 @@ export type HerdrTransportOptions = {
|
|
|
164
170
|
* variable at a time, six others left in place — was what turned it green.
|
|
165
171
|
*/
|
|
166
172
|
env?: Record<string, string | undefined>;
|
|
173
|
+
/** Override the submit mode this instance uses; defaults to `submitModeOf(this.#env)`. Tests only. */
|
|
174
|
+
submitMode?: SubmitMode;
|
|
167
175
|
};
|
|
168
176
|
|
|
177
|
+
/**
|
|
178
|
+
* ⟨q-f7e3c701⟩ — WHICH SUBMIT PATH `push` USES. `agent-prompt` is the atomic fix
|
|
179
|
+
* (`herdr agent prompt <target> <text>`, one call, no type→read-back→Enter window). `send-keys`
|
|
180
|
+
* is today's path (type, verify it landed, Enter, verify it submitted).
|
|
181
|
+
*
|
|
182
|
+
* DEFAULT IS `send-keys`, DELIBERATELY. The blast radius of switching is both fleets and the
|
|
183
|
+
* failure mode is silence — a wrong default ships broken delivery to every seat with no signal
|
|
184
|
+
* until someone notices nothing arrived. `agent-prompt` is enabled per-seat as a canary via
|
|
185
|
+
* `AGENT_COORD_SUBMIT=agent-prompt`, compared against control seats by `outcome=` rate in
|
|
186
|
+
* bus-daemon.err.log, and reverted by unsetting the variable — no republish required, which
|
|
187
|
+
* matters because 0.26.28 took two days to travel merged→observed.
|
|
188
|
+
*/
|
|
189
|
+
export type SubmitMode = "agent-prompt" | "send-keys";
|
|
190
|
+
export const SUBMIT_MODE_ENV_VAR = "AGENT_COORD_SUBMIT";
|
|
191
|
+
export function submitModeOf(env: Record<string, string | undefined> = process.env): SubmitMode {
|
|
192
|
+
return env[SUBMIT_MODE_ENV_VAR] === "agent-prompt" ? "agent-prompt" : "send-keys";
|
|
193
|
+
}
|
|
194
|
+
|
|
169
195
|
/**
|
|
170
196
|
* ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched.
|
|
171
197
|
*
|
|
@@ -219,6 +245,7 @@ export class HerdrTransport implements Transport {
|
|
|
219
245
|
#readLines: number;
|
|
220
246
|
#markerPid: () => HerdrMarkerPid;
|
|
221
247
|
#env: Record<string, string | undefined>;
|
|
248
|
+
#submitMode: SubmitMode;
|
|
222
249
|
|
|
223
250
|
constructor(opts: HerdrTransportOptions = {}) {
|
|
224
251
|
this.#run = opts.run ?? defaultHerdrRunner;
|
|
@@ -231,6 +258,7 @@ export class HerdrTransport implements Transport {
|
|
|
231
258
|
this.#readLines = opts.readLines ?? 40;
|
|
232
259
|
this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid());
|
|
233
260
|
this.#env = opts.env ?? process.env;
|
|
261
|
+
this.#submitMode = opts.submitMode ?? submitModeOf(this.#env);
|
|
234
262
|
}
|
|
235
263
|
|
|
236
264
|
/** Is herdr on this host AND is its server running? Both, or the reason. */
|
|
@@ -324,6 +352,9 @@ export class HerdrTransport implements Transport {
|
|
|
324
352
|
if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason };
|
|
325
353
|
const target = targetOf(marker);
|
|
326
354
|
if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" };
|
|
355
|
+
// ⟨q-f7e3c701⟩ ATOMIC SUBMIT, canaried per seat via AGENT_COORD_SUBMIT. Everything below this
|
|
356
|
+
// branch is the send-keys path (today's default); #pushAgentPrompt is the opt-in replacement.
|
|
357
|
+
if (this.#submitMode === "agent-prompt") return this.#pushAgentPrompt(target, text);
|
|
327
358
|
const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`;
|
|
328
359
|
const enterKey = herdrKeyName("enter");
|
|
329
360
|
if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error };
|
|
@@ -354,7 +385,74 @@ export class HerdrTransport implements Transport {
|
|
|
354
385
|
if (boxHoldsPayload(box, text)) continue;
|
|
355
386
|
return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the screen after it does not show the message submitted (${box.ready ? (box.draft ? "the box holds other text" : "the box is empty and the transcript gained no line carrying it") : box.reason})` };
|
|
356
387
|
}
|
|
357
|
-
return { delivered: false, verified: true, outcome: "pending", safeToRetry:
|
|
388
|
+
return { delivered: false, verified: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* ⟨q-f7e3c701⟩ ATOMIC SUBMIT, behind `AGENT_COORD_SUBMIT=agent-prompt` (default `send-keys` —
|
|
393
|
+
* see `submitModeOf`). `herdr agent prompt <target> <text> --wait --until working` types and
|
|
394
|
+
* submits in ONE call: no type→read-back→Enter window for a decoration to land in, which is
|
|
395
|
+
* the class the send-keys path above exists to survive. MEASURED (joint cross-fleet design,
|
|
396
|
+
* 2026-09-21): 4/4 across two hosts, including a LIVE WEDGE the send-keys path had already
|
|
397
|
+
* abandoned in the same minute.
|
|
398
|
+
*
|
|
399
|
+
* ⛔ `refusalBeforeKeys` IS NOT OPTIONAL — MEASURED ON A THROWAWAY PANE: `agent prompt` into a
|
|
400
|
+
* DIRTY composer returned `agent_prompted` (success) and CONCATENATED the stale draft with the
|
|
401
|
+
* new text, no separator, then submitted both. Atomic submit alone is strictly WORSE than
|
|
402
|
+
* today: a recoverable `held` becomes unrecoverable corruption carrying a success receipt.
|
|
403
|
+
* `agent_blocked` is a STATUS axis and is BLIND to a dirty composer (reads
|
|
404
|
+
* `status: idle · interactive_ready: true` regardless), so it cannot substitute for reading
|
|
405
|
+
* the box first — this method still reads it, exactly as the send-keys path does.
|
|
406
|
+
*
|
|
407
|
+
* Receipt is `--until working`, an OBSERVED state transition — strictly stronger than "I typed
|
|
408
|
+
* it and could read it back", the only receipt class send-keys has ever had. A stall/timeout is
|
|
409
|
+
* treated as UNCONFIRMED, never as safe to retry: unlike send-keys's `typed-unconfirmed` (where
|
|
410
|
+
* nothing had actually been typed yet), a stalled atomic submit may already have gone through,
|
|
411
|
+
* and retrying risks a genuine double-send.
|
|
412
|
+
*
|
|
413
|
+
* ⛔⛔ THE FIELD PATH BELOW WAS WRONG ONCE, SHIPPED, AND CAUGHT BY QA'S OWN LIVE SMOKE TEST
|
|
414
|
+
* BEFORE MERGE — recorded because the wrong version passed every unit test, having been
|
|
415
|
+
* written against a GUESSED shape (`json.status ?? json.result.status`) that the mocks then
|
|
416
|
+
* reproduced instead of measuring. Against the real CLI that path is always `undefined`, so
|
|
417
|
+
* every genuine success fell through to the catch-all and the feature would have reported
|
|
418
|
+
* 100% failure on real hardware. VERIFIED PAYLOADS, captured against herdr 0.9.1 by the aide
|
|
419
|
+
* and independently reproduced here on a disposable scratch pane (`herdr workspace create` /
|
|
420
|
+
* `agent start --kind claude` / closed after):
|
|
421
|
+
* success: {"result":{"agent":{...},"type":"agent_prompted"}} — result.type, not result.status
|
|
422
|
+
* blocked: {"error":{"code":"agent_blocked","message":"..."}} — TOP-LEVEL error, no `result` at all
|
|
423
|
+
* timeout: {"error":{"code":"timeout","message":"..."}} — same top-level shape as blocked
|
|
424
|
+
* So this is TWO PATHS, not one field read: `result.type` for success, `error.code` for every
|
|
425
|
+
* refusal. A single-location parse mishandles one of them regardless of which is picked — the
|
|
426
|
+
* exact shape of the bug that shipped. `agent_prompt_stalled` was NOT reproduced against a real
|
|
427
|
+
* binary despite attempting it (the 5000ms stall window did not fire in either kit's or my own
|
|
428
|
+
* attempts) — it is handled via the same `error.code` path as `agent_blocked`/`timeout` BY
|
|
429
|
+
* ANALOGY with the CLI's consistent refuse-via-top-level-error convention, not by measurement.
|
|
430
|
+
* If that analogy is wrong, the failure mode is `unverified/safeToRetry:false` (never a
|
|
431
|
+
* double-send) rather than a false "delivered", so the untested case fails safe.
|
|
432
|
+
*/
|
|
433
|
+
async #pushAgentPrompt(target: string, text: string): Promise<HerdrPushResult> {
|
|
434
|
+
if (readyProfile().profile !== "none") {
|
|
435
|
+
const before = readReadyBox(this.#screen(target));
|
|
436
|
+
const refusal = refusalBeforeKeys(before);
|
|
437
|
+
if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
|
|
438
|
+
}
|
|
439
|
+
const r = this.#run(["agent", "prompt", target, text, "--wait", "--until", "working"]);
|
|
440
|
+
const resultType = (r.json as { result?: { type?: string } } | undefined)?.result?.type;
|
|
441
|
+
if (r.ok && resultType === "agent_prompted") {
|
|
442
|
+
return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false };
|
|
443
|
+
}
|
|
444
|
+
if (r.error?.code === "agent_blocked") {
|
|
445
|
+
// MEASURED: nothing is typed into the pane on agent_blocked — the agent itself refused the
|
|
446
|
+
// prompt before any input was sent. Safe to retry, same as a send-keys `held`.
|
|
447
|
+
return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `agent_blocked at ${target}: ${r.error.message}` };
|
|
448
|
+
}
|
|
449
|
+
if (r.error?.code === "agent_prompt_stalled" || r.error?.code === "timeout") {
|
|
450
|
+
return {
|
|
451
|
+
delivered: false, verified: false, outcome: "unverified", safeToRetry: false,
|
|
452
|
+
error: `${r.error.code} at ${target}: submission unconfirmed by --wait --until working — not retried, to avoid a double-send on an atomic path`,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return { delivered: false, verified: false, outcome: "unverified", safeToRetry: false, error: `agent prompt to ${target} refused or unrecognised: ${r.error?.message ?? r.stderr ?? JSON.stringify(r.json)}` };
|
|
358
456
|
}
|
|
359
457
|
|
|
360
458
|
/** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */
|
|
@@ -380,7 +478,7 @@ export class HerdrTransport implements Transport {
|
|
|
380
478
|
if (pending === false) return { delivered: true, verified: true, unguarded: true, outcome: "delivered", safeToRetry: false, enters };
|
|
381
479
|
if (pending === null) return { delivered: false, verified: false, unguarded: true, outcome: "unverified", safeToRetry: false, enters, error: `enter sent to ${target}, but the pane could not be read back to verify it` };
|
|
382
480
|
}
|
|
383
|
-
return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry:
|
|
481
|
+
return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry: true, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
384
482
|
}
|
|
385
483
|
|
|
386
484
|
/**
|
package/src/transports/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { configuredTransport } from "./config.js";
|
|
|
14
14
|
export * from "./types.js";
|
|
15
15
|
export * from "./config.js";
|
|
16
16
|
export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
|
|
17
|
-
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid } from "./herdr.js";
|
|
17
|
+
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, submitModeOf, SUBMIT_MODE_ENV_VAR, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid, type SubmitMode } from "./herdr.js";
|
|
18
18
|
|
|
19
19
|
let host: TmuxHost | undefined;
|
|
20
20
|
|