agent-coord-mcp 0.26.23 → 0.26.25
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 +180 -5
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +172 -6
- package/dist/gated-head.js.map +1 -1
- package/dist/server-spread.js +60 -53
- package/dist/server-spread.js.map +1 -1
- package/dist/server.js +41 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/away.js +20 -2
- package/dist/tools/away.js.map +1 -1
- package/dist/tools/herdr-delivery.js +86 -26
- package/dist/tools/herdr-delivery.js.map +1 -1
- package/dist/tools/herdr-tail.js +221 -0
- package/dist/tools/herdr-tail.js.map +1 -0
- package/dist/tools/jsonl-offsets.js +37 -0
- package/dist/tools/jsonl-offsets.js.map +1 -0
- package/dist/tools/messaging.js +18 -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 +24 -5
- package/dist/tools/records.js.map +1 -1
- package/dist/tools/registry.js +56 -21
- package/dist/tools/registry.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 +4 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +203 -19
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +1 -1
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/types.js +41 -0
- package/dist/transports/types.js.map +1 -1
- package/hooks/control-bytes.mjs +69 -0
- package/hooks/submit.mjs +227 -9
- package/hooks/tier.mjs +7 -1
- package/hooks/tmux-pusher.mjs +5 -1
- package/package.json +1 -1
- package/scripts/coord-pusher.mjs +5 -1
- package/src/capabilities.ts +183 -4
- package/src/gated-head.ts +193 -6
- package/src/server-spread.ts +52 -4
- package/src/server.ts +38 -1
- package/src/tools/away.ts +38 -2
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +244 -0
- package/src/tools/jsonl-offsets.ts +29 -0
- package/src/tools/messaging.ts +20 -0
- package/src/tools/queue-write.ts +4 -1
- package/src/tools/record-events.ts +46 -4
- package/src/tools/records.ts +24 -4
- package/src/tools/registry.ts +56 -20
- package/src/tools/stall.ts +80 -12
- package/src/tools/tick.ts +117 -0
- package/src/tools/transport.ts +5 -4
- package/src/transports/herdr.ts +251 -19
- package/src/transports/index.ts +1 -1
- package/src/transports/types.ts +71 -0
package/src/transports/herdr.ts
CHANGED
|
@@ -34,8 +34,18 @@
|
|
|
34
34
|
* same reason (identical evidence for a typo and a default).
|
|
35
35
|
*/
|
|
36
36
|
import { spawnSync } from "node:child_process";
|
|
37
|
-
import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker } from "./types.js";
|
|
38
|
-
import { HERDR, targetOf } from "./types.js";
|
|
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
|
+
// ⟨q-dc83023a⟩ The chip grammar and the pane-unsafe byte class are single-sourced in hooks/, shared
|
|
40
|
+
// with both pushers. `hooks/` ships beside `dist/`, so these resolve the same when installed.
|
|
41
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
42
|
+
import { PASTE_CHIP_RE, readReadyBox, readyProfile, refusalBeforeKeys, boxHoldsPayload, transcriptGained } from "../../hooks/submit.mjs";
|
|
43
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
44
|
+
import { neutralizeControls } from "../../hooks/control-bytes.mjs";
|
|
45
|
+
|
|
46
|
+
/** Bracketed-paste markers (xterm mode 2004), which Claude Code and tmux `paste-buffer -p` both speak. */
|
|
47
|
+
export const PASTE_START = "\x1b[200~";
|
|
48
|
+
export const PASTE_END = "\x1b[201~";
|
|
39
49
|
|
|
40
50
|
export type HerdrError = { code: string; message: string };
|
|
41
51
|
export type HerdrResult = {
|
|
@@ -118,6 +128,20 @@ function processInfoOf(r: HerdrResult): ProcessInfo | undefined {
|
|
|
118
128
|
}
|
|
119
129
|
const LIVE_STATUSES = new Set(["idle", "working", "blocked", "done"]);
|
|
120
130
|
|
|
131
|
+
/**
|
|
132
|
+
* ⟨q-15d763dc⟩ What a push did, so a caller can tell a message that is safe to try again from one
|
|
133
|
+
* that may already be sitting in the pane:
|
|
134
|
+
* delivered — verified: the ready box took it and the transcript shows it. The ONLY
|
|
135
|
+
* outcome a cursor may advance on.
|
|
136
|
+
* held — NO key was sent (no ready box, a draft, a menu, or the pane unreadable).
|
|
137
|
+
* Safe to retry.
|
|
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.
|
|
140
|
+
* unverified — Enter was sent and the screen after it does not prove submission.
|
|
141
|
+
*/
|
|
142
|
+
export type PushOutcome = "delivered" | "held" | "typed-unconfirmed" | "pending" | "unverified";
|
|
143
|
+
export type HerdrPushResult = { delivered: boolean; verified?: boolean; outcome?: PushOutcome; safeToRetry?: boolean; error?: string; enters?: number; unguarded?: boolean; busy?: boolean };
|
|
144
|
+
|
|
121
145
|
export type HerdrTransportOptions = {
|
|
122
146
|
run?: HerdrRunner;
|
|
123
147
|
/** Milliseconds to wait before reading a pane back after typing. */
|
|
@@ -126,14 +150,75 @@ export type HerdrTransportOptions = {
|
|
|
126
150
|
sleep?: (ms: number) => void;
|
|
127
151
|
/** Lines of pane to read back when verifying a delivery. */
|
|
128
152
|
readLines?: number;
|
|
153
|
+
/** Injectable marker-pid decision (tests); defaults to asking the kernel about pid 1. */
|
|
154
|
+
markerPid?: () => HerdrMarkerPid;
|
|
155
|
+
/**
|
|
156
|
+
* The environment `attach` reads its default pane from. Injectable because it is AMBIENT
|
|
157
|
+
* PROCESS STATE, and ambient state is the one input an injected runner does not cover.
|
|
158
|
+
*
|
|
159
|
+
* ⛔ MEASURED 2026-09-16, the day seats moved onto herdr: the attach test injected a scripted
|
|
160
|
+
* runner and still read the REAL `process.env.HERDR_PANE_ID`. Outside herdr the variable was
|
|
161
|
+
* absent and the test passed; inside a herdr pane it resolved to the gater's own pane
|
|
162
|
+
* (`wA6:p1`), the scripted runner had never heard of it, and the suite went red on every
|
|
163
|
+
* herdr-hosted tree for a reason unrelated to any diff. Stripping `HERDR_PANE_ID` alone — one
|
|
164
|
+
* variable at a time, six others left in place — was what turned it green.
|
|
165
|
+
*/
|
|
166
|
+
env?: Record<string, string | undefined>;
|
|
129
167
|
};
|
|
130
168
|
|
|
169
|
+
/**
|
|
170
|
+
* ⟨q-abd88dd4⟩ — THE PID A HERDR MARKER CARRIES, chosen for the readers that CANNOT be patched.
|
|
171
|
+
*
|
|
172
|
+
* A herdr seat has no pusher, so its marker used to carry pid 0. Every server build from before
|
|
173
|
+
* the herdr transport (0.19.1, kit 0.26.19, published 0.26.22 — reproduced in a throwaway bus
|
|
174
|
+
* against each) decides marker liveness as `isPidAlive(marker.pid)` for everything but
|
|
175
|
+
* `tmux-push-remote`, and `isPidAlive(0)` is false, so ONE ordinary read (`list_agents`,
|
|
176
|
+
* `status`, `stall_check`, `send_command`) DELETES the marker. A mixed-build fleet deafens its
|
|
177
|
+
* herdr seats as a side effect of looking at the roster.
|
|
178
|
+
*
|
|
179
|
+
* pid 1 is kept by all three: it always exists, so `isPidAlive(1)` is true. Their signal paths,
|
|
180
|
+
* enumerated by qa across all three on its gate: kit 0.26.19 and published 0.26.22 gate
|
|
181
|
+
* `detach_agent`'s SIGTERM on `isPusherProcess(marker.pid)`, which reads the process COMMAND, so they
|
|
182
|
+
* never signal launchd whatever uid they run as. Only 0.19.1's detach is unguarded (`isPidAlive`
|
|
183
|
+
* then `process.kill(marker.pid, "SIGTERM")`, reachable from `detach_agent`, `unregister` and
|
|
184
|
+
* `rename_agent`), and it enforces identity, so its caller must be bound as that herdr seat. As a
|
|
185
|
+
* non-root process that kill gets EPERM — measured `killed:false`, launchd untouched. The seat's own
|
|
186
|
+
* server pid would have been kept by all three as well, and 0.19.1's unguarded detach would have
|
|
187
|
+
* SIGTERMed that seat's MCP server; that shape is rejected.
|
|
188
|
+
*
|
|
189
|
+
* ⛔ SO pid 1 IS WRITTEN ONLY WHEN THIS PROCESS COULD NOT SIGNAL IT, asked of the kernel with
|
|
190
|
+
* signal 0 rather than inferred from a uid: as root, or in a container where pid 1 is our own
|
|
191
|
+
* user's process (often the server itself), `kill(1, 0)` succeeds and an old reader running
|
|
192
|
+
* alongside could SIGTERM init. There the marker falls back to pid 0 and says why — an old
|
|
193
|
+
* reader then deletes it, which is deafness, and deafness is recoverable where a signal to init
|
|
194
|
+
* is not. RESIDUAL, stated as narrowly as it is: a 0.19.1-era server, running as ROOT, bound as
|
|
195
|
+
* the herdr seat's OWN identity, calling detach, unregister or rename, reaches init. Nothing a
|
|
196
|
+
* marker says can remove a privilege its reader holds.
|
|
197
|
+
*
|
|
198
|
+
* The CURRENT build never reads this pid as liveness: a herdr marker is live exactly when herdr
|
|
199
|
+
* says its pane exists (`isMarkerLive`), and nothing signals it (`detach_agent` removes the
|
|
200
|
+
* marker, `markerHoldsLiveProcess` answers false).
|
|
201
|
+
*/
|
|
202
|
+
export type HerdrMarkerPid = { pid: 0 | 1; why: string };
|
|
203
|
+
export function herdrMarkerPid(kill: (pid: number, signal: 0) => unknown = (p, s) => process.kill(p, s)): HerdrMarkerPid {
|
|
204
|
+
try {
|
|
205
|
+
kill(1, 0);
|
|
206
|
+
return { pid: 0, why: "this process may signal pid 1 (root, or a container whose pid 1 is ours), so pid 1 is not safe to advertise; pre-herdr readers will reap this marker" };
|
|
207
|
+
} catch (e) {
|
|
208
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
209
|
+
if (code === "EPERM") return { pid: 1, why: "pid 1 exists and this process may not signal it, so pre-herdr readers keep the marker and their detach cannot signal it" };
|
|
210
|
+
return { pid: 0, why: `kill(1, 0) answered ${code ?? "an unexpected error"}, not EPERM — pid 1 not advertised` };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
131
214
|
export class HerdrTransport implements Transport {
|
|
132
215
|
readonly kind: TransportKind = HERDR;
|
|
133
216
|
#run: HerdrRunner;
|
|
134
217
|
#settleMs: number;
|
|
135
218
|
#sleep: (ms: number) => void;
|
|
136
219
|
#readLines: number;
|
|
220
|
+
#markerPid: () => HerdrMarkerPid;
|
|
221
|
+
#env: Record<string, string | undefined>;
|
|
137
222
|
|
|
138
223
|
constructor(opts: HerdrTransportOptions = {}) {
|
|
139
224
|
this.#run = opts.run ?? defaultHerdrRunner;
|
|
@@ -143,7 +228,9 @@ export class HerdrTransport implements Transport {
|
|
|
143
228
|
// the spike measured is Claude's input box; the retry exists for that, bounded to one.
|
|
144
229
|
this.#settleMs = opts.settleMs ?? 400;
|
|
145
230
|
this.#sleep = opts.sleep ?? ((ms) => { const end = Date.now() + ms; while (Date.now() < end) { /* spin: tiny and rare */ } });
|
|
146
|
-
this.#readLines = opts.readLines ??
|
|
231
|
+
this.#readLines = opts.readLines ?? 40;
|
|
232
|
+
this.#markerPid = opts.markerPid ?? (() => herdrMarkerPid());
|
|
233
|
+
this.#env = opts.env ?? process.env;
|
|
147
234
|
}
|
|
148
235
|
|
|
149
236
|
/** Is herdr on this host AND is its server running? Both, or the reason. */
|
|
@@ -168,7 +255,7 @@ export class HerdrTransport implements Transport {
|
|
|
168
255
|
async attach(args: { agentId: string; target?: string; includeRoom?: boolean; allowlist?: string[]; debounceMs?: number }): Promise<TransportMarker> {
|
|
169
256
|
const avail = this.availability();
|
|
170
257
|
if (!avail.available) throw new Error(`herdr transport cannot attach '${args.agentId}': ${avail.reason}`);
|
|
171
|
-
let target = args.target ??
|
|
258
|
+
let target = args.target ?? this.#env.HERDR_PANE_ID;
|
|
172
259
|
if (!target) {
|
|
173
260
|
const cur = this.#run(["pane", "current"]);
|
|
174
261
|
target = paneOf(cur)?.pane_id;
|
|
@@ -178,10 +265,12 @@ export class HerdrTransport implements Transport {
|
|
|
178
265
|
}
|
|
179
266
|
const got = this.#run(["pane", "get", target]);
|
|
180
267
|
if (!got.ok) throw new Error(`herdr pane '${target}' not found: ${got.error?.message ?? got.stderr}`);
|
|
268
|
+
const markerPid = this.#markerPid();
|
|
181
269
|
return {
|
|
182
270
|
agentId: args.agentId,
|
|
183
271
|
transport: HERDR,
|
|
184
|
-
pid:
|
|
272
|
+
pid: markerPid.pid,
|
|
273
|
+
pidWhy: markerPid.why,
|
|
185
274
|
target,
|
|
186
275
|
tmuxTarget: target,
|
|
187
276
|
since: Date.now(),
|
|
@@ -203,40 +292,108 @@ export class HerdrTransport implements Transport {
|
|
|
203
292
|
* Type `text` into the pane and press enter; VERIFY by reading the pane back, and if
|
|
204
293
|
* the line is still sitting unsubmitted (the spike's race), press enter once more.
|
|
205
294
|
* Reports the enters it took so the race is measured on every delivery.
|
|
295
|
+
*
|
|
296
|
+
* ⟨q-dc83023a⟩ A DELIVERY IS A BRACKETED PASTE BY DEFAULT. `send-text` types keystrokes and
|
|
297
|
+
* herdr writes them in 1022-byte chunks; Claude Code v2.1.273 folds each large chunk into a
|
|
298
|
+
* `[Pasted text #N]` chip and, on submit, KEPT ONLY THE LAST CHUNK — a 2668-byte message
|
|
299
|
+
* arrived as its final 624 bytes, the PR, sha and gate gone. Wrapped in paste markers, the
|
|
300
|
+
* same bytes arrived whole (measured at 2668 B, 5 KB multi-line and 20 KB). The body is
|
|
301
|
+
* neutralised first so it can never carry the closing marker itself (⟨q-e5cb3538⟩).
|
|
302
|
+
* `paste: false` is for control commands only: a slash command must arrive as typing.
|
|
206
303
|
*/
|
|
207
|
-
async push(marker: TransportMarker, text: string
|
|
304
|
+
async push(marker: TransportMarker, text: string, opts: { paste?: boolean } = {}): Promise<HerdrPushResult> {
|
|
208
305
|
const avail = this.availability();
|
|
209
|
-
if (!avail.available) return { delivered: false, error: avail.reason };
|
|
306
|
+
if (!avail.available) return { delivered: false, outcome: "held", safeToRetry: true, error: avail.reason };
|
|
210
307
|
const target = targetOf(marker);
|
|
211
|
-
if (!target) return { delivered: false, error: "no target recorded on the marker" };
|
|
212
|
-
const
|
|
213
|
-
if (!typed.ok) return { delivered: false, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
|
|
308
|
+
if (!target) return { delivered: false, outcome: "held", safeToRetry: true, error: "no target recorded on the marker" };
|
|
309
|
+
const payload = opts.paste === false ? text : `${PASTE_START}${neutralizeControls(text)}${PASTE_END}`;
|
|
214
310
|
const enterKey = herdrKeyName("enter");
|
|
215
|
-
if (!enterKey.ok) return { delivered: false, error: enterKey.error };
|
|
311
|
+
if (!enterKey.ok) return { delivered: false, outcome: "held", safeToRetry: true, error: enterKey.error };
|
|
312
|
+
if (readyProfile().profile === "none") return this.#pushUnguarded(target, text, payload, enterKey.key);
|
|
313
|
+
|
|
314
|
+
// ⟨q-15d763dc⟩ BEFORE ANY KEY: the screen must be Claude Code's ready, empty input box. The text
|
|
315
|
+
// is held as firmly as the Enter — a digit typed into an open dialog selects an option.
|
|
316
|
+
const before = readReadyBox(this.#screen(target));
|
|
317
|
+
const refusal = refusalBeforeKeys(before);
|
|
318
|
+
if (refusal) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `held, nothing typed into ${target}: ${refusal}` };
|
|
319
|
+
|
|
320
|
+
const typed = this.#run(["pane", "send-text", target, payload]);
|
|
321
|
+
if (!typed.ok) return { delivered: false, verified: false, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
|
|
322
|
+
this.#sleep(this.#settleMs);
|
|
323
|
+
let box = readReadyBox(this.#screen(target));
|
|
324
|
+
if (!boxHoldsPayload(box, text)) {
|
|
325
|
+
return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `typed into ${target}, but the text did not show in the input box (${box.ready ? "box holds something else" : box.reason}) — Enter NOT sent` };
|
|
326
|
+
}
|
|
216
327
|
let enters = 0;
|
|
217
328
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
329
|
+
// Every Enter follows a read that saw THIS payload in the ready box, never a stale one.
|
|
218
330
|
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 };
|
|
331
|
+
if (!pressed.ok) return { delivered: false, verified: false, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
|
|
332
|
+
enters++;
|
|
333
|
+
this.#sleep(this.#settleMs);
|
|
334
|
+
box = readReadyBox(this.#screen(target));
|
|
335
|
+
if (transcriptGained(before, box, text)) return { delivered: true, verified: true, outcome: "delivered", safeToRetry: false, enters, busy: box.ready ? box.busy ?? undefined : undefined };
|
|
336
|
+
if (boxHoldsPayload(box, text)) continue;
|
|
337
|
+
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})` };
|
|
338
|
+
}
|
|
339
|
+
return { delivered: false, verified: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** The screen, styled, so the ready-box reader can tell a ghost suggestion from a draft. null = unreadable. */
|
|
343
|
+
#screen(target: string): string | null {
|
|
344
|
+
const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "ansi"]);
|
|
345
|
+
return read.ok ? read.stdout : null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* AGENT_COORD_READY_PROFILE=none, chosen by whoever started this process: #357's push, with no
|
|
350
|
+
* readiness check. Reported `unguarded`, and an unreadable screen still never counts as verified.
|
|
351
|
+
*/
|
|
352
|
+
#pushUnguarded(target: string, text: string, payload: string, enter: string): HerdrPushResult {
|
|
353
|
+
const typed = this.#run(["pane", "send-text", target, payload]);
|
|
354
|
+
if (!typed.ok) return { delivered: false, unguarded: true, outcome: "held", safeToRetry: true, error: `send-text to ${target} refused: ${typed.error?.message ?? typed.stderr}` };
|
|
355
|
+
let enters = 0;
|
|
356
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
357
|
+
const pressed = this.#run(["pane", "send-keys", target, enter]);
|
|
358
|
+
if (!pressed.ok) return { delivered: false, unguarded: true, outcome: "typed-unconfirmed", safeToRetry: false, error: `send-keys enter to ${target} refused: ${pressed.error?.message ?? pressed.stderr}`, enters };
|
|
220
359
|
enters++;
|
|
221
360
|
this.#sleep(this.#settleMs);
|
|
222
361
|
const pending = this.#stillPending(target, text);
|
|
223
|
-
if (pending === false) return { delivered: true,
|
|
224
|
-
if (pending === null) return { delivered: true,
|
|
362
|
+
if (pending === false) return { delivered: true, verified: true, unguarded: true, outcome: "delivered", safeToRetry: false, enters };
|
|
363
|
+
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` };
|
|
225
364
|
}
|
|
226
|
-
return { delivered: false,
|
|
365
|
+
return { delivered: false, verified: true, unguarded: true, outcome: "pending", safeToRetry: false, enters, error: `text still sitting unsubmitted in ${target}'s input after ${enters} enters` };
|
|
227
366
|
}
|
|
228
367
|
|
|
229
368
|
/**
|
|
230
|
-
* Read the pane back: is the typed text still
|
|
369
|
+
* Read the pane back: is the typed text still sitting in the INPUT (unsubmitted)?
|
|
231
370
|
* true = pending · false = submitted · null = could not read (unverified, not failed).
|
|
371
|
+
*
|
|
372
|
+
* ⟨q-dc83023a⟩ THE INPUT IS THE PROMPT LINE, NOT THE LAST LINE. Claude Code draws a border,
|
|
373
|
+
* a footer and hints ("paste again to expand") BELOW its `❯` input, so the last non-empty
|
|
374
|
+
* line is never the input and the old last-line check answered "submitted" for a message
|
|
375
|
+
* still sitting there. The prompt line is pending when it shows a paste chip or the start
|
|
376
|
+
* of what was typed (a long first line wraps, so either may be a prefix of the other).
|
|
377
|
+
*
|
|
378
|
+
* NO PROMPT LINE → null (UNVERIFIED), never the last-line reading: that reading was measured
|
|
379
|
+
* to answer "submitted" for text still sitting in Claude's input, so falling back to it would
|
|
380
|
+
* let a changed Claude version, an open dialog or a read mid-render verify silently again
|
|
381
|
+
* (the coordinator's condition on ⟨q-dc83023a⟩, from worker-2). What a caller does with an
|
|
382
|
+
* unverified delivery is ⟨q-15d763dc⟩.
|
|
232
383
|
*/
|
|
233
384
|
#stillPending(target: string, text: string): boolean | null {
|
|
234
385
|
const read = this.#run(["pane", "read", target, "--source", "visible", "--lines", String(this.#readLines), "--format", "text"]);
|
|
235
386
|
if (!read.ok) return null;
|
|
236
387
|
const lines = read.stdout.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => l.trim().length > 0);
|
|
237
|
-
const last = lines.at(-1) ?? "";
|
|
238
388
|
const firstLine = text.split("\n")[0];
|
|
239
|
-
|
|
389
|
+
const prompt = [...lines].reverse().find((l) => /^\s*❯/.test(l));
|
|
390
|
+
if (prompt !== undefined) {
|
|
391
|
+
const content = prompt.replace(/^\s*❯\s?/, "").trim();
|
|
392
|
+
if (!content) return false;
|
|
393
|
+
if (PASTE_CHIP_RE.test(content)) return true;
|
|
394
|
+
return firstLine.startsWith(content) || content.startsWith(firstLine);
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
240
397
|
}
|
|
241
398
|
|
|
242
399
|
/**
|
|
@@ -276,7 +433,8 @@ export class HerdrTransport implements Transport {
|
|
|
276
433
|
* not measured) and are said so in the result.
|
|
277
434
|
*/
|
|
278
435
|
async sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string; enters?: number; note?: string }> {
|
|
279
|
-
const r = await this.push(marker, `/${cmd}
|
|
436
|
+
const r = await this.push(marker, `/${cmd}`, { paste: false });
|
|
437
|
+
// push reports delivered only when verified (⟨q-15d763dc⟩), so `delivered` is the whole test here.
|
|
280
438
|
if (!r.delivered) return { ok: false, error: r.error ?? "control not delivered", enters: r.enters };
|
|
281
439
|
return {
|
|
282
440
|
ok: true,
|
|
@@ -285,6 +443,80 @@ export class HerdrTransport implements Transport {
|
|
|
285
443
|
};
|
|
286
444
|
}
|
|
287
445
|
|
|
446
|
+
/**
|
|
447
|
+
* ⟨q-1c95f7d4⟩ 5.1 — THE EXTERNAL TICK, CONSUMED. herdr's own `agent_status` for the
|
|
448
|
+
* pane, which is the signal this fleet has never had: our liveness is pid-existence and
|
|
449
|
+
* our activity is VCS commits, so a THINKING lane and a WEDGED lane are identical to us.
|
|
450
|
+
*
|
|
451
|
+
* ⛔ `unknown` AND `done` ARE NOT SEAT STATES AND MUST NOT READ AS CALM. `unknown` is
|
|
452
|
+
* herdr saying it has no agent there (the shape a plain shell pane returns, measured);
|
|
453
|
+
* `done` is a finished session, not a moving seat. Both come back UNREADABLE, so the
|
|
454
|
+
* caller counts them as unmeasured rather than crediting coverage — the exact inversion
|
|
455
|
+
* (blind read as quiet) that `stall_check` shipped and this task exists to end.
|
|
456
|
+
*/
|
|
457
|
+
async readTick(marker: TransportMarker): Promise<TickReading> {
|
|
458
|
+
if (marker.transport !== HERDR) return { readable: false, why: `transport "${marker.transport}" has no external tick — only a herdr marker does` };
|
|
459
|
+
const target = targetOf(marker);
|
|
460
|
+
if (!target) return { readable: false, why: "no target recorded on the marker" };
|
|
461
|
+
const avail = this.availability();
|
|
462
|
+
if (!avail.available) return { readable: false, why: avail.reason };
|
|
463
|
+
const got = this.#run(["pane", "get", target]);
|
|
464
|
+
if (!got.ok) {
|
|
465
|
+
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)` };
|
|
466
|
+
return { readable: false, why: `herdr could not describe pane ${target}: ${got.error?.message ?? got.stderr}` };
|
|
467
|
+
}
|
|
468
|
+
const status = String(paneOf(got)?.agent_status ?? "unknown");
|
|
469
|
+
const state = TICK_READS_AS[status];
|
|
470
|
+
if (state) return { readable: true, state, source: `herdr pane ${target}` };
|
|
471
|
+
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` };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* ⟨q-1c95f7d4⟩ Task 1's open question — PUBLISH, and it needs a receipt it does not get.
|
|
476
|
+
*
|
|
477
|
+
* MEASURED 2026-09-15 in a workspace of my own: `herdr pane report-agent <pane> --source
|
|
478
|
+
* coord-mcp --agent <label> --state blocked` prints NOTHING and exits 0, and the pane
|
|
479
|
+
* then reads `"agent":"<label>","agent_status":"blocked"` where a second earlier it read
|
|
480
|
+
* `unknown` with no agent field. ⛔ A WRITE WITH NO OUTPUT IS A WRITE WITH NO RECEIPT:
|
|
481
|
+
* exit 0 here says the CLI parsed the arguments, not that the pane carries the state, and
|
|
482
|
+
* a second `report-agent` overwrites the first with the same silence. So this method does
|
|
483
|
+
* not trust the exit code — it READS THE PANE BACK and reports `ok` only when the pane
|
|
484
|
+
* carries both the agent label we published and the state we published. An unverifiable
|
|
485
|
+
* write is returned as an error naming what the pane actually says.
|
|
486
|
+
*
|
|
487
|
+
* ⚠ WHAT A WRONG PUBLISH COSTS, stated because the write is silent and destructive:
|
|
488
|
+
* herdr keeps ONE agent state per pane, so publishing to a pane this bus does not own
|
|
489
|
+
* overwrites whatever that pane's real agent last reported, with no history and no
|
|
490
|
+
* notification to the owner — a fleet watching herdr would then read our fiction as the
|
|
491
|
+
* seat's own signal. Hence the marker gate above (`transport !== HERDR` refuses): we
|
|
492
|
+
* publish only to panes recorded as herdr seats of this bus, never to a pane id passed in
|
|
493
|
+
* from anywhere else.
|
|
494
|
+
*/
|
|
495
|
+
async publishTick(marker: TransportMarker, state: TickState, opts: { message?: string; source?: string } = {}): Promise<{ ok: boolean; error?: string; verified?: boolean }> {
|
|
496
|
+
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` };
|
|
497
|
+
const target = targetOf(marker);
|
|
498
|
+
if (!target) return { ok: false, error: "no target recorded on the marker" };
|
|
499
|
+
const avail = this.availability();
|
|
500
|
+
if (!avail.available) return { ok: false, error: avail.reason };
|
|
501
|
+
const source = opts.source ?? "coord-mcp";
|
|
502
|
+
const args = ["pane", "report-agent", target, "--source", source, "--agent", marker.agentId, "--state", state];
|
|
503
|
+
if (opts.message) args.push("--message", opts.message);
|
|
504
|
+
const wrote = this.#run(args);
|
|
505
|
+
if (!wrote.ok) return { ok: false, error: `herdr refused the report: ${wrote.error?.message ?? wrote.stderr}` };
|
|
506
|
+
// THE RECEIPT IS THE READ-BACK, never the exit code.
|
|
507
|
+
const back = this.#run(["pane", "get", target]);
|
|
508
|
+
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}` };
|
|
509
|
+
const pane = paneOf(back);
|
|
510
|
+
const gotAgent = String(pane?.agent ?? "");
|
|
511
|
+
const gotState = String(pane?.agent_status ?? "");
|
|
512
|
+
// VERIFIED AGAINST WHAT herdr STORES, not against what we sent: a published `idle`
|
|
513
|
+
// comes back as `done` (TICK_STORED_AS, measured). Comparing to the sent word would
|
|
514
|
+
// call every idle publish a failure; comparing to the stored word is the receipt.
|
|
515
|
+
const expected = TICK_STORED_AS[state];
|
|
516
|
+
if (gotAgent === marker.agentId && gotState === expected) return { ok: true, verified: true };
|
|
517
|
+
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` };
|
|
518
|
+
}
|
|
519
|
+
|
|
288
520
|
/**
|
|
289
521
|
* No pusher to kill. A herdr marker whose pane is gone is reaped (the caller deletes
|
|
290
522
|
* the marker); an unknown answer is unprobeable; a live pane is left alone.
|
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, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult } from "./herdr.js";
|
|
17
|
+
export { HerdrTransport, defaultHerdrRunner, interpretHerdrReply, herdrKeyName, herdrPaneExists, herdrMarkerPid, HERDR_KEYS, HERDR_ABSENT_MESSAGE, type HerdrRunner, type HerdrResult, type HerdrMarkerPid } from "./herdr.js";
|
|
18
18
|
|
|
19
19
|
let host: TmuxHost | undefined;
|
|
20
20
|
|
package/src/transports/types.ts
CHANGED
|
@@ -87,6 +87,11 @@ export type TransportMarker = {
|
|
|
87
87
|
target?: string;
|
|
88
88
|
/** The original field. Still written. See `target`. */
|
|
89
89
|
tmuxTarget?: string;
|
|
90
|
+
/**
|
|
91
|
+
* ⟨q-abd88dd4⟩ herdr only: why `pid` holds what it holds (see `herdrMarkerPid`). A herdr
|
|
92
|
+
* marker's pid is addressed to PRE-HERDR readers and is never liveness for this build.
|
|
93
|
+
*/
|
|
94
|
+
pidWhy?: string;
|
|
90
95
|
since: number;
|
|
91
96
|
/**
|
|
92
97
|
* Remote pushers run on a different machine; the local pid is meaningless,
|
|
@@ -154,6 +159,53 @@ export type Liveness =
|
|
|
154
159
|
| { state: "dead"; reason: string }
|
|
155
160
|
| { state: "unknown"; reason: string };
|
|
156
161
|
|
|
162
|
+
/**
|
|
163
|
+
* ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — THE EXTERNAL TICK'S VOCABULARY, TAKEN FROM THE TOOL THAT
|
|
164
|
+
* OWNS IT. Measured 2026-09-15 in a workspace of my own: `herdr pane report-agent --help`
|
|
165
|
+
* enumerates exactly `idle, working, blocked, unknown`, and a pane carries the state an
|
|
166
|
+
* external source published (`"agent":"<label>","agent_status":"blocked"` on a pane that
|
|
167
|
+
* read `unknown` a second earlier). So these four are herdr's own set, not ours.
|
|
168
|
+
*
|
|
169
|
+
* ⛔ `unknown` IS THE ABSENCE OF A SIGNAL, NOT A STATE OF THE SEAT. A tick that reads
|
|
170
|
+
* `unknown` is UNREADABLE — reported as such and counted as unmeasured — because the whole
|
|
171
|
+
* defect this task answers is a fleet that reads CALM when it is BLIND (Task 5.3).
|
|
172
|
+
*/
|
|
173
|
+
export const TICK_STATES = ["idle", "working", "blocked"] as const;
|
|
174
|
+
export type TickState = (typeof TICK_STATES)[number];
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ⛔⛆ THE WRITE VOCABULARY AND THE READ VOCABULARY ARE NOT THE SAME, AND ONLY A READ-BACK
|
|
178
|
+
* FINDS IT. Measured 2026-09-16 on a fresh pane of my own, six writes and three repeats:
|
|
179
|
+
*
|
|
180
|
+
* published --state working → agent_status "working"
|
|
181
|
+
* published --state blocked → agent_status "blocked"
|
|
182
|
+
* published --state idle → agent_status "done" ⬅ 3 of 3, with and without --seq
|
|
183
|
+
* published --state unknown → agent_status "unknown"
|
|
184
|
+
*
|
|
185
|
+
* herdr ACCEPTS `idle` (its own `--help` lists it) and STORES `done`. Nothing in the CLI
|
|
186
|
+
* says so: the write prints nothing and exits 0 either way. This is exactly what the
|
|
187
|
+
* receipt exists to catch, and it was caught by the read-back failing, not by reasoning.
|
|
188
|
+
*
|
|
189
|
+
* For the tick's purpose the two mean the same thing — a seat that is not working — so a
|
|
190
|
+
* `done` READS as `idle`, and a publish of `idle` is verified against `done`. The
|
|
191
|
+
* translation is named here rather than hidden in a comparison, so a herdr release that
|
|
192
|
+
* changes it fails a test instead of quietly inverting a verdict.
|
|
193
|
+
*
|
|
194
|
+
* ⚠ THE TWO DIRECTIONS ARE NOT THE SAME SET, AND THE `done` ENTRY IS NOT DEAD CODE — qa
|
|
195
|
+
* measured the asymmetry from the other side on herdr 0.9.0: `report-agent --state done`
|
|
196
|
+
* is REFUSED ("expected idle, working, blocked, or unknown"), so nothing we publish can
|
|
197
|
+
* produce it. It is still REACHABLE ON READ, which is the side `TICK_READS_AS` serves:
|
|
198
|
+
* `agent_status` is what a pane REPORTS, and herdr's own `agent wait --until` enumerates
|
|
199
|
+
* `idle, working, blocked, done, unknown`. A herdr-managed Claude session that finishes
|
|
200
|
+
* hands us `done` without anyone publishing it. So: refused on write, reachable on read,
|
|
201
|
+
* measured in both directions — do not delete this entry as unreachable.
|
|
202
|
+
*/
|
|
203
|
+
export const TICK_STORED_AS: Readonly<Record<TickState, string>> = Object.freeze({ idle: "done", working: "working", blocked: "blocked" });
|
|
204
|
+
export const TICK_READS_AS: Readonly<Record<string, TickState>> = Object.freeze({ idle: "idle", done: "idle", working: "working", blocked: "blocked" });
|
|
205
|
+
export type TickReading =
|
|
206
|
+
| { readable: true; state: TickState; source: string }
|
|
207
|
+
| { readable: false; why: string };
|
|
208
|
+
|
|
157
209
|
/** Keystroke-shaped commands a transport must deliver to an interactive pane. */
|
|
158
210
|
export const CONTROL_COMMANDS = ["clear", "compact", "reload-skills"] as const;
|
|
159
211
|
export type ControlCommand = (typeof CONTROL_COMMANDS)[number];
|
|
@@ -198,4 +250,23 @@ export interface Transport {
|
|
|
198
250
|
sendControl(marker: TransportMarker, cmd: ControlCommand): Promise<{ ok: boolean; error?: string }>;
|
|
199
251
|
|
|
200
252
|
reapWedged(markers: TransportMarker[]): Promise<{ reaped: string[]; unprobeable: string[] }>;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* ⟨q-1c95f7d4⟩ 5.1 — OPTIONAL, AND ITS ABSENCE IS THE HONEST ANSWER FOR tmux.
|
|
256
|
+
*
|
|
257
|
+
* A transport implements `readTick` only when something OUTSIDE this process observes
|
|
258
|
+
* the seat and can be asked. tmux cannot: a pane is a terminal, and "a pid exists" is
|
|
259
|
+
* what `probe` already says. Declaring the method optional is what keeps `stall_check`
|
|
260
|
+
* from inventing a tick for a tmux seat and reading a fabricated calm — those seats keep
|
|
261
|
+
* the unmeasurable story they had (5.3).
|
|
262
|
+
*/
|
|
263
|
+
readTick?(marker: TransportMarker): Promise<TickReading>;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* ⟨q-1c95f7d4⟩ Task 1's open question, answered by measurement: THE PIPE RUNS BOTH WAYS.
|
|
267
|
+
* `coord-mcp` knows what herdr cannot infer — which seat holds which lane, whether a
|
|
268
|
+
* DONE: awaits a verdict, whether a seat is parked on a David decision — so a transport
|
|
269
|
+
* that can PUBLISH state to its external observer exposes it here.
|
|
270
|
+
*/
|
|
271
|
+
publishTick?(marker: TransportMarker, state: TickState, opts?: { message?: string; source?: string }): Promise<{ ok: boolean; error?: string }>;
|
|
201
272
|
}
|