agent-coord-mcp 0.26.24 → 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 +129 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/gated-head.js +12 -1
- 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/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/records.js +18 -2
- 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/transport.js +4 -3
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/herdr.js +118 -18
- 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.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 +129 -3
- package/src/gated-head.ts +12 -1
- package/src/server-spread.ts +52 -4
- package/src/server.ts +38 -1
- 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/records.ts +18 -2
- package/src/tools/registry.ts +56 -20
- package/src/tools/transport.ts +5 -4
- package/src/transports/herdr.ts +175 -17
- package/src/transports/index.ts +1 -1
- package/src/transports/types.ts +5 -0
package/src/tools/records.ts
CHANGED
|
@@ -45,7 +45,7 @@ import { verdictsFor, gatedBy, prVerdictsIn } from "../gated-head.js";
|
|
|
45
45
|
import { boardRefFor, classifyBoardRef } from "./board-ref.js";
|
|
46
46
|
import { haltState } from "./stall.js";
|
|
47
47
|
import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
|
|
48
|
-
import { prRefsIn } from "./record-events.js";
|
|
48
|
+
import { prRefsIn, leadingItemIdOf } from "./record-events.js";
|
|
49
49
|
|
|
50
50
|
const QUEUE_DOC = "docs/QUEUE.md";
|
|
51
51
|
const DONE_DOC = "docs/DONE.md";
|
|
@@ -1199,7 +1199,23 @@ export async function landTool(
|
|
|
1199
1199
|
}
|
|
1200
1200
|
}
|
|
1201
1201
|
|
|
1202
|
-
|
|
1202
|
+
/*
|
|
1203
|
+
* ⛔ ⟨q-552b9912⟩ "ALREADY LOGGED" IS A QUESTION ABOUT THE ITEM, NOT THE PR.
|
|
1204
|
+
*
|
|
1205
|
+
* It asked whether ANY DONE entry cited this PR, so the second item a PR closed found the first item's entry,
|
|
1206
|
+
* reported `alreadyLogged: true`, and wrote nothing: the row left the queue and never reached the delivery record,
|
|
1207
|
+
* so `scan_record_events` emitted no item event for it. Measured twice on 2026-09-16 — ⟨q-3b8e05af⟩ on #360 and
|
|
1208
|
+
* ⟨q-144c97a8⟩ on #366, both hand-repaired (07338a1, 00c8315).
|
|
1209
|
+
*
|
|
1210
|
+
* Keyed per item BY POSITION ONLY: an entry is this item's when it LEADS with `⟨id⟩` (the form this verb writes). A
|
|
1211
|
+
* by-mention fallback was tried and REMOVED (qa's FAIL on #370 @ fce3ae1): an id-less entry for this PR that merely
|
|
1212
|
+
* MENTIONS the id — B never landed — read as logged and wrote nothing, the same defect narrowed. A mention is not a
|
|
1213
|
+
* record. A land with no item keeps the PR key, since there is no item to key on.
|
|
1214
|
+
*/
|
|
1215
|
+
const citesThisPr = (e: { ref?: string }) => new RegExp(`#${n}\\b`).test(String(e.ref ?? ""));
|
|
1216
|
+
const already = target
|
|
1217
|
+
? doneEntriesOf(d.doc).some((e) => leadingItemIdOf(e.text) === target.id)
|
|
1218
|
+
: doneEntriesOf(d.doc).some(citesThisPr);
|
|
1203
1219
|
|
|
1204
1220
|
// A DONE LINE A HUMAN WOULD NOT HAVE WRITTEN IS NOT A DONE LINE.
|
|
1205
1221
|
//
|
package/src/tools/registry.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { existsSync, openSync, statSync, watch } from "node:fs";
|
|
|
6
6
|
import { promises as fsp } from "node:fs";
|
|
7
7
|
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { detectSpread, installedBuild } from "../server-spread.js";
|
|
9
|
+
import { detectSpread, installedBuild, pidRunning } from "../server-spread.js";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import path from "node:path";
|
|
12
12
|
import {
|
|
@@ -161,6 +161,9 @@ export async function registerTool(args: {
|
|
|
161
161
|
registeredAt: existing?.registeredAt ?? now,
|
|
162
162
|
lastHeartbeat: now,
|
|
163
163
|
capabilities: existing?.capabilities,
|
|
164
|
+
// ⟨q-18a719c5⟩ The entry is rebuilt here, so the stamp is rebuilt with it — from the
|
|
165
|
+
// server answering THIS register, which is the one serving the seat now.
|
|
166
|
+
...answeringServerIdentity(),
|
|
164
167
|
// Omitted → carried forward untouched. Only an explicit `false` revokes.
|
|
165
168
|
...(args.proseOnly === undefined
|
|
166
169
|
? existing?.proseOnly
|
|
@@ -285,6 +288,39 @@ export async function quitTool(args: { agentId: string }): Promise<never> {
|
|
|
285
288
|
|
|
286
289
|
export const heartbeatSchema = { agentId: z.string().min(1) };
|
|
287
290
|
|
|
291
|
+
/**
|
|
292
|
+
* ⟨q-18a719c5⟩ THE ANSWERING SERVER'S IDENTITY — what `serverSpread` places a seat by. It exists
|
|
293
|
+
* only inside the process that serves the seat, so every path on which a session binds an
|
|
294
|
+
* identity stamps it: `register` (which `join` calls), the first-claim binding in server.ts
|
|
295
|
+
* (a server that restarts and re-binds through any gated tool, `attach_agent` included),
|
|
296
|
+
* `rename_agent` (the renaming session serves the new id), and `heartbeat`. Before this,
|
|
297
|
+
* only `heartbeat` stamped, no code path called it, and `register` rebuilt the entry and
|
|
298
|
+
* dropped whatever it had written.
|
|
299
|
+
*/
|
|
300
|
+
export function answeringServerIdentity(): { serverPid: number; serverStartedAt: number; serverModule?: string } {
|
|
301
|
+
return {
|
|
302
|
+
serverPid: process.pid,
|
|
303
|
+
// Derived from uptime rather than read from a file: an mtime tracks writes (a reinstall of
|
|
304
|
+
// identical bytes moves it) while uptime is a fact about THIS process.
|
|
305
|
+
serverStartedAt: Date.now() - Math.round(process.uptime() * 1000),
|
|
306
|
+
// WHAT this process is executing, not what it is labelled. Resolved from this module's own
|
|
307
|
+
// URL, so a server running a dev `dist/` says so instead of inheriting the installed path.
|
|
308
|
+
serverModule: installedBuild(import.meta.url, statSync)?.module,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Stamp an EXISTING registry entry with the answering server's identity. False when absent. */
|
|
313
|
+
export async function stampServerIdentity(agentId: string): Promise<boolean> {
|
|
314
|
+
let stamped = false;
|
|
315
|
+
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
316
|
+
if (!current[agentId]) return current;
|
|
317
|
+
Object.assign(current[agentId], answeringServerIdentity());
|
|
318
|
+
stamped = true;
|
|
319
|
+
return current;
|
|
320
|
+
});
|
|
321
|
+
return stamped;
|
|
322
|
+
}
|
|
323
|
+
|
|
288
324
|
export async function heartbeatTool(args: { agentId: string }) {
|
|
289
325
|
let missing = false;
|
|
290
326
|
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
@@ -293,23 +329,8 @@ export async function heartbeatTool(args: { agentId: string }) {
|
|
|
293
329
|
return current;
|
|
294
330
|
}
|
|
295
331
|
current[args.agentId].lastHeartbeat = Date.now();
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
* is answering, which is the only place these two facts exist: `capabilities`
|
|
299
|
-
* reports them solely to its own caller, and no seat can query another seat's
|
|
300
|
-
* server at all. Publishing them here is what makes a cross-seat comparison
|
|
301
|
-
* possible without every seat having to volunteer it in prose.
|
|
302
|
-
*
|
|
303
|
-
* `startedAt` is derived from uptime rather than read from a file: an mtime tracks
|
|
304
|
-
* writes (a reinstall of identical bytes moves it) while uptime is a fact about
|
|
305
|
-
* THIS process.
|
|
306
|
-
*/
|
|
307
|
-
current[args.agentId].serverPid = process.pid;
|
|
308
|
-
current[args.agentId].serverStartedAt = Date.now() - Math.round(process.uptime() * 1000);
|
|
309
|
-
// ⛔ WHAT this process is executing, not what it is labelled. Resolved from this
|
|
310
|
-
// module's own URL, so a server running a dev `dist/` says so instead of inheriting
|
|
311
|
-
// the installed path — the case that made a 3-build fleet read AGREED.
|
|
312
|
-
current[args.agentId].serverModule = installedBuild(import.meta.url, statSync)?.module;
|
|
332
|
+
// ⛔ STAMP THE ANSWERING PROCESS — `⟨q-cec42e20⟩`; see `answeringServerIdentity`.
|
|
333
|
+
Object.assign(current[args.agentId], answeringServerIdentity());
|
|
313
334
|
return current;
|
|
314
335
|
});
|
|
315
336
|
if (missing) return { ok: false, error: `agent '${args.agentId}' not registered` };
|
|
@@ -464,6 +485,8 @@ export async function listAgentsTool() {
|
|
|
464
485
|
serverModule: a.serverModule,
|
|
465
486
|
})),
|
|
466
487
|
installedBuild(import.meta.url, statSync),
|
|
488
|
+
// ⟨q-18a719c5⟩ a stamp whose server is gone reads unknown: ask the kernel, here, in production.
|
|
489
|
+
{ isRunning: pidRunning },
|
|
467
490
|
);
|
|
468
491
|
|
|
469
492
|
// ⟨q-178878aa⟩ — the humans the bus knows: visible here, read at send time, never evicted.
|
|
@@ -541,6 +564,18 @@ export function isMarkerLive(marker: TransportMarker, reg: AgentRegistry, now: n
|
|
|
541
564
|
return isPidAlive(marker.pid);
|
|
542
565
|
}
|
|
543
566
|
|
|
567
|
+
/**
|
|
568
|
+
* ⟨q-abd88dd4⟩ Does this marker hold a LIVE LOCAL PROCESS — a pusher someone could signal, or
|
|
569
|
+
* wait on? A herdr marker never does: it has no pusher, and its `pid` is addressed to pre-herdr
|
|
570
|
+
* readers (pid 1, see `herdrMarkerPid`), so reading it as a process would make pid 1 look like a
|
|
571
|
+
* running pusher. Every pid-as-process decision about a marker goes through here; liveness of a
|
|
572
|
+
* herdr seat is `isMarkerLive`, which asks herdr for the pane.
|
|
573
|
+
*/
|
|
574
|
+
export function markerHoldsLiveProcess(marker: TransportMarker | null | undefined): boolean {
|
|
575
|
+
if (!marker || marker.transport === HERDR) return false;
|
|
576
|
+
return isPidAlive(marker.pid);
|
|
577
|
+
}
|
|
578
|
+
|
|
544
579
|
export function isPidAlive(pid: number): boolean {
|
|
545
580
|
if (!pid || pid <= 0) return false;
|
|
546
581
|
try {
|
|
@@ -682,7 +717,7 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
682
717
|
// must re-attach under the new id (join/attach_agent) to restore push.
|
|
683
718
|
const liveTransport = await readJson<TransportMarker | null>(transportFile(oldId), null);
|
|
684
719
|
let detachedTransport = false;
|
|
685
|
-
if (
|
|
720
|
+
if (markerHoldsLiveProcess(liveTransport)) {
|
|
686
721
|
await detachAgentTool({ agentId: oldId });
|
|
687
722
|
detachedTransport = true;
|
|
688
723
|
}
|
|
@@ -690,7 +725,8 @@ export async function renameAgentTool(args: { agentId: string; newAgentId: strin
|
|
|
690
725
|
// Registry: move the entry under the new key.
|
|
691
726
|
await updateJson<AgentRegistry>(AGENTS_FILE, {}, (current) => {
|
|
692
727
|
if (current[oldId]) {
|
|
693
|
-
|
|
728
|
+
// ⟨q-18a719c5⟩ The renaming session serves the new id, so the stamp is re-taken from it.
|
|
729
|
+
current[newId] = { ...current[oldId], agentId: newId, ...answeringServerIdentity() };
|
|
694
730
|
delete current[oldId];
|
|
695
731
|
}
|
|
696
732
|
return current;
|
package/src/tools/transport.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadLiveTransports, isMarkerLive, isPidAlive } from "./registry.js";
|
|
1
|
+
import { loadLiveTransports, isMarkerLive, isPidAlive, markerHoldsLiveProcess } from "./registry.js";
|
|
2
2
|
import {
|
|
3
3
|
TMUX_PUSH,
|
|
4
4
|
registerTmuxHost,
|
|
@@ -685,7 +685,8 @@ export async function attachAgentTool(args: {
|
|
|
685
685
|
agentId: args.agentId,
|
|
686
686
|
transport: HERDR,
|
|
687
687
|
target: marker.target,
|
|
688
|
-
pid:
|
|
688
|
+
pid: marker.pid,
|
|
689
|
+
pidWhy: marker.pidWhy,
|
|
689
690
|
rooms: marker.rooms,
|
|
690
691
|
note: "herdr socket transport: no pusher process — delivery is made in-process by this server through herdr's socket API; liveness is herdr's own pane status",
|
|
691
692
|
};
|
|
@@ -711,10 +712,10 @@ export async function attachAgentTool(args: {
|
|
|
711
712
|
|
|
712
713
|
// If something's already attached, refuse rather than spawn a second pusher.
|
|
713
714
|
const existing = await readJson<TransportMarker | null>(transportFile(args.agentId), null);
|
|
714
|
-
if (
|
|
715
|
+
if (markerHoldsLiveProcess(existing)) {
|
|
715
716
|
return {
|
|
716
717
|
ok: false,
|
|
717
|
-
error: `agent '${args.agentId}' already has a live ${existing
|
|
718
|
+
error: `agent '${args.agentId}' already has a live ${existing!.transport} attached (pid ${existing!.pid}). Call detach_agent first.`,
|
|
718
719
|
existing,
|
|
719
720
|
};
|
|
720
721
|
}
|
package/src/transports/herdr.ts
CHANGED
|
@@ -36,6 +36,16 @@
|
|
|
36
36
|
import { spawnSync } from "node:child_process";
|
|
37
37
|
import type { ControlCommand, Liveness, Transport, TransportKind, TransportMarker, TickReading, TickState } from "./types.js";
|
|
38
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,
|
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,
|