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/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,40 @@
|
|
|
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
|
-
import {
|
|
67
|
+
import { tickVerdict } from "./tools/tick.js";
|
|
68
|
+
import { ensureHerdrTail, herdrTailState, newMessagesIn, stopHerdrTail } from "./tools/herdr-tail.js";
|
|
69
|
+
import { HERDR, TICK_READS_AS, TICK_STORED_AS, type TickState } from "./transports/types.js";
|
|
55
70
|
|
|
56
71
|
export type ProbeResult = {
|
|
57
72
|
id: string;
|
|
@@ -237,6 +252,35 @@ const PROBES: Probe[] = [
|
|
|
237
252
|
};
|
|
238
253
|
},
|
|
239
254
|
},
|
|
255
|
+
{
|
|
256
|
+
// ⟨q-b607005a⟩ — a WRITER must not impose this queue's id grammar on a
|
|
257
|
+
// document that does not use it. Measured on a consumer fleet 2026-09-16: filing ONE item
|
|
258
|
+
// stamped `⟨q-…⟩` onto ALL 168 rows of a queue keyed by `[Q-nnn]` and broke
|
|
259
|
+
// two green guards. The property is not "it stamps" — stamping a queue that
|
|
260
|
+
// ALREADY records ids is correct and must not regress. It is that the
|
|
261
|
+
// document's own prior art decides, and the row THIS CALL authored is the
|
|
262
|
+
// exception that keeps the absorption defect closed.
|
|
263
|
+
id: "stamp-respects-foreign-grammar",
|
|
264
|
+
since: "0.26.24",
|
|
265
|
+
run: () => {
|
|
266
|
+
const head = ["---", 'title: "Queue"', "---", "", "## Queue", ""];
|
|
267
|
+
const foreign = parseWorkDoc(head.concat(["- [ ] (P1) [Q-001] a consumer's own convention"]).join("\n"));
|
|
268
|
+
const native = parseWorkDoc(
|
|
269
|
+
head.concat(["- [ ] (P1) ⟨q-11111111⟩ records its id", "- [ ] (P2) appended by hand"]).join("\n"),
|
|
270
|
+
);
|
|
271
|
+
const foreignOwn = queueItemsOf(foreign)[0]?.id ?? "";
|
|
272
|
+
const left = stampQueueIds(foreign).stamped.length;
|
|
273
|
+
const owned = stampQueueIds(foreign, { own: [foreignOwn] }).stamped.length;
|
|
274
|
+
const absorbed = stampQueueIds(native).stamped.length;
|
|
275
|
+
const present = left === 0 && owned === 1 && absorbed === 1;
|
|
276
|
+
return {
|
|
277
|
+
present,
|
|
278
|
+
evidence:
|
|
279
|
+
`foreign doc, not ours -> stamped ${left} · foreign doc, our own row -> stamped ${owned} · ` +
|
|
280
|
+
`doc that already records ids -> absorbed ${absorbed} -> present=${present}`,
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
},
|
|
240
284
|
{
|
|
241
285
|
// #293 — `detectSpread` did not exist before 0.26.22. The property is not
|
|
242
286
|
// "it answers": an UNREADABLE seat must poison the verdict rather than be
|
|
@@ -272,6 +316,61 @@ const PROBES: Probe[] = [
|
|
|
272
316
|
return { present, evidence: `"⛔ Blocked — was 🚧 …" in flight: ${blocked} (0.26.21 said true) · "🔍 In Review": ${review} -> present=${present}` };
|
|
273
317
|
},
|
|
274
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
|
+
},
|
|
352
|
+
{
|
|
353
|
+
// ⟨q-1c95f7d4⟩ Phase 5.4 Task 5 — the external tick, probed by CALLING the code that
|
|
354
|
+
// decides what a reading MEANS (every probe here is synchronous, so the async read is
|
|
355
|
+
// exercised by its test suite and this asserts the decision layer plus the wiring):
|
|
356
|
+
// a `blocked` reading is a measured HIT, an `idle` one is coverage and NOT a hit, an
|
|
357
|
+
// unreadable one is neither, and herdr's measured write/read asymmetry is in place.
|
|
358
|
+
id: "external-tick-evidence",
|
|
359
|
+
since: "0.26.24",
|
|
360
|
+
run: () => {
|
|
361
|
+
const seat = (state: TickState) => ({ agentId: "probe", transport: HERDR, readable: true as const, state, source: "herdr pane w0:p0" });
|
|
362
|
+
const blocked = tickVerdict(seat("blocked"));
|
|
363
|
+
const idle = tickVerdict(seat("idle"));
|
|
364
|
+
const blind = tickVerdict({ agentId: "probe", transport: HERDR, readable: false as const, why: "no agent there" });
|
|
365
|
+
const wired = typeof new HerdrTransport().readTick === "function" && typeof new HerdrTransport().publishTick === "function";
|
|
366
|
+
const stored = TICK_STORED_AS.idle === "done" && TICK_READS_AS.done === "idle";
|
|
367
|
+
const present = blocked?.hit === true && idle?.hit === false && idle?.measured === true && blind?.measured === false && wired && stored;
|
|
368
|
+
return {
|
|
369
|
+
present,
|
|
370
|
+
evidence: `tickVerdict blocked -> hit=${blocked?.hit} · idle -> hit=${idle?.hit} measured=${idle?.measured} · unreadable -> measured=${blind?.measured} · readTick/publishTick wired=${wired} · TICK_STORED_AS.idle="${TICK_STORED_AS.idle}" -> present=${present}`,
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
},
|
|
275
374
|
{
|
|
276
375
|
// Phase 5.4 Task 4 (0.26.23) — the herdr transport exists and refuses by name: a scripted
|
|
277
376
|
// dead pane reads dead from herdr's own reply, and a tmux key name is refused, not typed.
|
|
@@ -290,6 +389,72 @@ const PROBES: Probe[] = [
|
|
|
290
389
|
return { present, evidence: `HerdrTransport.kind=${scripted.kind} · scripted pane_not_found -> paneExists=${exists} · herdrKeyName("C-u").ok=${key.ok} -> present=${present}` };
|
|
291
390
|
},
|
|
292
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
|
+
},
|
|
293
458
|
{
|
|
294
459
|
id: "record-authority",
|
|
295
460
|
since: "0.24.0",
|
|
@@ -472,7 +637,8 @@ export function probeCapabilities(context: { module: string; versionLabel: strin
|
|
|
472
637
|
|
|
473
638
|
import { resolveServerIdentity } from "./server-identity.js";
|
|
474
639
|
import { seatBuildOf, installedFrom, psReader, type SeatBuild } from "./tools/seat-build.js";
|
|
475
|
-
import { isInFlightStatus } from "./tools/stall.js";
|
|
640
|
+
import { isInFlightStatus, gateClaimsIn } from "./tools/stall.js";
|
|
641
|
+
import { parseWorkDoc, queueItemsOf, stampQueueIds } from "@davidbalzan/groundwork-seam";
|
|
476
642
|
|
|
477
643
|
export const capabilitiesSchema = {} as const;
|
|
478
644
|
|
|
@@ -556,11 +722,24 @@ export async function seatBuilds(): Promise<{ installed: { module: string; hookP
|
|
|
556
722
|
};
|
|
557
723
|
}
|
|
558
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
|
+
|
|
559
735
|
export async function capabilitiesTool() {
|
|
560
736
|
const id = resolveServerIdentity();
|
|
561
737
|
const report = probeCapabilities({ module: id.path, versionLabel: id.version });
|
|
562
738
|
try {
|
|
563
|
-
|
|
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() };
|
|
564
743
|
} catch (e) {
|
|
565
744
|
// A THROWN TRANSPORT PROBE IS NOT A BROKEN VERB. An unknown configured value
|
|
566
745
|
// refuses at startup by design, and this verb is exactly what an operator
|