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/hooks/submit.mjs
CHANGED
|
@@ -281,6 +281,168 @@ export function readPaneState(paneText) {
|
|
|
281
281
|
return { busy, draft, ghost, styledInputLine };
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// ⟨q-15d763dc⟩ NO KEY REACHES A PANE UNLESS ITS READY INPUT BOX IS POSITIVELY IDENTIFIED.
|
|
285
|
+
//
|
|
286
|
+
// Measured 2026-09-16 on a real Claude Code pane: with the /model picker open, a push typed its
|
|
287
|
+
// text and pressed Enter. The picker took the Enter, wrote ~/.claude/settings.json, closed, and
|
|
288
|
+
// left an empty `❯` prompt behind, so the push reported verified:true for a message Claude never
|
|
289
|
+
// received. The same Enter on a permission prompt approves a tool call, and a message's text can
|
|
290
|
+
// press a dialog's numeric option keys before any Enter. Any bus writer controls that text.
|
|
291
|
+
// So readiness is decided BEFORE the first keystroke, from the SHAPE of the screen, and every
|
|
292
|
+
// pusher (herdr.ts, tmux-pusher.mjs, coord-pusher.mjs) asks this one function. Do not re-implement it.
|
|
293
|
+
//
|
|
294
|
+
// PROFILES, decided once, from THIS PROCESS's environment at start and nowhere else:
|
|
295
|
+
// claude-code (default) — the only box shape known: a ─ rule, `❯` at column 0, a ─ rule,
|
|
296
|
+
// then at most a few footer lines at the bottom of the screen.
|
|
297
|
+
// Measured on Claude Code v2.1.273. Any other screen is HELD.
|
|
298
|
+
// none — the guard is off. For a harness with no profile yet (Aider, codex,
|
|
299
|
+
// gemini-cli, opencode). Logged at start and surfaced in capabilities.
|
|
300
|
+
// An unrecognised value is NOT "none": a typo must never unguard a seat.
|
|
301
|
+
export const READY_PROFILES = Object.freeze(["claude-code", "none"]);
|
|
302
|
+
export function resolveReadyProfile(raw) {
|
|
303
|
+
if (raw === undefined || raw === "") return { profile: "claude-code", raw: null };
|
|
304
|
+
if (READY_PROFILES.includes(raw)) return { profile: raw, raw };
|
|
305
|
+
return { profile: "claude-code", raw, warning: `AGENT_COORD_READY_PROFILE=${JSON.stringify(raw)} is not one of ${READY_PROFILES.join(", ")} — the guard stays ON (claude-code); a typo never unguards a seat` };
|
|
306
|
+
}
|
|
307
|
+
// Read ONCE, at module load, from process.env. Not re-read per call, so nothing that runs later in
|
|
308
|
+
// the process (a message, a bus file, a tool call setting an env var) can switch the guard off.
|
|
309
|
+
const READY_PROFILE_AT_START = Object.freeze(resolveReadyProfile(process.env.AGENT_COORD_READY_PROFILE));
|
|
310
|
+
export function readyProfile() {
|
|
311
|
+
return READY_PROFILE_AT_START;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// The start-up line a pusher or server prints about its guard. Silent when guarded and unambiguous;
|
|
315
|
+
// LOUD when the guard is off or the env value was not understood, so an unguarded seat is visible.
|
|
316
|
+
export function readyProfileStartupLine(who) {
|
|
317
|
+
const p = READY_PROFILE_AT_START;
|
|
318
|
+
if (p.profile === "none") return `[${who}] ⛔ READY-BOX GUARD OFF (AGENT_COORD_READY_PROFILE=none, from this process's env): pushes are typed into panes WITHOUT checking for an input box — any open dialog or permission prompt can receive message text and Enter (q-15d763dc)\n`;
|
|
319
|
+
if (p.warning) return `[${who}] ⚠ ${p.warning}\n`;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// How many lines may sit below the input box's lower rule. Claude Code draws a footer there
|
|
324
|
+
// (mode line, hints); a dialog, picker or autocomplete menu draws many more.
|
|
325
|
+
export const READY_BOX_MAX_BELOW = 4;
|
|
326
|
+
const RULE_RE = /^─{8,}$/;
|
|
327
|
+
const BOX_LINE_RE = /^❯(?:\s|$)/;
|
|
328
|
+
const MAX_DRAFT_LINES = 20;
|
|
329
|
+
|
|
330
|
+
// Read the screen for Claude Code's input box. Returns:
|
|
331
|
+
// { ready: true, draft, ghost, below, above, busy } — the box is on screen; draft "" means empty
|
|
332
|
+
// { ready: false, reason, unreadable? } — no key may be sent
|
|
333
|
+
// { ready: true, unguarded: true } — profile none: the caller's legacy path
|
|
334
|
+
// `screen` may be plain or styled (herdr --format ansi, tmux capture-pane -e).
|
|
335
|
+
export function readReadyBox(screen, profile = READY_PROFILE_AT_START.profile) {
|
|
336
|
+
if (profile === "none") return { ready: true, unguarded: true };
|
|
337
|
+
if (profile !== "claude-code") return { ready: false, reason: `unknown ready profile ${JSON.stringify(profile)} — held` };
|
|
338
|
+
if (screen === null || screen === undefined) return { ready: false, unreadable: true, reason: "the pane could not be read — no key was sent" };
|
|
339
|
+
const styled = String(screen).replace(/\r/g, "").split("\n");
|
|
340
|
+
const plain = styled.map((l) => stripAnsi(l).replace(/ /g, " ").replace(/\s+$/, ""));
|
|
341
|
+
let end = plain.length;
|
|
342
|
+
while (end > 0 && plain[end - 1].trim() === "") end--;
|
|
343
|
+
let p = -1;
|
|
344
|
+
for (let i = end - 1; i >= 0; i--) if (plain[i].includes("❯")) { p = i; break; }
|
|
345
|
+
if (p < 0) return { ready: false, reason: "no ❯ on screen — not Claude Code's input box (another harness, a shell, or a dialog without one)" };
|
|
346
|
+
if (!BOX_LINE_RE.test(plain[p])) return { ready: false, reason: `the last ❯ on screen is not at column 0 (${JSON.stringify(plain[p].trim().slice(0, 40))}) — a dialog, picker or menu row, not the input box` };
|
|
347
|
+
if (p === 0 || !RULE_RE.test(plain[p - 1].trim())) return { ready: false, reason: "the ❯ line has no ─ rule directly above it — not the input box" };
|
|
348
|
+
let q = -1;
|
|
349
|
+
for (let i = p + 1; i < end && i <= p + MAX_DRAFT_LINES; i++) {
|
|
350
|
+
if (RULE_RE.test(plain[i].trim())) { q = i; break; }
|
|
351
|
+
if (plain[i].includes("❯")) break;
|
|
352
|
+
}
|
|
353
|
+
if (q < 0) return { ready: false, reason: "the ❯ line has no ─ rule below it — not the input box" };
|
|
354
|
+
const belowLines = plain.slice(q + 1, end).filter((l) => l.trim() !== "");
|
|
355
|
+
if (belowLines.some((l) => RULE_RE.test(l.trim()))) return { ready: false, reason: "a second ruled box below the input — a dialog or menu is open" };
|
|
356
|
+
let real = "";
|
|
357
|
+
let ghost = "";
|
|
358
|
+
for (let i = p; i < q; i++) {
|
|
359
|
+
const part = partitionStyledLine(styled[i]);
|
|
360
|
+
real += " " + stripAnsi(part.real).replace(/ /g, " ");
|
|
361
|
+
ghost += " " + part.ghost;
|
|
362
|
+
}
|
|
363
|
+
const content = squash(real.replace(/^\s*❯/, ""));
|
|
364
|
+
const placeholder = PLACEHOLDER_PATTERN();
|
|
365
|
+
const draft = placeholder && new RegExp(placeholder).test(content) ? "" : content;
|
|
366
|
+
const busyRe = BUSY_PATTERN();
|
|
367
|
+
return {
|
|
368
|
+
ready: true,
|
|
369
|
+
draft,
|
|
370
|
+
ghost: squash(ghost) || null,
|
|
371
|
+
below: belowLines.length,
|
|
372
|
+
above: plain.slice(0, p - 1),
|
|
373
|
+
busy: busyRe ? new RegExp(busyRe).test(belowLines.join("\n")) : null,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// May a keystroke be sent at all? Ready box, EMPTY input (typing onto someone's draft concatenates
|
|
378
|
+
// with it), and nothing drawn below the footer. `null` = yes; otherwise the reason nothing was sent.
|
|
379
|
+
export function refusalBeforeKeys(box) {
|
|
380
|
+
if (!box.ready) return box.reason;
|
|
381
|
+
if (box.unguarded) return null;
|
|
382
|
+
if (box.draft) return `the input box already holds unsent text (${JSON.stringify(box.draft.slice(0, 40))}) — typing would join it`;
|
|
383
|
+
if (box.below > READY_BOX_MAX_BELOW) return `${box.below} lines drawn below the input box (the footer is at most ${READY_BOX_MAX_BELOW}) — a menu or dialog is open`;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// The pushers' form of refusalBeforeKeys: a pasteAndSubmit-shaped refusal, or null to proceed.
|
|
388
|
+
function heldBeforeKeys(screen, target) {
|
|
389
|
+
const box = readReadyBox(screen);
|
|
390
|
+
const refusal = refusalBeforeKeys(box);
|
|
391
|
+
if (!refusal) return null;
|
|
392
|
+
return {
|
|
393
|
+
submitted: false,
|
|
394
|
+
verified: !box.unreadable, // true: we DID read the screen, and it is not a ready input box
|
|
395
|
+
pasted: false,
|
|
396
|
+
held: true,
|
|
397
|
+
attempts: 0,
|
|
398
|
+
reason: `held, nothing pasted into pane '${target}': ${refusal}`,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Did the typed payload land IN the box (and not in whatever else might have taken the keys)?
|
|
403
|
+
export function boxHoldsPayload(box, payload) {
|
|
404
|
+
if (!box.ready || box.unguarded || !box.draft) return false;
|
|
405
|
+
if (isPasteChip(box.draft)) return true;
|
|
406
|
+
const needle = squash(payload);
|
|
407
|
+
if (!needle) return false;
|
|
408
|
+
const head = needle.slice(0, 32);
|
|
409
|
+
return box.draft.includes(head) || (needle.startsWith(box.draft) && box.draft.length >= 8);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// After Enter: the box is back and EMPTY, and the transcript's LAST user-message echo is this
|
|
413
|
+
// payload and is NEW. An empty box alone proves nothing: a dialog that ate the Enter and closed
|
|
414
|
+
// leaves exactly that.
|
|
415
|
+
//
|
|
416
|
+
// THE ECHO SHAPE, measured on a live Claude Code v2.1.273 pane that the tmux pusher had delivered
|
|
417
|
+
// into: a submitted message is drawn as `❯ <first line>` at column 0, its further lines indented
|
|
418
|
+
// beneath, then a blank line; a message queued behind a running turn is drawn ` ❯ <first line>`
|
|
419
|
+
// just above the box. Bus renders often share a first line ("[agent-coord] +1 routine …"), so
|
|
420
|
+
// "a line carrying the head appeared" cannot be a set lookup: an older identical echo would
|
|
421
|
+
// satisfy it. The whole echo block (the echo and its lines up to the first blank) must differ from
|
|
422
|
+
// the one the screen showed before any key was sent. Spinner and status lines never enter it.
|
|
423
|
+
const ECHO_RE = /^\s{0,2}❯ /;
|
|
424
|
+
function lastEchoBlock(above) {
|
|
425
|
+
const lines = above ?? [];
|
|
426
|
+
let i = lines.length - 1;
|
|
427
|
+
while (i >= 0 && !ECHO_RE.test(lines[i])) i--;
|
|
428
|
+
if (i < 0) return null;
|
|
429
|
+
const block = [lines[i]];
|
|
430
|
+
for (let j = i + 1; j < lines.length && lines[j].trim() !== "" && /^\s/.test(lines[j]); j++) block.push(lines[j]);
|
|
431
|
+
return block.map((l) => squash(l)).join("\n");
|
|
432
|
+
}
|
|
433
|
+
export function transcriptGained(before, after, payload) {
|
|
434
|
+
if (!after.ready || after.unguarded || after.draft) return false;
|
|
435
|
+
const now = lastEchoBlock(after.above);
|
|
436
|
+
if (now === null) return false;
|
|
437
|
+
const first = now.split("\n")[0];
|
|
438
|
+
const line = squash(String(payload ?? "").split("\n").find((l) => l.trim()) ?? "");
|
|
439
|
+
const head = line.slice(0, 24);
|
|
440
|
+
// A short payload (a control command such as /clear) must match the echo exactly, not as a substring.
|
|
441
|
+
const matches = head.length >= 8 ? first.includes(head) : line !== "" && squash(first.replace(/^❯/, "")) === line;
|
|
442
|
+
if (!PASTE_CHIP_RE.test(first) && !matches) return false;
|
|
443
|
+
return now !== lastEchoBlock(before.above);
|
|
444
|
+
}
|
|
445
|
+
|
|
284
446
|
// Submit a CONTROL command (/clear, /compact, /reload-skills) — the path that must actually
|
|
285
447
|
// run, not merely arrive.
|
|
286
448
|
//
|
|
@@ -309,11 +471,18 @@ export async function submitControl(deps, payload) {
|
|
|
309
471
|
|
|
310
472
|
const idleBudget = CONTROL_IDLE_WAIT_MS();
|
|
311
473
|
const deadline = Date.now() + idleBudget;
|
|
312
|
-
let
|
|
474
|
+
let screen = capture();
|
|
475
|
+
let state = readPaneState(screen);
|
|
313
476
|
while (state.busy === true && idleBudget > 0 && Date.now() < deadline) {
|
|
314
477
|
await sleep(VERIFY_POLL_MS() * 5);
|
|
315
|
-
|
|
478
|
+
screen = capture();
|
|
479
|
+
state = readPaneState(screen);
|
|
316
480
|
}
|
|
481
|
+
// ⟨q-15d763dc⟩ No key before the ready input box is identified, read from the SAME screen the busy
|
|
482
|
+
// and draft guards below answer from. A dialog or picker row also matches PROMPT_PATTERN, so
|
|
483
|
+
// without this it would read as a draft at best, and as an empty input at worst.
|
|
484
|
+
const box = readReadyBox(screen);
|
|
485
|
+
if (!box.ready) return heldBeforeKeys(screen, target);
|
|
317
486
|
if (state.busy === true) {
|
|
318
487
|
return {
|
|
319
488
|
submitted: false,
|
|
@@ -347,7 +516,9 @@ export async function submitControl(deps, payload) {
|
|
|
347
516
|
`Styled input line: ${JSON.stringify(String(state.styledInputLine ?? "").slice(0, 160))}`,
|
|
348
517
|
};
|
|
349
518
|
}
|
|
350
|
-
|
|
519
|
+
const below = refusalBeforeKeys(box);
|
|
520
|
+
if (below) return heldBeforeKeys(screen, target);
|
|
521
|
+
return pasteAndSubmit(deps, payload, { bracketed: false, verify: true, screen });
|
|
351
522
|
}
|
|
352
523
|
|
|
353
524
|
// Paste a payload into the target pane and submit it.
|
|
@@ -365,10 +536,16 @@ export async function submitControl(deps, payload) {
|
|
|
365
536
|
// dropped or refused — no retry, no receipt of failure. Control commands
|
|
366
537
|
// already verified; peer traffic gets the same loop now. Pass
|
|
367
538
|
// `verify:false` explicitly to opt out (unknown-TUI / test escape hatch).
|
|
368
|
-
export async function pasteAndSubmit(deps, payload, { bracketed = false, verify } = {}) {
|
|
539
|
+
export async function pasteAndSubmit(deps, payload, { bracketed = false, verify, screen } = {}) {
|
|
369
540
|
const { run, runStdin, target, buffer } = deps;
|
|
370
541
|
const doVerify = verify === undefined ? bracketed : verify;
|
|
371
542
|
|
|
543
|
+
// ⟨q-15d763dc⟩ NOTHING IS PASTED until the screen is the ready, empty input box. The paste is held
|
|
544
|
+
// as firmly as the Enter: a digit pasted into an open dialog selects one of its options.
|
|
545
|
+
// `screen` is submitControl's own guard read, so a control command costs no second capture.
|
|
546
|
+
const held = heldBeforeKeys(screen ?? captureStyled(run, target), target);
|
|
547
|
+
if (held) return held;
|
|
548
|
+
|
|
372
549
|
await runStdin(["load-buffer", "-b", buffer, "-"], payload);
|
|
373
550
|
const paste = run(["paste-buffer", ...(bracketed ? ["-p"] : []), "-b", buffer, "-t", target, "-d"]);
|
|
374
551
|
if (paste.status !== 0) {
|
|
@@ -383,11 +560,24 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
|
|
|
383
560
|
// false — input line readable the whole time and the payload never
|
|
384
561
|
// appeared; refuse the Enter and say so (honest timeout, not a
|
|
385
562
|
// blind CR into someone's session).
|
|
386
|
-
// null — no readable input line (
|
|
387
|
-
//
|
|
563
|
+
// null — no readable input line (capture failed, or the box went away
|
|
564
|
+
// after the paste). ⟨q-15d763dc⟩ This used to fall back to a blind
|
|
565
|
+
// Enter ("unknown never blocks delivery"). Unreadable now means HELD:
|
|
566
|
+
// an Enter nobody looked before is the Enter that lands in a dialog.
|
|
388
567
|
const floor = sleep(ENTER_DELAY_MS());
|
|
389
568
|
const landed = await pollUntilLanded(deps, payload);
|
|
390
569
|
await floor;
|
|
570
|
+
if (landed === null) {
|
|
571
|
+
return {
|
|
572
|
+
submitted: false,
|
|
573
|
+
verified: false,
|
|
574
|
+
pasted: true,
|
|
575
|
+
attempts: 0,
|
|
576
|
+
reason:
|
|
577
|
+
`pasted into pane '${target}', but its input line could not be read afterwards (capture failed, or no line matched AGENT_COORD_PROMPT_PATTERN) — Enter NOT sent ` +
|
|
578
|
+
`(an unreadable screen is held, never answered with a blind Enter; check the pane for a stranded draft)`,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
391
581
|
if (landed === false) {
|
|
392
582
|
return {
|
|
393
583
|
submitted: false,
|
|
@@ -399,17 +589,34 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
|
|
|
399
589
|
`and check the pane for a stranded draft before resending)`,
|
|
400
590
|
};
|
|
401
591
|
}
|
|
592
|
+
// ⟨q-15d763dc⟩ ONE Enter, sent right after the landing read saw this payload in the input. The
|
|
593
|
+
// unconditional second Enter that used to follow it (150 ms later, no screen read) is gone: it
|
|
594
|
+
// landed in whatever opened after the first (qa's FAIL on #364). A menu that swallows the first
|
|
595
|
+
// Enter is answered by the retry below, which looks at the screen before every key.
|
|
402
596
|
const e1 = run(["send-keys", "-t", target, "Enter"]);
|
|
403
597
|
if (e1.status !== 0) throw new Error(`tmux send-keys: ${String(e1.stderr ?? "").trim()}`);
|
|
404
|
-
await sleep(ENTER_GAP_MS());
|
|
405
|
-
run(["send-keys", "-t", target, "Enter"]);
|
|
406
598
|
|
|
407
599
|
if (!doVerify) return { submitted: true, verified: false, attempts: 1 };
|
|
408
600
|
|
|
409
601
|
const retries = ENTER_RETRIES();
|
|
410
602
|
for (let attempt = 1; ; attempt++) {
|
|
411
603
|
const outcome = await pollUntilGone(deps, payload);
|
|
412
|
-
if (outcome === false)
|
|
604
|
+
if (outcome === false) {
|
|
605
|
+
// "The payload left the input" is only a submit if the screen is still the ready box. A
|
|
606
|
+
// picker's `❯ 2. …` row also matches PROMPT_PATTERN and reads as an emptied input.
|
|
607
|
+
const after = readReadyBox(captureStyled(run, target));
|
|
608
|
+
if (!after.ready || (!after.unguarded && after.draft)) {
|
|
609
|
+
return {
|
|
610
|
+
submitted: false,
|
|
611
|
+
verified: false,
|
|
612
|
+
attempts: attempt,
|
|
613
|
+
reason:
|
|
614
|
+
`Enter sent to pane '${target}', but the screen after it is not the ready input box ` +
|
|
615
|
+
`(${after.ready ? "the box holds other text" : after.reason}) — a dialog may have taken the Enter; not verified`,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
return { submitted: true, verified: true, attempts: attempt };
|
|
619
|
+
}
|
|
413
620
|
if (outcome === null) {
|
|
414
621
|
return {
|
|
415
622
|
submitted: false,
|
|
@@ -440,6 +647,17 @@ export async function pasteAndSubmit(deps, payload, { bracketed = false, verify
|
|
|
440
647
|
// since. Under the old tail-window check this fired on EVERY successful
|
|
441
648
|
// submit — three Enters into a pane that had already run the command.
|
|
442
649
|
await sleep(ENTER_GAP_MS());
|
|
650
|
+
// The loop head read the input; the retry Enter also needs the screen to still BE the ready box
|
|
651
|
+
// holding this payload, or it is the key that lands in a dialog.
|
|
652
|
+
const box = readReadyBox(captureStyled(run, target));
|
|
653
|
+
if (!box.unguarded && !boxHoldsPayload(box, payload)) {
|
|
654
|
+
return {
|
|
655
|
+
submitted: false,
|
|
656
|
+
verified: false,
|
|
657
|
+
attempts: attempt,
|
|
658
|
+
reason: `retry Enter NOT sent to pane '${target}': the screen is no longer the ready box holding the command (${box.ready ? "the box changed" : box.reason})`,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
443
661
|
run(["send-keys", "-t", target, "Enter"]);
|
|
444
662
|
}
|
|
445
663
|
}
|
package/hooks/tier.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { isGateRunner } from "./roles.mjs";
|
|
13
13
|
import { replayMarker } from "./replay.mjs";
|
|
14
|
+
import { neutralizeControls } from "./control-bytes.mjs";
|
|
14
15
|
|
|
15
16
|
// Typed protocol record → tier (Phase 8). The prefix table below is the same
|
|
16
17
|
// vocabulary parsed out of text; reading the field instead removes the parse
|
|
@@ -222,7 +223,12 @@ export function injectLine(m) {
|
|
|
222
223
|
// is still `[tag HH:MM from] ` and the marker is part of the TEXT, which is
|
|
223
224
|
// correct — it is something the reader must see, not metadata about routing.
|
|
224
225
|
text = `${replayMarker(m.replay)}${text}`;
|
|
225
|
-
|
|
226
|
+
// ⟨q-e5cb3538⟩ — LAST, over the WHOLE line, so every field that reaches a pane is covered
|
|
227
|
+
// (tag, from, text, the record type in the handle) on every push path that renders here:
|
|
228
|
+
// herdr delivery, the tmux pusher and the remote pusher. A control byte in content is a
|
|
229
|
+
// keystroke in the recipient's session; it becomes a visible escape instead. Content line
|
|
230
|
+
// feeds are kept — see hooks/control-bytes.mjs for why, and for the byte class.
|
|
231
|
+
return neutralizeControls(` [${tag} ${hhmm} ${m.from}] ${text}`);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
// Which room is THIS agent's project room, derived from the bus naming
|
package/hooks/tmux-pusher.mjs
CHANGED
|
@@ -103,7 +103,7 @@ import { isCi } from "./roles.mjs";
|
|
|
103
103
|
import { mergeTransportMarker } from "./marker.mjs";
|
|
104
104
|
import { readPushCursor, writePushCursor } from "./push-cursor.mjs";
|
|
105
105
|
import { priorDeliveries, replayInfo } from "./replay.mjs";
|
|
106
|
-
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "./submit.mjs";
|
|
106
|
+
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl, readyProfileStartupLine } from "./submit.mjs";
|
|
107
107
|
|
|
108
108
|
const AGENT_ID = process.env.AGENT_COORD_ID;
|
|
109
109
|
const TMUX_TARGET = process.env.AGENT_COORD_TMUX_TARGET;
|
|
@@ -768,6 +768,10 @@ function submitControlCommand(payload) {
|
|
|
768
768
|
|
|
769
769
|
// Publish transport marker so list_agents can show this agent is push-capable.
|
|
770
770
|
writeTransportMarker();
|
|
771
|
+
{
|
|
772
|
+
const guardLine = readyProfileStartupLine("tmux-pusher");
|
|
773
|
+
if (guardLine) process.stderr.write(guardLine);
|
|
774
|
+
}
|
|
771
775
|
let markerCleaned = false;
|
|
772
776
|
const cleanupMarker = () => {
|
|
773
777
|
if (markerCleaned) return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.25",
|
|
4
4
|
"description": "File-backed MCP server for coordinating multiple AI coding agents (Claude Code, Cursor, Cline, etc.). Local stdio or networked over Streamable HTTP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/scripts/coord-pusher.mjs
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
|
|
38
38
|
import { hostname } from "node:os";
|
|
39
39
|
import { spawn, spawnSync } from "node:child_process";
|
|
40
|
-
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl } from "../hooks/submit.mjs";
|
|
40
|
+
import { pasteAndSubmit as sharedPasteAndSubmit, submitControl as sharedSubmitControl, readyProfileStartupLine } from "../hooks/submit.mjs";
|
|
41
41
|
// The pane's parse-contract line, single-sourced — see the note above formatBatch.
|
|
42
42
|
import { injectLine } from "../hooks/tier.mjs";
|
|
43
43
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
@@ -58,6 +58,10 @@ const REFRESH_MS = parseInt(argv["refresh-ms"] ?? "30000", 10);
|
|
|
58
58
|
if (!SERVER) die("--server (or AGENT_COORD_SERVER) is required");
|
|
59
59
|
if (!TOKEN) die("--token (or AGENT_COORD_TOKEN) is required");
|
|
60
60
|
if (!AGENT_ID) die("--agent (or AGENT_COORD_ID) is required");
|
|
61
|
+
{
|
|
62
|
+
const guardLine = readyProfileStartupLine("coord-pusher");
|
|
63
|
+
if (guardLine) process.stderr.write(guardLine);
|
|
64
|
+
}
|
|
61
65
|
if (!TMUX_TARGET) die("--tmux (or AGENT_COORD_TMUX_TARGET) is required");
|
|
62
66
|
|
|
63
67
|
const SAFE_ID = AGENT_ID.replace(/[^a-zA-Z0-9._-]/g, "_");
|
package/src/capabilities.ts
CHANGED
|
@@ -33,25 +33,39 @@
|
|
|
33
33
|
* Probes call behaviour. The version travels beside the answer as CONTEXT and
|
|
34
34
|
* is labelled as such.
|
|
35
35
|
*/
|
|
36
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
37
|
+
import { tmpdir } from "node:os";
|
|
38
|
+
import path from "node:path";
|
|
36
39
|
import { prRefsIn } from "./tools/record-events.js";
|
|
37
40
|
import { EVENT_KIND_IDS } from "./tools/event-kinds.js";
|
|
38
41
|
import { suggestRecordType, typedRecordMode } from "./typed-records.js";
|
|
39
42
|
import { LEAD_REFUSED, PARKED_CATEGORIES } from "./tools/away.js";
|
|
40
43
|
import { recordAuthorityFor } from "./roles.js";
|
|
44
|
+
// ⟨q-e5cb3538⟩ The renderer and the byte class live in hooks/, shared with both pushers.
|
|
45
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
46
|
+
import { injectLine } from "../hooks/tier.mjs";
|
|
47
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
48
|
+
import { findControlByte } from "../hooks/control-bytes.mjs";
|
|
49
|
+
// ⟨q-15d763dc⟩ The ready-box guard's profile, as this process read it from its own env at start.
|
|
50
|
+
// @ts-expect-error — untyped .mjs sibling, deliberately not duplicated in TS
|
|
51
|
+
import { readyProfile } from "../hooks/submit.mjs";
|
|
41
52
|
import { subscriptionHealth } from "./tools/events.js";
|
|
42
53
|
import {
|
|
43
54
|
configuredTransport,
|
|
44
55
|
runningTransport,
|
|
45
56
|
isTmuxKind,
|
|
46
57
|
targetOf,
|
|
58
|
+
herdrMarkerPid,
|
|
47
59
|
type TransportKind,
|
|
48
60
|
} from "./transports/index.js";
|
|
49
|
-
import { readAllTransportMarkers } from "./tools/registry.js";
|
|
61
|
+
import { readAllTransportMarkers, markerHoldsLiveProcess } from "./tools/registry.js";
|
|
50
62
|
import { queueWriteSchema } from "./tools/queue-write.js";
|
|
51
63
|
import { treeProvenance } from "./tools/tree-provenance.js";
|
|
52
64
|
import { detectSpread } from "./server-spread.js";
|
|
65
|
+
import { answeringServerIdentity } from "./tools/registry.js";
|
|
53
66
|
import { HerdrTransport, herdrKeyName } from "./transports/herdr.js";
|
|
54
67
|
import { tickVerdict } from "./tools/tick.js";
|
|
68
|
+
import { ensureHerdrTail, herdrTailState, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
55
69
|
import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
|
|
56
70
|
|
|
57
71
|
export type ProbeResult = {
|
|
@@ -302,6 +316,39 @@ const PROBES: Probe[] = [
|
|
|
302
316
|
return { present, evidence: `"⛔ Blocked — was 🚧 …" in flight: ${blocked} (0.26.21 said true) · "🔍 In Review": ${review} -> present=${present}` };
|
|
303
317
|
},
|
|
304
318
|
},
|
|
319
|
+
{
|
|
320
|
+
// ⛔ A HERDR SEAT'S OWN SERVER TAILS ITS INBOX. The first version of this probe checked
|
|
321
|
+
// `typeof startHerdrTail === "function"` — which read PRESENT on six seats while the tail ran
|
|
322
|
+
// on none, the exact lie this verb exists to refuse. It now STARTS a tail against a scripted
|
|
323
|
+
// herdr transport and asserts the process registry reports it running, is idempotent on a
|
|
324
|
+
// second bind, and reports it gone after a stop — plus the line reader's byte offsets, which
|
|
325
|
+
// are what stop a later held message being skipped. Runtime truth for THIS seat is reported
|
|
326
|
+
// separately, under `herdrTail`, because a probe answers "can this build" and not "is it".
|
|
327
|
+
id: "herdr-inbox-tail",
|
|
328
|
+
since: "0.26.25",
|
|
329
|
+
run: () => {
|
|
330
|
+
const probeId = `__probe-herdr-tail-${process.pid}`;
|
|
331
|
+
const scripted = { kind: HERDR } as unknown as Parameters<typeof ensureHerdrTail>[1] extends infer O ? O extends { transport?: infer T } ? T : never : never;
|
|
332
|
+
const dir = mkdtempSync(path.join(tmpdir(), "probe-tail-"));
|
|
333
|
+
try {
|
|
334
|
+
const first = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
|
|
335
|
+
const again = ensureHerdrTail(probeId, { transport: scripted, pollMs: 3_600_000, tail: async () => ({ delivered: [], held: [] }) });
|
|
336
|
+
const seen = herdrTailState().some((s) => s.agentId === probeId && s.running);
|
|
337
|
+
const stopped = stopHerdrTail(probeId);
|
|
338
|
+
const gone = !herdrTailState().some((s) => s.agentId === probeId);
|
|
339
|
+
const f = path.join(dir, "inbox.jsonl");
|
|
340
|
+
const a = `${JSON.stringify({ id: "a" })}\n`;
|
|
341
|
+
writeFileSync(f, `${a}${JSON.stringify({ id: "b" })}\n{"id":"hal`);
|
|
342
|
+
const read = newMessagesIn(f, 0);
|
|
343
|
+
const offsets = read.map((m) => m.end).join(",") === `${Buffer.byteLength(a)},${Buffer.byteLength(a) + Buffer.byteLength(`${JSON.stringify({ id: "b" })}\n`)}`;
|
|
344
|
+
const present = first.started && !again.started && again.running && seen && stopped && gone && read.length === 2 && offsets;
|
|
345
|
+
return { present, evidence: `ensureHerdrTail started=${first.started} · second bind started=${again.started} running=${again.running} · registry running=${seen} · stop=${stopped} gone=${gone} · reader ${read.length} whole lines with exact end offsets=${offsets} -> present=${present}` };
|
|
346
|
+
} finally {
|
|
347
|
+
stopHerdrTail(probeId);
|
|
348
|
+
rmSync(dir, { recursive: true, force: true });
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
},
|
|
305
352
|
{
|
|
306
353
|
// ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
|
|
307
354
|
// decides what a reading MEANS (every probe here is synchronous, so the async read is
|
|
@@ -342,6 +389,72 @@ const PROBES: Probe[] = [
|
|
|
342
389
|
return { present, evidence: `HerdrTransport.kind=${scripted.kind} · scripted pane_not_found -> paneExists=${exists} · herdrKeyName("C-u").ok=${key.ok} -> present=${present}` };
|
|
343
390
|
},
|
|
344
391
|
},
|
|
392
|
+
{
|
|
393
|
+
// ⟨q-e5cb3538⟩ (0.26.25) — a control byte in message content never reaches a pane as a
|
|
394
|
+
// keystroke: the renderer every push path uses shows it as a visible escape, and a new send
|
|
395
|
+
// carrying one is refused at ingress. Measured on qa's payload (CR, ESC[201~, ETX) plus DEL
|
|
396
|
+
// and C1, rendered through THIS process's hooks — a seat whose server predates the fix answers
|
|
397
|
+
// false here, which is the question a release is delivered by.
|
|
398
|
+
id: "pane-render-neutralizes-controls",
|
|
399
|
+
since: "0.26.25",
|
|
400
|
+
run: () => {
|
|
401
|
+
const body = "X1\rX2\x1b[201~X3\x03X4\x7f\u0085";
|
|
402
|
+
const line = String(injectLine({ ts: 0, tag: "DM", from: "probe", text: body }));
|
|
403
|
+
const leaked = (line.match(/[\x00-\x09\x0b-\x1f\x7f\u0080-\u009f]/g) ?? []).length;
|
|
404
|
+
const visible = line.includes("X1\\rX2\\x1b[201~X3\\x03X4\\x7f\\u0085");
|
|
405
|
+
const refused = findControlByte({ text: body }) !== null && findControlByte({ text: "ok\n\tok" }) === null;
|
|
406
|
+
const present = leaked === 0 && visible && refused;
|
|
407
|
+
return { present, evidence: `rendered qa's payload + DEL + C1 -> leaked ${leaked} control byte(s) · escapes visible=${visible} · ingress refuses it and passes LF/TAB=${refused} -> present=${present}` };
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
// ⟨q-abd88dd4⟩ — a herdr marker's pid is chosen for the pre-herdr readers that cannot be
|
|
412
|
+
// patched: 1 only where this process may not signal pid 1 (EPERM), 0 otherwise, and this
|
|
413
|
+
// build never reads it as a process. Both functions are new in 0.26.25.
|
|
414
|
+
id: "herdr-marker-survives-pre-herdr-readers",
|
|
415
|
+
since: "0.26.25",
|
|
416
|
+
run: () => {
|
|
417
|
+
const eperm = () => { const e = new Error("EPERM") as NodeJS.ErrnoException; e.code = "EPERM"; throw e; };
|
|
418
|
+
const notSignalable = herdrMarkerPid(eperm).pid;
|
|
419
|
+
const signalable = herdrMarkerPid(() => true).pid;
|
|
420
|
+
const holds = markerHoldsLiveProcess({ agentId: "probe", transport: HERDR, pid: 1, since: 0 });
|
|
421
|
+
const present = notSignalable === 1 && signalable === 0 && holds === false;
|
|
422
|
+
return { present, evidence: `kill(1,0) EPERM -> pid ${notSignalable} · kill(1,0) permitted (root/container) -> pid ${signalable} · herdr marker pid 1 holds a live process: ${holds} -> present=${present}` };
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
// ⟨q-3cd5a77d⟩ — the result word counts only in the CLAIM position. A seat on an older server
|
|
427
|
+
// reads the #359 routing header (PASS in the PR title) as the coordinator gating it PASS.
|
|
428
|
+
id: "gate-claim-needs-claim-position",
|
|
429
|
+
since: "0.26.25",
|
|
430
|
+
run: () => {
|
|
431
|
+
const sha = "0826519e043add993c5d757908c1903239542160";
|
|
432
|
+
const line = (text: string) => JSON.stringify({ ts: 1, from: "probe", text, record: { type: "go", payload: {}, cites: [{ kind: "pr", ref: "#359" }] } });
|
|
433
|
+
const routing = gateClaimsIn(line(`GATE, prioritised: #359 (test-all prints PASS after FAILED) @ ${sha}`), "359", sha).length;
|
|
434
|
+
const typed = gateClaimsIn(line(`QA GATE — **PASS** @ \`${sha}\``), "359", sha).length;
|
|
435
|
+
const present = routing === 0 && typed === 1;
|
|
436
|
+
return { present, evidence: `routing header with PASS in the title -> ${routing} claim(s) · typed QA GATE — **PASS** @ sha -> ${typed} -> present=${present}` };
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
// ⟨q-18a719c5⟩ The spread can place a seat at all: the identity THIS server stamps names its
|
|
441
|
+
// module (resolved by package name, through the real caller in dist/tools/registry.js), a
|
|
442
|
+
// stamp whose pid is gone reads unknown, and unknowns block AGREED but not DIVERGED. Before
|
|
443
|
+
// 0.26.25 the module resolved to undefined on every server.
|
|
444
|
+
id: "server-spread-places-seats",
|
|
445
|
+
since: "0.26.25",
|
|
446
|
+
run: () => {
|
|
447
|
+
const me = answeringServerIdentity();
|
|
448
|
+
const installed = typeof me.serverModule === "string" ? { mtime: 1_000, module: me.serverModule } : null;
|
|
449
|
+
const seat = (agentId: string, startedAt: number, pid = 101) => ({ agentId, serverPid: pid, serverStartedAt: startedAt, serverModule: me.serverModule });
|
|
450
|
+
const alive = (pid: number) => pid !== 999;
|
|
451
|
+
const stale = installed ? detectSpread([seat("gone", 2_000, 999), seat("b", 3_000)], installed, { isRunning: alive }) : null;
|
|
452
|
+
const split = installed ? detectSpread([seat("old", 500), seat("new", 3_000), { agentId: "mute" }], installed, { isRunning: alive }) : null;
|
|
453
|
+
const staleUnknown = !!stale?.uncomparable.some((u) => u.agentId === "gone" && /no longer running/.test(u.why));
|
|
454
|
+
const present = typeof me.serverModule === "string" && staleUnknown && split?.state === "DIVERGED";
|
|
455
|
+
return { present, evidence: `module ${me.serverModule ?? "UNRESOLVED"} · dead stamp -> unknown: ${staleUnknown} · split + unstamped seat -> ${split?.state ?? "n/a"} -> present=${present}` };
|
|
456
|
+
},
|
|
457
|
+
},
|
|
345
458
|
{
|
|
346
459
|
id: "record-authority",
|
|
347
460
|
since: "0.24.0",
|
|
@@ -524,7 +637,7 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
|
|
|
524
637
|
|
|
525
638
|
import { resolveServerIdentity } from "./server-identity.js";
|
|
526
639
|
import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
|
|
527
|
-
import { isInFlightStatus } from "./tools/stall.js";
|
|
640
|
+
import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
|
|
528
641
|
import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
|
|
529
642
|
|
|
530
643
|
export const capabilitiesSchema = {} as const;
|
|
@@ -609,11 +722,24 @@ export async function seatBuilds(): Promise<{ installed: { module: string; hookP
|
|
|
609
722
|
};
|
|
610
723
|
}
|
|
611
724
|
|
|
725
|
+
/**
|
|
726
|
+
* ⟨q-15d763dc⟩ Is THIS process's pane push guarded? `guarded:false` means AGENT_COORD_READY_PROFILE=none
|
|
727
|
+
* was set in this server's env at start: its pushes type into panes without checking for Claude
|
|
728
|
+
* Code's input box. Stated here so an unguarded seat is visible fleet-wide, not only in a log.
|
|
729
|
+
*/
|
|
730
|
+
function readyBoxState(): { profile: string; guarded: boolean; source: string; warning?: string } {
|
|
731
|
+
const p = readyProfile() as { profile: string; raw: string | null; warning?: string };
|
|
732
|
+
return { profile: p.profile, guarded: p.profile !== "none", source: "AGENT_COORD_READY_PROFILE in this process's env at start (never a message or the bus dir)", ...(p.warning ? { warning: p.warning } : {}) };
|
|
733
|
+
}
|
|
734
|
+
|
|
612
735
|
export async function capabilitiesTool() {
|
|
613
736
|
const id = resolveServerIdentity();
|
|
614
737
|
const report = probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
615
738
|
try {
|
|
616
|
-
|
|
739
|
+
// ⭐ `herdrTail` is RUNTIME truth for this process — the tails actually ticking here — kept apart
|
|
740
|
+
// from the build probe on purpose, because "this build can tail" and "this seat is tailing"
|
|
741
|
+
// are different facts and the first version of #353 reported the first as the second.
|
|
742
|
+
return { ...report, transport: await probeTransport(), herdrTail: herdrTailState(), readyBox: readyBoxState(), seats: await seatBuilds() };
|
|
617
743
|
} catch (e) {
|
|
618
744
|
// A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
|
|
619
745
|
// refuses at startup by design, and this verb is exactly what an operator
|
package/src/gated-head.ts
CHANGED
|
@@ -233,8 +233,19 @@ export function gatedBy(verdicts: PassVerdict[], head: string, mergedAt: number
|
|
|
233
233
|
* so the experiment CONFIRMS the false mechanism — whoever ran it would conclude they had
|
|
234
234
|
* removed something load-bearing and got away with it. If you are removing it, the thing
|
|
235
235
|
* to check is `DECLARES`, not this.
|
|
236
|
+
*
|
|
237
|
+
* ⛔ ⟨q-3cd5a77d⟩ THE RESULT WORD MUST STAND IN THE CLAIM POSITION — right after the opener, past
|
|
238
|
+
* only separators (`:` `—` `–` `-`, spaces) and an optional `**`. The first version accepted PASS or
|
|
239
|
+
* FAIL ANYWHERE before the `@`, so a ROUTING header read as a verdict:
|
|
240
|
+
* `GATE, prioritised: #359 (⟨q-3605ede3⟩, test-all prints PASS after FAILED) @ 0826519e…`
|
|
241
|
+
* — the word was in the PR's TITLE. Bus history is permanent, so that one DM (11:19:43Z) turned
|
|
242
|
+
* `verdict-names-gater`'s live-population test red on every chain on the machine, and made
|
|
243
|
+
* `stall_check` report #359 as `reported-unscribed` with the COORDINATOR claiming PASS at its head.
|
|
244
|
+
* Measured before the change, old vs new over 126 real lines (the 61-line verdict corpus + every
|
|
245
|
+
* distinct line the old form matched on the live bus and in kit PR comments): they differ on
|
|
246
|
+
* exactly that routing line, and agree — result word and sha — on the other 125.
|
|
236
247
|
*/
|
|
237
|
-
export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[
|
|
248
|
+
export const VERDICT_COMMENT = /^(?:QA GATE|\*\*GATE|GATE)\b[\s:—–-]*(?:\*\*\s*)?(PASS|FAIL)\b[^\n]*?@\s*`?([0-9a-f]{7,40})`?/;
|
|
238
249
|
|
|
239
250
|
/**
|
|
240
251
|
* WHAT MAKES A VERDICT BINDING, expressed as a shape instead of a phrase: a
|