agent-coord-mcp 0.26.24 → 0.26.26
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/README.md +31 -70
- package/dist/capabilities.js +169 -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 +53 -4
- 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 +243 -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 +17 -148
- package/dist/tools/transport.js.map +1 -1
- package/dist/transports/config.js +22 -11
- package/dist/transports/config.js.map +1 -1
- package/dist/transports/herdr.js +134 -18
- package/dist/transports/herdr.js.map +1 -1
- package/dist/transports/index.js +4 -3
- package/dist/transports/index.js.map +1 -1
- package/dist/transports/tmux.js +5 -2
- package/dist/transports/tmux.js.map +1 -1
- package/dist/transports/types.js +18 -3
- 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/package.json +4 -2
- package/scripts/check-global-mcp-fallback.mjs +85 -0
- package/scripts/coord-pusher.mjs +5 -1
- package/scripts/coord-seat.mjs +96 -0
- package/scripts/coord-token.mjs +79 -4
- package/scripts/stop-agent.sh +8 -4
- package/src/capabilities.ts +170 -5
- package/src/gated-head.ts +12 -1
- package/src/server-spread.ts +52 -4
- package/src/server.ts +49 -3
- package/src/tools/herdr-delivery.ts +92 -24
- package/src/tools/herdr-tail.ts +276 -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 +17 -154
- package/src/transports/config.ts +25 -12
- package/src/transports/herdr.ts +193 -17
- package/src/transports/index.ts +4 -3
- package/src/transports/tmux.ts +5 -2
- package/src/transports/types.ts +24 -4
- package/hooks/tmux-pusher.mjs +0 -967
- package/scripts/spawn-agent.sh +0 -94
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/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-coord-mcp",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.26",
|
|
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": {
|
|
7
7
|
"agent-coord-mcp": "dist/server.js",
|
|
8
8
|
"coord-chat": "scripts/coord-chat.mjs",
|
|
9
|
-
"coord-pusher": "scripts/coord-pusher.mjs"
|
|
9
|
+
"coord-pusher": "scripts/coord-pusher.mjs",
|
|
10
|
+
"coord-token": "scripts/coord-token.mjs",
|
|
11
|
+
"coord-seat": "scripts/coord-seat.mjs"
|
|
10
12
|
},
|
|
11
13
|
"scripts": {
|
|
12
14
|
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ⟨q-8c23e5a3⟩ acceptance (c) — a GLOBAL `agent-coord` MCP entry must never be a working
|
|
3
|
+
// fallback. Claude Code resolves `.mcp.json` at project scope first, but falls back to a
|
|
4
|
+
// user/global scope config when a project has none of its own — so a seat launched from a
|
|
5
|
+
// project that forgot `--emit-mcp-config` does not fail loudly, it comes up healthy,
|
|
6
|
+
// wearing whatever identity the global entry's token resolves to. That is the SAME class as
|
|
7
|
+
// ⟨q-e439e4ad⟩ (an omitted, seat-specific fact silently answered by someone else's context)
|
|
8
|
+
// one layer up, at config-resolution time instead of attach time.
|
|
9
|
+
//
|
|
10
|
+
// This is a DOCTOR, not a gate link: it inspects files outside this repo (the operator's
|
|
11
|
+
// global Claude config), so its population is one machine's state, not this tree's — the
|
|
12
|
+
// same distinction check-doc-links.mjs draws for its external file:// links. Run it by hand
|
|
13
|
+
// after any global MCP config change: `node packages/coord-mcp/scripts/check-global-mcp-fallback.mjs`.
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
/** Every location Claude Code can resolve a GLOBAL (non-project) `mcpServers` map from. */
|
|
19
|
+
export function globalMcpConfigPaths(home = homedir()) {
|
|
20
|
+
return [path.join(home, ".claude", ".mcp.json"), path.join(home, ".claude", ".claude.json")];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read one config file's `mcpServers` map, or `null` if absent/unreadable-as-JSON. */
|
|
24
|
+
function mcpServersOf(file) {
|
|
25
|
+
if (!existsSync(file)) return null;
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return null; // unreadable is not this check's population — a parse-error gate exists elsewhere if needed
|
|
31
|
+
}
|
|
32
|
+
const servers = parsed?.mcpServers;
|
|
33
|
+
return servers && typeof servers === "object" && !Array.isArray(servers) ? servers : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Is a global `agent-coord` entry a WORKING fallback? "Working" means a seat that
|
|
38
|
+
* inherits it could actually authenticate as someone: an http/https url, plus either a
|
|
39
|
+
* non-empty bearer header or (advisory mode) no header at all — a url with an EMPTY or
|
|
40
|
+
* absent Authorization header, or no url, is not something a stray seat could complete a
|
|
41
|
+
* handshake through.
|
|
42
|
+
*/
|
|
43
|
+
export function isWorkingFallback(entry) {
|
|
44
|
+
if (!entry || typeof entry !== "object") return false;
|
|
45
|
+
const url = entry.url;
|
|
46
|
+
if (typeof url !== "string" || !/^https?:\/\//.test(url)) return false;
|
|
47
|
+
const auth = entry.headers?.Authorization;
|
|
48
|
+
if (typeof auth === "string" && /^Bearer\s+\S+/.test(auth)) return true;
|
|
49
|
+
// No header at all is advisory-mode shaped — still a working fallback IF a shared
|
|
50
|
+
// bearer is expected server-side, which this file cannot see. Treat "has a real url,
|
|
51
|
+
// no header" as working too: the absence of evidence it's inert is not evidence it is.
|
|
52
|
+
return auth === undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every global config path carrying a live `agent-coord` entry, with the reason. */
|
|
56
|
+
export function findLiveGlobalFallbacks(home = homedir()) {
|
|
57
|
+
const hits = [];
|
|
58
|
+
for (const file of globalMcpConfigPaths(home)) {
|
|
59
|
+
const servers = mcpServersOf(file);
|
|
60
|
+
const entry = servers?.["agent-coord"];
|
|
61
|
+
if (entry && isWorkingFallback(entry)) hits.push({ file, entry });
|
|
62
|
+
}
|
|
63
|
+
return hits;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function main() {
|
|
67
|
+
const hits = findLiveGlobalFallbacks();
|
|
68
|
+
if (hits.length === 0) {
|
|
69
|
+
console.log("OK: no global `agent-coord` MCP entry is a working fallback on this machine.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
console.error(
|
|
73
|
+
`⛔ ${hits.length} global MCP config(s) carry a WORKING \`agent-coord\` fallback — a seat ` +
|
|
74
|
+
`launched from a project with no per-seat .mcp.json (⟨q-8c23e5a3⟩'s --emit-mcp-config) ` +
|
|
75
|
+
`would inherit this identity silently instead of refusing at start:`,
|
|
76
|
+
);
|
|
77
|
+
for (const h of hits) console.error(` ${h.file} -> mcpServers.agent-coord: ${JSON.stringify(h.entry)}`);
|
|
78
|
+
console.error(
|
|
79
|
+
" Fix: remove the `agent-coord` key from each file above, or point its `url` at nothing " +
|
|
80
|
+
"reachable and its `headers.Authorization` at an empty/no token.",
|
|
81
|
+
);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
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, "_");
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// coord-seat — ⟨q-8c23e5a3⟩ acceptance (d): the FIVE manual steps of putting one agent
|
|
3
|
+
// on the HTTP bus, composed into one command. Registering a seat used to mean: (1) mint a
|
|
4
|
+
// token, (2) hand-write a per-project .mcp.json, (3) remember --mcp-config or silently
|
|
5
|
+
// inherit whatever the global entry answers to, (4) find/create a pane and pass it to
|
|
6
|
+
// attach_agent by hand (⟨q-e439e4ad⟩, now refused rather than misattached), (5) know which
|
|
7
|
+
// role-card skill to hand the seat. Two of those five failed SILENTLY into the wrong
|
|
8
|
+
// identity or the wrong terminal. This is the composite default path; the five steps
|
|
9
|
+
// above remain available separately for anyone who wants to do one by hand.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// coord-seat <agent-id> --server <url> --dir <project-dir> --role <roleId> \
|
|
13
|
+
// [--pane <target>] [--project <name>] [--cmd claude] [--card <skill-name>]
|
|
14
|
+
//
|
|
15
|
+
// --pane is OPTIONAL here only: if omitted, coord-seat tries `herdr pane new` to create one
|
|
16
|
+
// and use its id. If herdr cannot create a pane (binary absent, or no such subcommand on
|
|
17
|
+
// this herdr version), it REFUSES rather than falling back to an omitted target — the one
|
|
18
|
+
// thing this whole queue item exists to stop.
|
|
19
|
+
//
|
|
20
|
+
// ⛔ ALL ARGV PARSING LIVES INSIDE main(), NOT AT MODULE SCOPE: this file is imported
|
|
21
|
+
// directly by its own tests to exercise resolvePane()/main() as functions, and top-level
|
|
22
|
+
// code that reads process.argv would run (and die() on a bare `node --test`'s own argv)
|
|
23
|
+
// the instant the import happened — the same class of defect this queue item is about,
|
|
24
|
+
// just at import time instead of attach time.
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
|
|
29
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const COORD_TOKEN = path.join(HERE, "coord-token.mjs");
|
|
31
|
+
|
|
32
|
+
function die(msg) {
|
|
33
|
+
process.stderr.write(`coord-seat: ${msg}\n`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Try to mint a fresh pane through herdr. `null` if herdr can't (absent, or refuses). */
|
|
38
|
+
function tryHerdrPaneNew(run = (args) => spawnSync("herdr", args, { encoding: "utf8" })) {
|
|
39
|
+
const r = run(["pane", "new"]);
|
|
40
|
+
if (r.error || r.status !== 0) return null;
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = JSON.parse(r.stdout);
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return parsed?.result?.pane?.pane_id ?? parsed?.pane_id ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolvePane(explicitPane, run) {
|
|
51
|
+
if (explicitPane) return explicitPane;
|
|
52
|
+
const minted = tryHerdrPaneNew(run);
|
|
53
|
+
if (minted) return minted;
|
|
54
|
+
die(
|
|
55
|
+
"no --pane given and `herdr pane new` could not mint one (binary absent, or this herdr " +
|
|
56
|
+
"version has no such subcommand) — refusing rather than emitting a config with an " +
|
|
57
|
+
"omitted pane. Create a pane yourself and pass --pane <target>.",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function main(run = (args) => spawnSync("herdr", args, { encoding: "utf8" }), argv = process.argv.slice(2)) {
|
|
62
|
+
function opt(name, fallback) {
|
|
63
|
+
const i = argv.indexOf(name);
|
|
64
|
+
return i >= 0 ? argv[i + 1] : fallback;
|
|
65
|
+
}
|
|
66
|
+
const agentId = argv[0];
|
|
67
|
+
if (!agentId || agentId.startsWith("--")) die("usage: coord-seat <agent-id> --server <url> --dir <project-dir> --role <roleId> [--pane <target>]");
|
|
68
|
+
const server = opt("--server");
|
|
69
|
+
const dir = opt("--dir");
|
|
70
|
+
const role = opt("--role", "worker");
|
|
71
|
+
const project = opt("--project");
|
|
72
|
+
const cmd = opt("--cmd", "claude");
|
|
73
|
+
const card = opt("--card", `coord-${role}`);
|
|
74
|
+
if (!server) die("--server <url> is required");
|
|
75
|
+
if (!dir) die("--dir <project-dir> is required");
|
|
76
|
+
|
|
77
|
+
const pane = resolvePane(opt("--pane"), run);
|
|
78
|
+
|
|
79
|
+
const tokenArgs = ["add", agentId, "--emit-mcp-config", dir, "--server", server, "--pane", pane];
|
|
80
|
+
const minted = spawnSync(process.execPath, [COORD_TOKEN, ...tokenArgs], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
81
|
+
if (minted.status !== 0) die(`coord-token add failed:\n${minted.stderr}`);
|
|
82
|
+
process.stderr.write(minted.stderr);
|
|
83
|
+
|
|
84
|
+
const roleFlags = [`--id=${agentId}`, `--role=${role}`].concat(project ? [`--project=${project}`] : []);
|
|
85
|
+
console.error(
|
|
86
|
+
`\ncoord-seat '${agentId}' ready:\n` +
|
|
87
|
+
` 1. pane: ${pane}\n` +
|
|
88
|
+
` 2. config: ${dir}/.mcp.json (mode 600)\n` +
|
|
89
|
+
` 3. launch: cd ${dir} && ${cmd}\n` +
|
|
90
|
+
` 4. attach: attach_agent { agentId: "${agentId}", tmuxTarget: "${pane}", includeRoom: false }\n` +
|
|
91
|
+
` 5. hand off the role card: /${card} ${roleFlags.join(" ")}\n`,
|
|
92
|
+
);
|
|
93
|
+
return { pane, dir, agentId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
package/scripts/coord-token.mjs
CHANGED
|
@@ -7,6 +7,13 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Usage:
|
|
9
9
|
// scripts/coord-token.mjs add <agent-id> mint (or rotate) a token, print it
|
|
10
|
+
// [--emit-mcp-config <dir>] ALSO write <dir>/.mcp.json (mode 600)
|
|
11
|
+
// --server <url> required with --emit-mcp-config: the
|
|
12
|
+
// HTTP bus URL this seat's config points at
|
|
13
|
+
// --pane <target> required with --emit-mcp-config: the
|
|
14
|
+
// herdr/tmux pane THIS seat will run in —
|
|
15
|
+
// see ⟨q-8c23e5a3⟩ below for why this can't
|
|
16
|
+
// be optional
|
|
10
17
|
// scripts/coord-token.mjs list list agent ids (never prints tokens)
|
|
11
18
|
// scripts/coord-token.mjs revoke <agent-id> remove a token
|
|
12
19
|
// [--dir <coord-dir>] override AGENT_COORD_DIR
|
|
@@ -14,6 +21,15 @@
|
|
|
14
21
|
// tokens.json is created 0600 (and re-chmod'd on every write) so secrets are
|
|
15
22
|
// never group/other-readable. After add/revoke, SIGHUP the running bus to
|
|
16
23
|
// reload its token map (kill -HUP <bus-pid>).
|
|
24
|
+
//
|
|
25
|
+
// ⟨q-8c23e5a3⟩ --emit-mcp-config REQUIRES --pane, not optionally accepts it: the whole
|
|
26
|
+
// point is that a seat onboarded this way never has an OMITTED target to fall back from —
|
|
27
|
+
// ⟨q-e439e4ad⟩ made the omitted-target case refuse at attach time, and this makes the
|
|
28
|
+
// omission impossible one step earlier, at mint time, for every seat that goes through
|
|
29
|
+
// this path. The printed instructions carry the exact `attach_agent` call with the pane
|
|
30
|
+
// already filled in, because there is no server-side env var that means "this seat's pane"
|
|
31
|
+
// under the shared HTTP daemon — one process, every seat, so a per-seat fact has to travel
|
|
32
|
+
// in what the OPERATOR (or the agent's own first tool call) is handed, not in `process.env`.
|
|
17
33
|
|
|
18
34
|
import { randomBytes } from "node:crypto";
|
|
19
35
|
import {
|
|
@@ -69,6 +85,44 @@ function save(map) {
|
|
|
69
85
|
chmodSync(TOKENS_FILE, 0o600); // enforce even when the file pre-existed looser
|
|
70
86
|
}
|
|
71
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Write a project-local `.mcp.json` (⟨q-8c23e5a3⟩ acceptance (a)) whose `agent-coord`
|
|
90
|
+
* entry embeds this seat's own token in the `Authorization` header — never a shared
|
|
91
|
+
* default, so a project that gets its OWN config never depends on (or silently becomes)
|
|
92
|
+
* a global fallback. mode 600, same as tokens.json: a bearer token is a secret regardless
|
|
93
|
+
* of which file holds it. `pane` is recorded under `_agentCoordPane`, a key no MCP client
|
|
94
|
+
* reads or writes — it exists so a re-run of this script, or `coord-seat`, can recover the
|
|
95
|
+
* pane a config was minted for without re-asking the operator, and so a human reading the
|
|
96
|
+
* file sees the fact `attach_agent` will need, instead of it living only in scrollback.
|
|
97
|
+
*/
|
|
98
|
+
export function writeMcpConfig({ dir, agentId, server, token, pane }) {
|
|
99
|
+
mkdirSync(dir, { recursive: true });
|
|
100
|
+
const configPath = path.join(dir, ".mcp.json");
|
|
101
|
+
let existing = {};
|
|
102
|
+
if (existsSync(configPath)) {
|
|
103
|
+
try {
|
|
104
|
+
existing = JSON.parse(readFileSync(configPath, "utf8"));
|
|
105
|
+
} catch (e) {
|
|
106
|
+
die(`${configPath} exists and is not valid JSON (${e.message}) — refusing to overwrite blindly`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const config = {
|
|
110
|
+
...existing,
|
|
111
|
+
mcpServers: {
|
|
112
|
+
...(existing.mcpServers ?? {}),
|
|
113
|
+
"agent-coord": {
|
|
114
|
+
type: "http",
|
|
115
|
+
url: server,
|
|
116
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
117
|
+
...(pane ? { _agentCoordPane: pane } : {}),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
122
|
+
chmodSync(configPath, 0o600);
|
|
123
|
+
return configPath;
|
|
124
|
+
}
|
|
125
|
+
|
|
72
126
|
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
73
127
|
const cmd = positional[0];
|
|
74
128
|
const id = positional[1];
|
|
@@ -78,6 +132,17 @@ switch (cmd) {
|
|
|
78
132
|
if (!id || !AGENT_ID_RE.test(id)) {
|
|
79
133
|
die("usage: coord-token add <agent-id> (id: [a-zA-Z0-9._-], <=64 chars)");
|
|
80
134
|
}
|
|
135
|
+
const emitDir = opt("--emit-mcp-config");
|
|
136
|
+
const server = opt("--server");
|
|
137
|
+
const pane = opt("--pane");
|
|
138
|
+
if (emitDir && !server) die("--emit-mcp-config requires --server <url>");
|
|
139
|
+
if (emitDir && !pane) {
|
|
140
|
+
die(
|
|
141
|
+
"--emit-mcp-config requires --pane <target> — a config with no pane recorded is exactly " +
|
|
142
|
+
"the omitted-target case ⟨q-e439e4ad⟩ made attach_agent refuse; this refuses one step " +
|
|
143
|
+
"earlier instead of emitting a config that would hit that refusal on first use.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
81
146
|
const map = load();
|
|
82
147
|
const rotating = id in map;
|
|
83
148
|
const token = "tk_" + randomBytes(24).toString("base64url");
|
|
@@ -89,10 +154,20 @@ switch (cmd) {
|
|
|
89
154
|
process.stderr.write(
|
|
90
155
|
`${rotating ? "rotated" : "minted"} token for '${id}' in ${TOKENS_FILE}\n`,
|
|
91
156
|
);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
if (emitDir) {
|
|
158
|
+
const configPath = writeMcpConfig({ dir: emitDir, agentId: id, server, token, pane });
|
|
159
|
+
process.stderr.write(
|
|
160
|
+
`mcp config written: ${configPath} (mode 600)\n` +
|
|
161
|
+
`launch: cd ${emitDir} && claude\n` +
|
|
162
|
+
`first tool call — the pane is not optional, name it every time:\n` +
|
|
163
|
+
` attach_agent { agentId: "${id}", tmuxTarget: "${pane}", includeRoom: false }\n`,
|
|
164
|
+
);
|
|
165
|
+
} else {
|
|
166
|
+
process.stderr.write(
|
|
167
|
+
`SIGHUP the running bus to load it (kill -HUP <bus-pid>), then on the node:\n` +
|
|
168
|
+
` AGENT_COORD_TOKEN=${token} scripts/coord-node.sh --server <url> --id ${id} --cmd claude\n`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
96
171
|
process.stdout.write(token + "\n"); // token alone on stdout, pipe/capture-friendly
|
|
97
172
|
break;
|
|
98
173
|
}
|