agent-coord-mcp 0.26.22 → 0.26.24
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/dist/capabilities.js +70 -0
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +160 -5
- package/dist/gated-head.js.map +1 -1
- package/dist/tools/away.js +20 -2
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/event-kinds.js.map +1 -1
- package/dist/tools/events.js +9 -2
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/herdr-delivery.js +99 -0
- package/dist/tools/herdr-delivery.js.map +1 -0
- package/dist/tools/messaging.js +8 -0
- package/dist/tools/messaging.js.map +1 -1
- package/dist/tools/queue-write.js +4 -1
- package/dist/tools/queue-write.js.map +1 -1
- package/dist/tools/record-events.js +42 -4
- package/dist/tools/record-events.js.map +1 -1
- package/dist/tools/records.js +85 -9
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +15 -1
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/seat-build.js +10 -1
- package/dist/tools/seat-build.js.map +1 -1
- package/dist/tools/stall.js +73 -9
- package/dist/tools/stall.js.map +1 -1
- package/dist/tools/tick.js +77 -0
- package/dist/tools/tick.js.map +1 -0
- package/dist/tools/transport.js +50 -1
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +381 -0
- package/dist/transports/herdr.js.map +1 -0
- package/dist/transports/index.js +10 -4
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/types.js +41 -0
- package/dist/transports/types.js.map +1 -1
- package/package.json +1 -1
- package/src/capabilities.ts +73 -0
- package/src/gated-head.ts +181 -5
- package/src/tools/away.ts +38 -2
- package/src/tools/event-kinds.ts +2 -1
- package/src/tools/events.ts +9 -2
- package/src/tools/herdr-delivery.ts +87 -0
- package/src/tools/messaging.ts +8 -0
- package/src/tools/queue-write.ts +4 -1
- package/src/tools/record-events.ts +46 -4
- package/src/tools/records.ts +74 -8
- package/src/tools/registry.ts +14 -1
- package/src/tools/seat-build.ts +8 -1
- package/src/tools/stall.ts +80 -12
- package/src/tools/tick.ts +117 -0
- package/src/tools/transport.ts +48 -0
- package/src/transports/herdr.ts +385 -0
- package/src/transports/index.ts +10 -4
- package/src/transports/types.ts +66 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE HERDR TRANSPORT — the second implementation of the seam (Phase 5.4 Task 4).
|
|
3
|
+
*
|
|
4
|
+
* herdr is a RUST BINARY (brew / herdr.dev / GitHub releases), NOT an npm package:
|
|
5
|
+
* `npm view herdr` answers 0.0.0 "Reserved package name" (spike 1.1). Nothing here is
|
|
6
|
+
* imported; every call is `herdr <subcommand>` over the socket API on this host, through
|
|
7
|
+
* one injectable runner so the transport can be exercised without a herdr server and
|
|
8
|
+
* measured against one when it is there.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IS DIFFERENT FROM TMUX, and why each difference is a stated behaviour rather
|
|
11
|
+
* than a quiet default:
|
|
12
|
+
* · NO PUSHER PROCESS. Delivery is a socket call made by the server itself; a marker
|
|
13
|
+
* carries pid 0. Liveness is therefore never a pid heuristic — `probe` asks herdr
|
|
14
|
+
* for the pane and its OWN `agent_status` (spike 1.4: live / wedged / killed are
|
|
15
|
+
* three distinguishable answers there, which they are not on tmux).
|
|
16
|
+
* · ERRORS ARE JSON, ON EITHER STREAM. `herdr pane get w999:p1` prints
|
|
17
|
+
* {"error":{"code":"pane_not_found",…}} — measured on stdout with exit 0 in one call
|
|
18
|
+
* and on stderr in another (a pane closed moments before read as "could not describe"
|
|
19
|
+
* until the runner parsed stderr too). The runner parses whichever stream carries a
|
|
20
|
+
* JSON body; the exit code alone would call failures successes.
|
|
21
|
+
* · KEY NAMES ARE NOT PORTABLE. `ctrl+u` is accepted, `C-u` / `ctrl-u` are rejected
|
|
22
|
+
* with {"error":{"code":"invalid_key"}} (spike, and measured again here). The seam
|
|
23
|
+
* takes INTENTS; the one place a key name is spelled is `herdrKeyName`, which
|
|
24
|
+
* REFUSES a tmux-vocabulary name rather than translating it by guess.
|
|
25
|
+
* · THE ENTER RACE IS REAL. `send-text` then `send-keys enter` did not submit once in
|
|
26
|
+
* the spike; the line sat in Claude's input box until a second enter. `push` and
|
|
27
|
+
* `sendControl` VERIFY by reading the pane back and retry the enter once, and report
|
|
28
|
+
* how many enters it took — measured per delivery, never assumed away.
|
|
29
|
+
* · THE SLASH IS EATEN DOWNSTREAM OF ANY TRANSPORT (spike 1.3, ⟨q-f14692ca⟩ struck):
|
|
30
|
+
* Claude Code interprets a leading `/` however the characters arrive. This transport
|
|
31
|
+
* delivers bytes verbatim and makes no claim about what the reader does with them.
|
|
32
|
+
* · ABSENT BINARY / STOPPED SERVER → an explicit refusal that NAMES herdr. Never a
|
|
33
|
+
* silent fall-through to tmux: the config layer already refuses unknown kinds for the
|
|
34
|
+
* same reason (identical evidence for a typo and a default).
|
|
35
|
+
*/
|
|
36
|
+
import { spawnSync } from "node:child_process";
|
|
37
|
+
import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
|
|
38
|
+
import { HERDR, TICK_READS_AS, TICK_STORED_AS, targetOf } from "./types.js";
|
|
39
|
+
|
|
40
|
+
export type HerdrError = { code: string; message: string };
|
|
41
|
+
export type HerdrResult = {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
status: number | null;
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
/** Parsed JSON body when herdr printed one. */
|
|
47
|
+
json?: unknown;
|
|
48
|
+
/** herdr's own error object, or a synthetic one for a missing binary. */
|
|
49
|
+
error?: HerdrError;
|
|
50
|
+
/** The binary itself is not on PATH. */
|
|
51
|
+
absent?: boolean;
|
|
52
|
+
};
|
|
53
|
+
export type HerdrRunner = (args: string[]) => HerdrResult;
|
|
54
|
+
|
|
55
|
+
export const HERDR_BINARY = "herdr";
|
|
56
|
+
export const HERDR_ABSENT_MESSAGE =
|
|
57
|
+
`herdr binary not found on PATH — herdr is a Rust binary (brew install herdr, or https://herdr.dev), ` +
|
|
58
|
+
`NOT an npm package (npm's "herdr" is a reserved 0.0.0 name). Install it, or use the tmux-push transport.`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Interpret a herdr reply — PURE, so the one rule it holds (a JSON error body on either
|
|
62
|
+
* stream is a refusal, whatever the exit code) is testable without a herdr on the host.
|
|
63
|
+
*/
|
|
64
|
+
export function interpretHerdrReply(r: { status: number | null; stdout?: string | null; stderr?: string | null }): HerdrResult {
|
|
65
|
+
const stdout = r.stdout ?? "";
|
|
66
|
+
const stderr = r.stderr ?? "";
|
|
67
|
+
let json: unknown;
|
|
68
|
+
for (const stream of [stdout, stderr]) {
|
|
69
|
+
const trimmed = stream.trim();
|
|
70
|
+
if (!trimmed.startsWith("{")) continue;
|
|
71
|
+
try { json = JSON.parse(trimmed); break; } catch { /* not a JSON body */ }
|
|
72
|
+
}
|
|
73
|
+
const err = (json as { error?: HerdrError } | undefined)?.error;
|
|
74
|
+
if (err && typeof err.code === "string") return { ok: false, status: r.status, stdout, stderr, json, error: err };
|
|
75
|
+
if (r.status !== 0) return { ok: false, status: r.status, stdout, stderr, json, error: { code: "exit", message: (stderr || stdout).trim() || `herdr exited ${r.status}` } };
|
|
76
|
+
return { ok: true, status: r.status, stdout, stderr, json };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Run `herdr <args>` and interpret the reply. */
|
|
80
|
+
export function defaultHerdrRunner(args: string[]): HerdrResult {
|
|
81
|
+
const r = spawnSync(HERDR_BINARY, args, { encoding: "utf8" });
|
|
82
|
+
if (r.error && (r.error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
83
|
+
return { ok: false, status: null, stdout: "", stderr: "", absent: true, error: { code: "binary_absent", message: HERDR_ABSENT_MESSAGE } };
|
|
84
|
+
}
|
|
85
|
+
return interpretHerdrReply({ status: r.status, stdout: r.stdout, stderr: r.stderr });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* THE KEY VOCABULARY, in one place. Intents on the left, herdr's names on the right.
|
|
90
|
+
* A tmux-vocabulary name (`C-u`, `ctrl-u`, `M-x`) is REFUSED, never translated by guess:
|
|
91
|
+
* herdr would reject it as invalid_key, and a transport that "helpfully" rewrote it could
|
|
92
|
+
* just as easily rewrite it wrong and type garbage into a pane.
|
|
93
|
+
*/
|
|
94
|
+
export const HERDR_KEYS: Readonly<Record<string, string>> = Object.freeze({
|
|
95
|
+
enter: "enter",
|
|
96
|
+
escape: "esc",
|
|
97
|
+
"clear-line": "ctrl+u",
|
|
98
|
+
});
|
|
99
|
+
export function herdrKeyName(intentOrName: string): { ok: true; key: string } | { ok: false; error: string } {
|
|
100
|
+
const s = String(intentOrName ?? "").trim();
|
|
101
|
+
if (/^(?:C|M|S)-/i.test(s) || /^(?:ctrl|alt|meta|shift)-/i.test(s)) {
|
|
102
|
+
return { ok: false, error: `key '${s}' is tmux vocabulary and herdr rejects it as invalid_key — the herdr form is '${s.replace(/^(?:C|ctrl)-/i, "ctrl+").replace(/^(?:M|alt|meta)-/i, "alt+")}'; refused rather than guessed` };
|
|
103
|
+
}
|
|
104
|
+
if (HERDR_KEYS[s]) return { ok: true, key: HERDR_KEYS[s] };
|
|
105
|
+
if (/^[a-z0-9]+(?:\+[a-z0-9]+)*$/.test(s)) return { ok: true, key: s };
|
|
106
|
+
return { ok: false, error: `key '${s}' is not a herdr key name (letters, digits and '+', e.g. ctrl+u) and not a known intent (${Object.keys(HERDR_KEYS).join(", ")})` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type PaneInfo = { pane_id?: string; agent_status?: string; agent?: string; workspace_id?: string };
|
|
110
|
+
type ProcessInfo = { foreground_processes?: { argv?: string[]; pid?: number; name?: string }[]; shell_pid?: number };
|
|
111
|
+
|
|
112
|
+
function paneOf(r: HerdrResult): PaneInfo | undefined {
|
|
113
|
+
const j = r.json as { result?: { pane?: PaneInfo; root_pane?: PaneInfo } } | undefined;
|
|
114
|
+
return j?.result?.pane ?? j?.result?.root_pane;
|
|
115
|
+
}
|
|
116
|
+
function processInfoOf(r: HerdrResult): ProcessInfo | undefined {
|
|
117
|
+
return (r.json as { result?: { process_info?: ProcessInfo } } | undefined)?.result?.process_info;
|
|
118
|
+
}
|
|
119
|
+
const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]);
|
|
120
|
+
|
|
121
|
+
export type HerdrTransportOptions = {
|
|
122
|
+
run?: HerdrRunner;
|
|
123
|
+
/** Milliseconds to wait before reading a pane back after typing. */
|
|
124
|
+
settleMs?: number;
|
|
125
|
+
/** Sleep, injectable so tests do not wait. */
|
|
126
|
+
sleep?: (ms: number) => void;
|
|
127
|
+
/** Lines of pane to read back when verifying a delivery. */
|
|
128
|
+
readLines?: number;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export class HerdrTransport implements Transport {
|
|
132
|
+
readonly kind: TransportKind = HERDR;
|
|
133
|
+
#run: HerdrRunner;
|
|
134
|
+
#settleMs: number;
|
|
135
|
+
#sleep: (ms: number) => void;
|
|
136
|
+
#readLines: number;
|
|
137
|
+
|
|
138
|
+
constructor(opts: HerdrTransportOptions = {}) {
|
|
139
|
+
this.#run = opts.run ?? defaultHerdrRunner;
|
|
140
|
+
// MEASURED on a shell pane in a task-owned workspace: after a single enter the output had
|
|
141
|
+
// rendered by 400 ms and not by 150 ms — below that, render lag reads as an unsubmitted
|
|
142
|
+
// line and the retry fires an empty enter (harmless, but counted). The genuine lost enter
|
|
143
|
+
// the spike measured is Claude's input box; the retry exists for that, bounded to one.
|
|
144
|
+
this.#settleMs = opts.settleMs ?? 400;
|
|
145
|
+
this.#sleep = opts.sleep ?? ((ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin: tiny and rare */ } });
|
|
146
|
+
this.#readLines = opts.readLines ?? 12;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Is herdr on this host AND is its server running? Both, or the reason. */
|
|
150
|
+
availability(): { available: boolean; reason: string } {
|
|
151
|
+
const r = this.#run(["status"]);
|
|
152
|
+
if (r.absent) return { available: false, reason: HERDR_ABSENT_MESSAGE };
|
|
153
|
+
if (!r.ok) return { available: false, reason: `herdr status failed: ${r.error?.message ?? r.stderr}` };
|
|
154
|
+
const running = /server:[\s\S]*status:\s*running/.test(r.stdout);
|
|
155
|
+
return running
|
|
156
|
+
? { available: true, reason: `herdr server running (${(r.stdout.match(/version:\s*(\S+)/) ?? [])[1] ?? "version unread"})` }
|
|
157
|
+
: { available: false, reason: `herdr binary present but its server is not running (herdr status: ${r.stdout.trim().split("\n").slice(-2).join(" ")}) — start herdr, or use the tmux-push transport` };
|
|
158
|
+
}
|
|
159
|
+
available(): boolean {
|
|
160
|
+
return this.availability().available;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Attach = verify the pane and return a marker. No process is spawned: the marker's pid
|
|
165
|
+
* is 0 and its `target` is the herdr pane id. Persisting the marker is the tool's job,
|
|
166
|
+
* exactly as for tmux.
|
|
167
|
+
*/
|
|
168
|
+
async attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number }): Promise<TransportMarker> {
|
|
169
|
+
const avail = this.availability();
|
|
170
|
+
if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`);
|
|
171
|
+
let target = args.target ?? process.env.HERDR_PANE_ID;
|
|
172
|
+
if (!target) {
|
|
173
|
+
const cur = this.#run(["pane", "current"]);
|
|
174
|
+
target = paneOf(cur)?.pane_id;
|
|
175
|
+
}
|
|
176
|
+
if (!target) {
|
|
177
|
+
throw new Error("herdr target not provided and this process is not inside a herdr pane (no HERDR_PANE_ID, `herdr pane current` answered nothing). Pass target explicitly (e.g. 'w2:p1').");
|
|
178
|
+
}
|
|
179
|
+
const got = this.#run(["pane", "get", target]);
|
|
180
|
+
if (!got.ok) throw new Error(`herdr pane '${target}' not found: ${got.error?.message ?? got.stderr}`);
|
|
181
|
+
return {
|
|
182
|
+
agentId: args.agentId,
|
|
183
|
+
transport: HERDR,
|
|
184
|
+
pid: 0,
|
|
185
|
+
target,
|
|
186
|
+
tmuxTarget: target,
|
|
187
|
+
since: Date.now(),
|
|
188
|
+
rooms: args.includeRoom !== false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Does the pane exist, per herdr, through THIS transport's runner? null = could not ask. */
|
|
193
|
+
paneExists(target: string): boolean | null {
|
|
194
|
+
return herdrPaneExists(target, this.#run);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Nothing to kill: there is no pusher. The tool deletes the marker. */
|
|
198
|
+
async detach(_agentId: string): Promise<void> {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Type `text` into the pane and press enter; VERIFY by reading the pane back, and if
|
|
204
|
+
* the line is still sitting unsubmitted (the spike's race), press enter once more.
|
|
205
|
+
* Reports the enters it took so the race is measured on every delivery.
|
|
206
|
+
*/
|
|
207
|
+
async push(marker: TransportMarker, text: string): Promise<{ delivered: boolean; error?: string; enters?: number; verified?: boolean }> {
|
|
208
|
+
const avail = this.availability();
|
|
209
|
+
if (!avail.available) return { delivered: false, error: avail.reason };
|
|
210
|
+
const target = targetOf(marker);
|
|
211
|
+
if (!target) return { delivered: false, error: "no target recorded on the marker" };
|
|
212
|
+
const typed = this.#run(["pane", "send-text", target, text]);
|
|
213
|
+
if (!typed.ok) return { delivered: false, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
|
|
214
|
+
const enterKey = herdrKeyName("enter");
|
|
215
|
+
if (!enterKey.ok) return { delivered: false, error: enterKey.error };
|
|
216
|
+
let enters = 0;
|
|
217
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
218
|
+
const pressed = this.#run(["pane", "send-keys", target, enterKey.key]);
|
|
219
|
+
if (!pressed.ok) return { delivered: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
|
|
220
|
+
enters++;
|
|
221
|
+
this.#sleep(this.#settleMs);
|
|
222
|
+
const pending = this.#stillPending(target, text);
|
|
223
|
+
if (pending === false) return { delivered: true, enters, verified: true };
|
|
224
|
+
if (pending === null) return { delivered: true, enters, verified: false };
|
|
225
|
+
}
|
|
226
|
+
return { delivered: false, enters, verified: true, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read the pane back: is the typed text still the LAST non-empty line (unsubmitted)?
|
|
231
|
+
* true = pending · false = submitted · null = could not read (unverified, not failed).
|
|
232
|
+
*/
|
|
233
|
+
#stillPending(target: string, text: string): boolean | null {
|
|
234
|
+
const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "text"]);
|
|
235
|
+
if (!read.ok) return null;
|
|
236
|
+
const lines = read.stdout.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0);
|
|
237
|
+
const last = lines.at(-1) ?? "";
|
|
238
|
+
const firstLine = text.split("\n")[0];
|
|
239
|
+
return last.endsWith(firstLine) && !/^[⏺✔✖]/.test(last);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* THREE ANSWERS FROM HERDR'S OWN STATUS, never a pid heuristic:
|
|
244
|
+
* · not a herdr marker / no target / herdr unavailable → unknown, naming which
|
|
245
|
+
* · pane_not_found → dead
|
|
246
|
+
* · agent_status idle | working | blocked | done → live (blocked IS live: it waits on a person)
|
|
247
|
+
* · agent_status unknown, a foreground process present → unknown, naming the process (the wedged shape)
|
|
248
|
+
* · agent_status unknown, nothing in the foreground → unknown (a shell pane nobody is in)
|
|
249
|
+
*/
|
|
250
|
+
async probe(marker: TransportMarker): Promise<Liveness> {
|
|
251
|
+
if (marker.transport !== HERDR) return { state: "unknown", reason: `transport "${marker.transport}" is not herdr` };
|
|
252
|
+
const target = targetOf(marker);
|
|
253
|
+
if (!target) return { state: "unknown", reason: "no target recorded on the marker" };
|
|
254
|
+
const avail = this.availability();
|
|
255
|
+
if (!avail.available) return { state: "unknown", reason: avail.reason };
|
|
256
|
+
const got = this.#run(["pane", "get", target]);
|
|
257
|
+
if (!got.ok) {
|
|
258
|
+
if (got.error?.code === "pane_not_found") return { state: "dead", reason: `herdr reports pane ${target} not found (${got.error.message})` };
|
|
259
|
+
return { state: "unknown", reason: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` };
|
|
260
|
+
}
|
|
261
|
+
const status = String(paneOf(got)?.agent_status ?? "unknown");
|
|
262
|
+
if (LIVE_STATUSES.has(status)) return { state: "live" };
|
|
263
|
+
const proc = this.#run(["pane", "process-info", "--pane", target]);
|
|
264
|
+
const fg = processInfoOf(proc)?.foreground_processes ?? [];
|
|
265
|
+
if (fg.length) {
|
|
266
|
+
const p = fg[0];
|
|
267
|
+
return { state: "unknown", reason: `herdr reports agent_status "${status}" for pane ${target}; foreground process ${JSON.stringify(p.argv ?? [p.name])} (pid ${p.pid}) — present but not a recognised agent` };
|
|
268
|
+
}
|
|
269
|
+
return { state: "unknown", reason: `herdr reports agent_status "${status}" for pane ${target} and no foreground process` };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* A control command is typed as `/<cmd>` and verified to have LEFT the input, with the
|
|
274
|
+
* same enter-race handling as push. All three commands ride the path the spike measured
|
|
275
|
+
* for /clear; compact and reload-skills are the same delivery path (spike: inferred,
|
|
276
|
+
* not measured) and are said so in the result.
|
|
277
|
+
*/
|
|
278
|
+
async sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string; enters?: number; note?: string }> {
|
|
279
|
+
const r = await this.push(marker, `/${cmd}`);
|
|
280
|
+
if (!r.delivered) return { ok: false, error: r.error ?? "control not delivered", enters: r.enters };
|
|
281
|
+
return {
|
|
282
|
+
ok: true,
|
|
283
|
+
enters: r.enters,
|
|
284
|
+
note: cmd === "clear" ? "measured end to end in the spike" : `same delivery path as /clear; ${cmd} itself was inferred, not measured, by the spike`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* ⟨q-1c95f7d4⟩ 5.1 — THE EXTERNAL TICK, CONSUMED. herdr's own `agent_status` for the
|
|
290
|
+
* pane, which is the signal this fleet has never had: our liveness is pid-existence and
|
|
291
|
+
* our activity is VCS commits, so a THINKING lane and a WEDGED lane are identical to us.
|
|
292
|
+
*
|
|
293
|
+
* ⛔ `unknown` AND `done` ARE NOT SEAT STATES AND MUST NOT READ AS CALM. `unknown` is
|
|
294
|
+
* herdr saying it has no agent there (the shape a plain shell pane returns, measured);
|
|
295
|
+
* `done` is a finished session, not a moving seat. Both come back UNREADABLE, so the
|
|
296
|
+
* caller counts them as unmeasured rather than crediting coverage — the exact inversion
|
|
297
|
+
* (blind read as quiet) that `stall_check` shipped and this task exists to end.
|
|
298
|
+
*/
|
|
299
|
+
async readTick(marker: TransportMarker): Promise<TickReading> {
|
|
300
|
+
if (marker.transport !== HERDR) return { readable: false, why: `transport "${marker.transport}" has no external tick — only a herdr marker does` };
|
|
301
|
+
const target = targetOf(marker);
|
|
302
|
+
if (!target) return { readable: false, why: "no target recorded on the marker" };
|
|
303
|
+
const avail = this.availability();
|
|
304
|
+
if (!avail.available) return { readable: false, why: avail.reason };
|
|
305
|
+
const got = this.#run(["pane", "get", target]);
|
|
306
|
+
if (!got.ok) {
|
|
307
|
+
if (got.error?.code === "pane_not_found") return { readable: false, why: `herdr reports pane ${target} not found — a gone pane has no tick (and probe calls it dead)` };
|
|
308
|
+
return { readable: false, why: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` };
|
|
309
|
+
}
|
|
310
|
+
const status = String(paneOf(got)?.agent_status ?? "unknown");
|
|
311
|
+
const state = TICK_READS_AS[status];
|
|
312
|
+
if (state) return { readable: true, state, source: `herdr pane ${target}` };
|
|
313
|
+
return { readable: false, why: `herdr reports agent_status "${status}" for pane ${target} — no agent state is published there, which is a MISSING signal, not a calm seat` };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get.
|
|
318
|
+
*
|
|
319
|
+
* MEASURED 2026-09-15 in a workspace of my own: `herdr pane report-agent <pane> --source
|
|
320
|
+
* coord-mcp --agent <label> --state blocked` prints NOTHING and exits 0, and the pane
|
|
321
|
+
* then reads `"agent":"<label>","agent_status":"blocked"` where a second earlier it read
|
|
322
|
+
* `unknown` with no agent field. ⛔ A WRITE WITH NO OUTPUT IS A WRITE WITH NO RECEIPT:
|
|
323
|
+
* exit 0 here says the CLI parsed the arguments, not that the pane carries the state, and
|
|
324
|
+
* a second `report-agent` overwrites the first with the same silence. So this method does
|
|
325
|
+
* not trust the exit code — it READS THE PANE BACK and reports `ok` only when the pane
|
|
326
|
+
* carries both the agent label we published and the state we published. An unverifiable
|
|
327
|
+
* write is returned as an error naming what the pane actually says.
|
|
328
|
+
*
|
|
329
|
+
* ⚠ WHAT A WRONG PUBLISH COSTS, stated because the write is silent and destructive:
|
|
330
|
+
* herdr keeps ONE agent state per pane, so publishing to a pane this bus does not own
|
|
331
|
+
* overwrites whatever that pane's real agent last reported, with no history and no
|
|
332
|
+
* notification to the owner — a fleet watching herdr would then read our fiction as the
|
|
333
|
+
* seat's own signal. Hence the marker gate above (`transport !== HERDR` refuses): we
|
|
334
|
+
* publish only to panes recorded as herdr seats of this bus, never to a pane id passed in
|
|
335
|
+
* from anywhere else.
|
|
336
|
+
*/
|
|
337
|
+
async publishTick(marker: TransportMarker, state: TickState, opts: { message?: string; source?: string } = {}): Promise<{ ok: boolean; error?: string; verified?: boolean }> {
|
|
338
|
+
if (marker.transport !== HERDR) return { ok: false, error: `refusing to publish state for a "${marker.transport}" seat — herdr keeps one agent state per pane and a publish to a pane this bus does not own would silently overwrite its owner's signal` };
|
|
339
|
+
const target = targetOf(marker);
|
|
340
|
+
if (!target) return { ok: false, error: "no target recorded on the marker" };
|
|
341
|
+
const avail = this.availability();
|
|
342
|
+
if (!avail.available) return { ok: false, error: avail.reason };
|
|
343
|
+
const source = opts.source ?? "coord-mcp";
|
|
344
|
+
const args = ["pane", "report-agent", target, "--source", source, "--agent", marker.agentId, "--state", state];
|
|
345
|
+
if (opts.message) args.push("--message", opts.message);
|
|
346
|
+
const wrote = this.#run(args);
|
|
347
|
+
if (!wrote.ok) return { ok: false, error: `herdr refused the report: ${wrote.error?.message ?? wrote.stderr}` };
|
|
348
|
+
// THE RECEIPT IS THE READ-BACK, never the exit code.
|
|
349
|
+
const back = this.#run(["pane", "get", target]);
|
|
350
|
+
if (!back.ok) return { ok: false, verified: false, error: `published, but the pane could not be read back to verify it: ${back.error?.message ?? back.stderr}` };
|
|
351
|
+
const pane = paneOf(back);
|
|
352
|
+
const gotAgent = String(pane?.agent ?? "");
|
|
353
|
+
const gotState = String(pane?.agent_status ?? "");
|
|
354
|
+
// VERIFIED AGAINST WHAT herdr STORES, not against what we sent: a published `idle`
|
|
355
|
+
// comes back as `done` (TICK_STORED_AS, measured). Comparing to the sent word would
|
|
356
|
+
// call every idle publish a failure; comparing to the stored word is the receipt.
|
|
357
|
+
const expected = TICK_STORED_AS[state];
|
|
358
|
+
if (gotAgent === marker.agentId && gotState === expected) return { ok: true, verified: true };
|
|
359
|
+
return { ok: false, verified: false, error: `published --agent ${marker.agentId} --state ${state} (herdr stores that as "${expected}"), but pane ${target} reads agent "${gotAgent || "(none)"}" state "${gotState || "(none)"}" — the write did not land as published` };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* No pusher to kill. A herdr marker whose pane is gone is reaped (the caller deletes
|
|
364
|
+
* the marker); an unknown answer is unprobeable; a live pane is left alone.
|
|
365
|
+
*/
|
|
366
|
+
async reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }> {
|
|
367
|
+
const reaped: string[] = [];
|
|
368
|
+
const unprobeable: string[] = [];
|
|
369
|
+
for (const marker of markers) {
|
|
370
|
+
const live = await this.probe(marker);
|
|
371
|
+
if (live.state === "dead") reaped.push(marker.agentId);
|
|
372
|
+
else if (live.state === "unknown") unprobeable.push(marker.agentId);
|
|
373
|
+
}
|
|
374
|
+
return { reaped, unprobeable };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Synchronous pane-existence read for the registry's liveness path (no pid to check). */
|
|
379
|
+
export function herdrPaneExists(target: string, run: HerdrRunner = defaultHerdrRunner): boolean | null {
|
|
380
|
+
const r = run(["pane", "get", target]);
|
|
381
|
+
if (r.absent) return null;
|
|
382
|
+
if (r.ok) return true;
|
|
383
|
+
if (r.error?.code === "pane_not_found") return false;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
package/src/transports/index.ts
CHANGED
|
@@ -6,13 +6,15 @@
|
|
|
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, TMUX_PUSH, TMUX_PUSH_REMOTE, type Transport, type TransportKind } from "./types.js";
|
|
9
|
+
import { HERDR, TMUX_PUSH, TMUX_PUSH_REMOTE, TRANSPORT_KINDS, type Transport, type TransportKind } from "./types.js";
|
|
10
10
|
import { TmuxTransport, type TmuxHost } from "./tmux.js";
|
|
11
|
+
import { HerdrTransport } from "./herdr.js";
|
|
11
12
|
import { configuredTransport } from "./config.js";
|
|
12
13
|
|
|
13
14
|
export * from "./types.js";
|
|
14
15
|
export * from "./config.js";
|
|
15
16
|
export { TmuxTransport, tmuxAvailable, paneExists, probePane, tmuxVersion, type TmuxHost } from "./tmux.js";
|
|
17
|
+
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult } from "./herdr.js";
|
|
16
18
|
|
|
17
19
|
let host: TmuxHost | undefined;
|
|
18
20
|
|
|
@@ -33,9 +35,13 @@ export function resolveTransport(kind: TransportKind): Transport {
|
|
|
33
35
|
case TMUX_PUSH_REMOTE:
|
|
34
36
|
return new TmuxTransport(host, kind);
|
|
35
37
|
case HERDR:
|
|
36
|
-
// Task 4
|
|
37
|
-
//
|
|
38
|
-
|
|
38
|
+
// Task 4: the socket transport. No host object — there is no pusher process to
|
|
39
|
+
// inject; every call is `herdr <subcommand>` through the transport's own runner.
|
|
40
|
+
return new HerdrTransport();
|
|
41
|
+
default:
|
|
42
|
+
// Unreachable for a TransportKind; reachable from JS with a string. A designed
|
|
43
|
+
// refusal, never a default that behaves like tmux.
|
|
44
|
+
throw new Error(`unknown transport kind ${JSON.stringify(kind)} — valid: ${TRANSPORT_KINDS.join(", ")}`);
|
|
39
45
|
}
|
|
40
46
|
}
|
|
41
47
|
|
package/src/transports/types.ts
CHANGED
|
@@ -154,6 +154,53 @@ export type Liveness =
|
|
|
154
154
|
| { state: "dead"; reason: string }
|
|
155
155
|
| { state: "unknown"; reason: string };
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — THE EXTERNAL TICK'S VOCABULARY, TAKEN FROM THE TOOL THAT
|
|
159
|
+
* OWNS IT. Measured 2026-09-15 in a workspace of my own: `herdr pane report-agent --help`
|
|
160
|
+
* enumerates exactly `idle, working, blocked, unknown`, and a pane carries the state an
|
|
161
|
+
* external source published (`"agent":"<label>","agent_status":"blocked"` on a pane that
|
|
162
|
+
* read `unknown` a second earlier). So these four are herdr's own set, not ours.
|
|
163
|
+
*
|
|
164
|
+
* ⛔ `unknown` IS THE ABSENCE OF A SIGNAL, NOT A STATE OF THE SEAT. A tick that reads
|
|
165
|
+
* `unknown` is UNREADABLE — reported as such and counted as unmeasured — because the whole
|
|
166
|
+
* defect this task answers is a fleet that reads CALM when it is BLIND (Task 5.3).
|
|
167
|
+
*/
|
|
168
|
+
export const TICK_STATES = ["idle", "working", "blocked"] as const;
|
|
169
|
+
export type TickState = (typeof TICK_STATES)[number];
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* ⛔⛆ THE WRITE VOCABULARY AND THE READ VOCABULARY ARE NOT THE SAME, AND ONLY A READ-BACK
|
|
173
|
+
* FINDS IT. Measured 2026-09-16 on a fresh pane of my own, six writes and three repeats:
|
|
174
|
+
*
|
|
175
|
+
* published --state working → agent_status "working"
|
|
176
|
+
* published --state blocked → agent_status "blocked"
|
|
177
|
+
* published --state idle → agent_status "done" ⬅ 3 of 3, with and without --seq
|
|
178
|
+
* published --state unknown → agent_status "unknown"
|
|
179
|
+
*
|
|
180
|
+
* herdr ACCEPTS `idle` (its own `--help` lists it) and STORES `done`. Nothing in the CLI
|
|
181
|
+
* says so: the write prints nothing and exits 0 either way. This is exactly what the
|
|
182
|
+
* receipt exists to catch, and it was caught by the read-back failing, not by reasoning.
|
|
183
|
+
*
|
|
184
|
+
* For the tick's purpose the two mean the same thing — a seat that is not working — so a
|
|
185
|
+
* `done` READS as `idle`, and a publish of `idle` is verified against `done`. The
|
|
186
|
+
* translation is named here rather than hidden in a comparison, so a herdr release that
|
|
187
|
+
* changes it fails a test instead of quietly inverting a verdict.
|
|
188
|
+
*
|
|
189
|
+
* ⚠ THE TWO DIRECTIONS ARE NOT THE SAME SET, AND THE `done` ENTRY IS NOT DEAD CODE — qa
|
|
190
|
+
* measured the asymmetry from the other side on herdr 0.9.0: `report-agent --state done`
|
|
191
|
+
* is REFUSED ("expected idle, working, blocked, or unknown"), so nothing we publish can
|
|
192
|
+
* produce it. It is still REACHABLE ON READ, which is the side `TICK_READS_AS` serves:
|
|
193
|
+
* `agent_status` is what a pane REPORTS, and herdr's own `agent wait --until` enumerates
|
|
194
|
+
* `idle, working, blocked, done, unknown`. A herdr-managed Claude session that finishes
|
|
195
|
+
* hands us `done` without anyone publishing it. So: refused on write, reachable on read,
|
|
196
|
+
* measured in both directions — do not delete this entry as unreachable.
|
|
197
|
+
*/
|
|
198
|
+
export const TICK_STORED_AS: Readonly<Record<TickState, string>> = Object.freeze({ idle: "done", working: "working", blocked: "blocked" });
|
|
199
|
+
export const TICK_READS_AS: Readonly<Record<string, TickState>> = Object.freeze({ idle: "idle", done: "idle", working: "working", blocked: "blocked" });
|
|
200
|
+
export type TickReading =
|
|
201
|
+
| { readable: true; state: TickState; source: string }
|
|
202
|
+
| { readable: false; why: string };
|
|
203
|
+
|
|
157
204
|
/** Keystroke-shaped commands a transport must deliver to an interactive pane. */
|
|
158
205
|
export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
|
|
159
206
|
export type ControlCommand = (typeof CONTROL_COMMANDS)[number];
|
|
@@ -198,4 +245,23 @@ export interface Transport {
|
|
|
198
245
|
sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>;
|
|
199
246
|
|
|
200
247
|
reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }>;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* ⟨q-1c95f7d4⟩ 5.1 — OPTIONAL, AND ITS ABSENCE IS THE HONEST ANSWER FOR tmux.
|
|
251
|
+
*
|
|
252
|
+
* A transport implements `readTick` only when something OUTSIDE this process observes
|
|
253
|
+
* the seat and can be asked. tmux cannot: a pane is a terminal, and "a pid exists" is
|
|
254
|
+
* what `probe` already says. Declaring the method optional is what keeps `stall_check`
|
|
255
|
+
* from inventing a tick for a tmux seat and reading a fabricated calm — those seats keep
|
|
256
|
+
* the unmeasurable story they had (5.3).
|
|
257
|
+
*/
|
|
258
|
+
readTick?(marker: TransportMarker): Promise<TickReading>;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* ⟨q-1c95f7d4⟩ Task 1's open question, answered by measurement: THE PIPE RUNS BOTH WAYS.
|
|
262
|
+
* `coord-mcp` knows what herdr cannot infer — which seat holds which lane, whether a
|
|
263
|
+
* DONE: awaits a verdict, whether a seat is parked on a David decision — so a transport
|
|
264
|
+
* that can PUBLISH state to its external observer exposes it here.
|
|
265
|
+
*/
|
|
266
|
+
publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
|
|
201
267
|
}
|