moshcode 0.59.0 → 0.61.0

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/src/herd-cli.mjs CHANGED
@@ -13,13 +13,21 @@ import {
13
13
  herdDir, killSession, listSessions, paneIndex, readManifest, rememberSession, sendKeys, sendPrompt,
14
14
  slugifyName, startSession, stopRuntime, substrateNote, validName, NAME_RE,
15
15
  } from "./herd.mjs";
16
- import { clearReport, reportState, STATES, withState } from "./herd-state.mjs";
16
+ import { BLOCKED_KINDS, clearReport, inspectUserRules, reportState, STATES, withState } from "./herd-state.mjs";
17
17
  import { ENGINES, resolveEngine, resolveExecutable, agentLaunchArgs } from "./engines.mjs";
18
+ import {
19
+ endTask, findTask, ledgerSessions, openTask, readLog, readTasks, TERMINAL_STATES,
20
+ recordTransition, screenDelta, startTask, stats as taskStats,
21
+ } from "./herd-tasks.mjs";
18
22
  import { ingestApproval, pollApproval } from "./notify.mjs";
19
23
  import { acid, amber, ash, bone, danger, dim, err, info, ok, table, warn } from "./ui.mjs";
20
24
 
21
- /** Distinct exit codes, because `wait` exists to be branched on (R10). */
22
- export const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3 };
25
+ /**
26
+ * Distinct exit codes, because `wait` exists to be branched on (0009 R10), and
27
+ * because `eval` in CI has to tell "the agent got worse" apart from "the
28
+ * harness fell over" (0011 R13). One non-zero code cannot say both.
29
+ */
30
+ export const EXIT = { matched: 0, usage: 1, timeout: 2, gone: 3, below: 4, infra: 5 };
23
31
 
24
32
  const configFile = () => path.join(herdDir(), "config.json");
25
33
 
@@ -87,10 +95,19 @@ export function renderRoster(rows, { indent = " " } = {}) {
87
95
  bone(r.name),
88
96
  ash(String(r.engine)),
89
97
  paintState(r.state),
98
+ // A remote member's cwd is the host it answers on, set when it was added:
99
+ // "where is this thing" is the same question for both, and the answer is
100
+ // a directory for one and a hostname for the other.
90
101
  ash(tilde(r.cwd || "")),
91
- dim(humanAge(r.age)),
102
+ // A remote row has no age worth printing — it was registered, not
103
+ // started, and "3d" would read as three days of work.
104
+ dim(r.kind === "remote" ? "—" : humanAge(r.age)),
105
+ // Where the state came from (0011 R1, R11). This is the column that makes
106
+ // the hook install visible — and the one that stops a remote's claim from
107
+ // being mistaken for something this box verified.
108
+ dim(String(r.authority || "")),
92
109
  ]),
93
- { columns: ["name", "engine", "state", "cwd", "age"], header: false, indent: indent.length },
110
+ { columns: ["name", "engine", "state", "cwd", "age", "from"], header: false, indent: indent.length },
94
111
  );
95
112
  }
96
113
 
@@ -285,8 +302,13 @@ export function splitDetachArgs(args = []) {
285
302
  export function herdPs(argv, { write = console.log } = {}) {
286
303
  const rows = roster();
287
304
  if (argv.includes("--json")) {
288
- write(JSON.stringify(rows.map(({ name, engine, herd, state, authority, cwd, age, alive, attached, substrate }) => ({
289
- name, engine, herd, state, authority, cwd, ageMs: age, alive, attached, substrate,
305
+ write(JSON.stringify(rows.map(({ name, engine, herd, state, authority, blockedOn, kind, url, cwd, age, alive, attached, substrate }) => ({
306
+ name, engine, herd, state, authority, kind, ...(url ? { url } : {}),
307
+ // The blocked sub-kind (R4) rides here and not in the roster's own
308
+ // column: `--ask` needs to know whether a menu or a sentence is wanted,
309
+ // and a person glancing at six rows does not.
310
+ ...(blockedOn ? { blockedOn } : {}),
311
+ cwd, ageMs: age, alive, attached, substrate,
290
312
  })), null, 2));
291
313
  return EXIT.matched;
292
314
  }
@@ -361,12 +383,23 @@ export async function herdAttach(argv, { write = console.log } = {}) {
361
383
  return EXIT.matched;
362
384
  }
363
385
 
364
- export function herdKill(argv, { write = console.log } = {}) {
386
+ export async function herdKill(argv, { write = console.log } = {}) {
365
387
  const all = argv.includes("--all");
366
388
  const names = all ? roster().map((s) => s.name) : argv.filter((a) => !a.startsWith("-"));
367
389
  if (!names.length) { write(err("usage: moshcode kill <name> | --all")); return EXIT.usage; }
368
390
  let failed = 0;
369
391
  for (const name of names) {
392
+ // Killing a remote is deregistering it. There is no process of ours on the
393
+ // other end, and reaching across the network to end somebody else's agent
394
+ // because a local roster entry was removed would be a `kill` that does
395
+ // considerably more than it says.
396
+ if (isRemoteMember(name)) {
397
+ const remote = await import("./herd-remote.mjs");
398
+ const dropped = remote.removeRemote(name);
399
+ if (dropped.ok) write(ok(`${name} removed from the roster — the agent at the far end is untouched.`));
400
+ else { write(err(`${name}: ${dropped.error?.message}`)); failed++; }
401
+ continue;
402
+ }
370
403
  const result = killSession(name);
371
404
  clearReport(name);
372
405
  if (result.ok) write(ok(`${name} ended.`));
@@ -378,6 +411,11 @@ export function herdKill(argv, { write = console.log } = {}) {
378
411
  /**
379
412
  * Drop sessions the runtime no longer has. Only ever removes bookkeeping — a
380
413
  * `prune` that could end running work would be a `kill` with a friendlier name.
414
+ *
415
+ * The task ledger is deliberately NOT pruned with the session. "What did that
416
+ * agent do before the box rebooted" is the question the ledger exists for, and
417
+ * a prune is usually the moment someone starts asking it. Growth is bounded by
418
+ * the per-session cap in herd-tasks.mjs instead.
381
419
  */
382
420
  export function herdPrune(argv, { write = console.log } = {}) {
383
421
  const gone = roster().filter((s) => !s.alive);
@@ -398,6 +436,9 @@ export function herdRead(argv, { write = console.log } = {}) {
398
436
  }
399
437
  const name = positional[0];
400
438
  if (!name) { write(err("usage: moshcode herd read <name> [--lines N]")); return EXIT.usage; }
439
+ // A remote has no screen — what it has is the last thing it said, which is
440
+ // what `read` is for in both cases.
441
+ if (isRemoteMember(name)) return readRemoteMember(name, { json, write });
401
442
  const session = findSession(name);
402
443
  if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; }
403
444
  const screen = capture(name, { lines });
@@ -405,6 +446,24 @@ export function herdRead(argv, { write = console.log } = {}) {
405
446
  return EXIT.matched;
406
447
  }
407
448
 
449
+ async function readRemoteMember(name, { json, write }) {
450
+ const remote = await import("./herd-remote.mjs");
451
+ const entry = remote.remoteEntry(name);
452
+ if (!entry) { write(err(`no member named ${JSON.stringify(name)}`)); return EXIT.gone; }
453
+ const text = remote.readRemote(name);
454
+ const status = remote.remoteStatusOf(name);
455
+ if (json) {
456
+ write(JSON.stringify({ name, kind: "remote", url: entry.url, state: status?.state || "unknown", observedAt: status?.at || null, screen: text }, null, 2));
457
+ return EXIT.matched;
458
+ }
459
+ if (!text) {
460
+ write(info(`${name} has not answered anything yet — ${acid(`moshcode herd prompt ${name} "…"`)}`));
461
+ return EXIT.matched;
462
+ }
463
+ write(text);
464
+ return EXIT.matched;
465
+ }
466
+
408
467
  /**
409
468
  * Deliberately NOT unref'd.
410
469
  *
@@ -430,12 +489,20 @@ export async function waitFor(name, states, {
430
489
  intervalMs = 1000,
431
490
  now = () => Date.now(),
432
491
  look = (n) => findSession(n),
492
+ // Called with every state this poll observes that differs from the last one.
493
+ // The ledger (0011 R5) is written from here rather than from a second poller:
494
+ // this loop already sees every transition a task goes through, and a second
495
+ // one watching the same sessions would be twice the `capture-pane` for the
496
+ // same answer.
497
+ onState = null,
433
498
  } = {}) {
434
499
  const wanted = new Set(states);
435
500
  const deadline = now() + timeoutMs;
501
+ let seen;
436
502
  for (;;) {
437
503
  const session = look(name);
438
504
  if (!session) return { outcome: "gone", state: "gone" };
505
+ if (onState && session.state !== seen) { seen = session.state; onState(session.state, session); }
439
506
  if (wanted.has(session.state)) return { outcome: "matched", state: session.state };
440
507
  // A session that ended can never reach `blocked`; waiting the full timeout
441
508
  // for something impossible is a hang, not a wait.
@@ -456,24 +523,122 @@ function parseDuration(raw, fallback) {
456
523
  return { ms: n, s: n * 1000, m: n * 60000, h: n * 3600000 }[m[2] || "s"];
457
524
  }
458
525
 
526
+ /** Is this member a URL rather than a pty? Read straight from the manifest. */
527
+ export function isRemoteMember(name) {
528
+ return readManifest().sessions[name]?.kind === "remote";
529
+ }
530
+
531
+ /**
532
+ * Wait on one member, wherever it lives (0011 R12).
533
+ *
534
+ * A remote is polled by asking it, a local by looking at it, and the caller
535
+ * writes the same `if` either way — which is the whole claim R12 makes.
536
+ */
537
+ export async function waitMember(name, states, options = {}) {
538
+ if (!isRemoteMember(name)) return waitFor(name, states, options);
539
+ const remote = await import("./herd-remote.mjs");
540
+ return remote.waitRemote(name, states, options);
541
+ }
542
+
543
+ /**
544
+ * Wait on several members at once (0011 R8).
545
+ *
546
+ * `--any` returns on the first to arrive, `--all` when the last one has. Every
547
+ * fan-out script written against the herd so far ends with a hand-rolled loop
548
+ * doing one of these two things; this is that loop, once.
549
+ */
550
+ export async function waitForMany(names, states, {
551
+ mode = "any",
552
+ timeoutMs = 30 * 60 * 1000,
553
+ intervalMs = 1500,
554
+ now = () => Date.now(),
555
+ nap = sleep,
556
+ observe = observeMember,
557
+ } = {}) {
558
+ const wanted = new Set(states);
559
+ const deadline = now() + timeoutMs;
560
+ // ONE loop over all of them, rather than N waits raced against each other.
561
+ // A race leaves the losers polling a process that has already printed its
562
+ // answer, and their timers keep node alive — `wait --any` would return the
563
+ // right thing and then refuse to exit for half an hour.
564
+ const done = new Map();
565
+ for (;;) {
566
+ for (const name of names) {
567
+ if (done.has(name)) continue;
568
+ const seen = await observe(name);
569
+ if (!seen.present) { done.set(name, { name, outcome: "gone", state: "gone" }); continue; }
570
+ if (wanted.has(seen.state)) { done.set(name, { name, outcome: "matched", state: seen.state }); continue; }
571
+ if (seen.alive === false || seen.state === "done") done.set(name, { name, outcome: "ended", state: seen.state });
572
+ }
573
+ const results = [...done.values()];
574
+ const matched = results.filter((r) => r.outcome === "matched");
575
+ if (mode === "any" && matched.length) {
576
+ return { mode, outcome: "matched", winner: matched[0].name, first: matched[0], results };
577
+ }
578
+ if (done.size === names.length) {
579
+ if (mode === "all") {
580
+ const missed = results.find((r) => r.outcome !== "matched");
581
+ return { mode, outcome: missed ? missed.outcome : "matched", winner: null, results };
582
+ }
583
+ return { mode, outcome: results[0]?.outcome || "gone", winner: null, results };
584
+ }
585
+ if (now() >= deadline) {
586
+ return { mode, outcome: "timeout", winner: null, results, pending: names.filter((n) => !done.has(n)) };
587
+ }
588
+ await nap(intervalMs);
589
+ }
590
+ }
591
+
592
+ /** One member's state right now — a look for a local, a request for a remote. */
593
+ export async function observeMember(name) {
594
+ if (isRemoteMember(name)) {
595
+ const remote = await import("./herd-remote.mjs");
596
+ if (!remote.remoteEntry(name)) return { name, present: false };
597
+ const pinged = await remote.pingRemote(name).catch(() => null);
598
+ return { name, present: true, alive: true, state: pinged?.state || "unknown" };
599
+ }
600
+ const session = findSession(name);
601
+ return session
602
+ ? { name, present: true, alive: session.alive, state: session.state, blockedOn: session.blockedOn }
603
+ : { name, present: false };
604
+ }
605
+
459
606
  export async function herdWait(argv, { write = console.log } = {}) {
460
607
  const positional = [];
461
- let states = ["blocked", "done"], timeoutMs = 30 * 60 * 1000, json = false;
608
+ let states = ["blocked", "done"], timeoutMs = 30 * 60 * 1000, json = false, mode = null;
462
609
  for (let i = 0; i < argv.length; i++) {
463
610
  const a = argv[i];
464
611
  if (a === "--state") states = String(argv[++i] || "").split(",").filter(Boolean);
465
612
  else if (a.startsWith("--state=")) states = a.slice(8).split(",").filter(Boolean);
466
613
  else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs);
467
614
  else if (a.startsWith("--timeout=")) timeoutMs = parseDuration(a.slice(10), timeoutMs);
615
+ else if (a === "--any") mode = "any";
616
+ else if (a === "--all") mode = "all";
468
617
  else if (a === "--json") json = true;
469
618
  else if (!a.startsWith("-")) positional.push(a);
470
619
  }
471
- const name = positional[0];
472
- if (!name) { write(err("usage: moshcode wait <name> [--state blocked,done] [--timeout 30m]")); return EXIT.usage; }
620
+ if (!positional.length) {
621
+ write(err("usage: moshcode wait <name…> [--any|--all] [--state blocked,done] [--timeout 30m]"));
622
+ return EXIT.usage;
623
+ }
473
624
  const unknown = states.filter((s) => !STATES.includes(s));
474
625
  if (unknown.length) { write(err(`unknown state ${unknown[0]} — one of ${STATES.join(", ")}`)); return EXIT.usage; }
626
+ if (!mode && positional.length > 1) mode = "all"; // several names and no verb: join on all of them
627
+
628
+ if (mode) {
629
+ const result = await waitForMany(positional, states, { mode, timeoutMs });
630
+ if (json) write(JSON.stringify({ mode, ...result }, null, 2));
631
+ else if (result.outcome === "matched") {
632
+ write(mode === "any"
633
+ ? ok(`${result.winner} is ${result.first.state} first.`)
634
+ : ok(`all ${positional.length} reached ${states.join("/")}.`));
635
+ } else write(warn(`${mode === "any" ? "none of them" : "not all of them"} reached ${states.join("/")} (${result.outcome}).`));
636
+ if (result.outcome === "matched") return EXIT.matched;
637
+ return result.outcome === "timeout" ? EXIT.timeout : EXIT.gone;
638
+ }
475
639
 
476
- const result = await waitFor(name, states, { timeoutMs });
640
+ const name = positional[0];
641
+ const result = await waitMember(name, states, { timeoutMs, onState: ledgerRecorder(name) });
477
642
  if (json) write(JSON.stringify({ name, ...result }, null, 2));
478
643
  else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`));
479
644
  else if (result.outcome === "timeout") write(warn(`${name} is still ${result.state} after the timeout.`));
@@ -485,6 +650,22 @@ export async function herdWait(argv, { write = console.log } = {}) {
485
650
  return EXIT.gone;
486
651
  }
487
652
 
653
+ /**
654
+ * The ledger write a poll performs (0011 R5).
655
+ *
656
+ * Attributed to whichever task is open on that session, so a `wait` that
657
+ * happens to be running while an agent works fills in the history of the prompt
658
+ * that started it. With no open task the transition is still recorded, unbound
659
+ * — `herd log` and `herd stats` want the state history whether or not anyone
660
+ * submitted the work through the herd.
661
+ */
662
+ export function ledgerRecorder(name) {
663
+ return (state, session) => {
664
+ const open = openTask(name);
665
+ recordTransition(name, state, { id: open?.id || null, kind: session?.blockedOn || null });
666
+ };
667
+ }
668
+
488
669
  /**
489
670
  * Type a prompt into a running session, optionally waiting for it to land.
490
671
  *
@@ -508,21 +689,73 @@ export async function herdPrompt(argv, { write = console.log } = {}) {
508
689
  const [name, ...words] = positional;
509
690
  const text = words.join(" ");
510
691
  if (!name || !text) { write(err('usage: moshcode herd prompt <name> "<text>" [--wait]')); return EXIT.usage; }
692
+
693
+ // A remote member takes the same verb and the same flags (0011 R12). The
694
+ // whole point is that a fan-out script contains no `if (remote)`, so this is
695
+ // the one place that does.
696
+ if (isRemoteMember(name)) return promptRemoteMember(name, text, { wait, json, write });
697
+
511
698
  const session = findSession(name);
512
699
  if (!session?.alive) { write(err(`no live session named ${JSON.stringify(name)}`)); return EXIT.gone; }
513
700
 
701
+ // The task is minted BEFORE the keystrokes land, so a prompt that sends and
702
+ // then vanishes into a crashed engine still leaves evidence that it was
703
+ // submitted. A ledger that only records successful work is a ledger that
704
+ // cannot answer the one question anybody asks it at 3am.
705
+ const at = Date.now();
706
+ const baseline = capture(name, { lines: 60 });
707
+ const taskId = startTask(name, text, { screen: baseline, now: at, state: session.state });
708
+
514
709
  const sent = sendPrompt(name, text);
515
- if (!sent.ok) { write(err(String(sent.error?.message || sent.error))); return EXIT.usage; }
710
+ if (!sent.ok) {
711
+ endTask(name, taskId, { state: "done", artifact: `moshcode could not type into ${name}: ${sent.error?.message || sent.error}` });
712
+ write(err(String(sent.error?.message || sent.error)));
713
+ return EXIT.usage;
714
+ }
516
715
  if (!wait) {
517
- if (json) write(JSON.stringify({ name, sent: true }, null, 2));
518
- else write(ok(`sent to ${bone(name)}.`));
716
+ if (json) write(JSON.stringify({ name, sent: true, task: taskId }, null, 2));
717
+ else write(ok(`sent to ${bone(name)} — ${ash(taskId)}`));
519
718
  return EXIT.matched;
520
719
  }
521
720
 
522
- await waitFor(name, ["working"], { timeoutMs: 8000, intervalMs: 500 });
523
- const result = await waitFor(name, ["blocked", "done", "idle"], { timeoutMs });
524
- if (json) write(JSON.stringify({ name, sent: true, ...result }, null, 2));
525
- else if (result.outcome === "matched") write(ok(`${name} is ${result.state}.`));
721
+ const record = ledgerRecorder(name);
722
+ await waitFor(name, ["working"], { timeoutMs: 8000, intervalMs: 500, onState: record });
723
+ const result = await waitFor(name, ["blocked", "done", "idle"], { timeoutMs, onState: record });
724
+ endTask(name, taskId, {
725
+ state: result.state,
726
+ artifact: screenDelta(baseline, capture(name, { lines: 400 })),
727
+ });
728
+ if (json) write(JSON.stringify({ name, sent: true, task: taskId, ...result }, null, 2));
729
+ else if (result.outcome === "matched") write(ok(`${name} is ${result.state}. ${ash(`${taskId} — moshcode herd task ${taskId}`)}`));
730
+ else write(warn(`${name}: ${result.outcome} (${result.state})`));
731
+ return result.outcome === "matched" ? EXIT.matched : result.outcome === "timeout" ? EXIT.timeout : EXIT.gone;
732
+ }
733
+
734
+ /** `herd prompt` against a URL. Same ledger, same exit codes, different wire. */
735
+ async function promptRemoteMember(name, text, { wait, json, write }) {
736
+ const remote = await import("./herd-remote.mjs");
737
+ const at = Date.now();
738
+ const taskId = startTask(name, text, { screen: "", now: at });
739
+ const sent = await remote.promptRemote(name, text);
740
+ if (!sent.ok) {
741
+ endTask(name, taskId, { state: "done", artifact: String(sent.error?.message || sent.error) });
742
+ write(err(String(sent.error?.message || sent.error)));
743
+ return EXIT.gone;
744
+ }
745
+ // An `a2a` member answers with a task that may still be running; a `run`
746
+ // member has already answered by the time the POST returns. Both end up in
747
+ // the ledger, which is what makes `herd tasks <remote>` mean anything.
748
+ if (!wait || sent.state === "done") {
749
+ endTask(name, taskId, { state: sent.state || "done", artifact: sent.artifact || "" });
750
+ if (json) write(JSON.stringify({ name, sent: true, task: taskId, remoteTask: sent.taskId || null, state: sent.state }, null, 2));
751
+ else write(ok(`${bone(name)} answered — ${ash(taskId)}`));
752
+ return EXIT.matched;
753
+ }
754
+ const result = await remote.waitRemote(name, ["blocked", "done", "idle"]);
755
+ const artifact = remote.readRemote(name);
756
+ endTask(name, taskId, { state: result.state, artifact });
757
+ if (json) write(JSON.stringify({ name, sent: true, task: taskId, ...result }, null, 2));
758
+ else if (result.outcome === "matched") write(ok(`${name} is ${result.state}. ${ash(`${taskId}`)}`));
526
759
  else write(warn(`${name}: ${result.outcome} (${result.state})`));
527
760
  return result.outcome === "matched" ? EXIT.matched : result.outcome === "timeout" ? EXIT.timeout : EXIT.gone;
528
761
  }
@@ -549,8 +782,15 @@ export function herdReport(argv, { write = console.log } = {}) {
549
782
  else if (!a.startsWith("-")) positional.push(a);
550
783
  }
551
784
  const [name, state] = positional;
785
+ // A hook fired outside a herd session passes an empty name, because
786
+ // $MOSHCODE_HERD_NAME is not set there. That is not a mistake to complain
787
+ // about — it is an engine being used by hand, which is most of the time —
788
+ // and a hook that printed usage on every turn would be uninstalled by
789
+ // lunchtime. Present-but-empty is silence; absent entirely is still usage.
790
+ if (positional.length >= 1 && name === "") return EXIT.matched;
552
791
  if (!name || !state) {
553
792
  write(err(`usage: moshcode herd report <name> <${STATES.join("|")}> [--ttl 15m]`));
793
+ write(info(`blocked also takes a sub-kind: ${BLOCKED_KINDS.map((k) => `blocked:${k}`).join(", ")}`));
554
794
  return EXIT.usage;
555
795
  }
556
796
  const result = reportState(name, state, ttl ? { ttl } : {});
@@ -646,6 +886,11 @@ export async function herdWatch(argv, { write = console.log, once = false } = {}
646
886
 
647
887
  const seen = new Map();
648
888
  for (;;) {
889
+ // Remotes first, so this tick's roster reads a status cache that was
890
+ // refreshed this tick rather than last one. The watcher is the only thing
891
+ // in the herd that runs continuously, which makes it the only honest place
892
+ // to keep a remote's state fresh (0011 R11).
893
+ await refreshRemotes();
649
894
  // One roster per tick, not one per session: this loop runs forever, and
650
895
  // re-reading the herd inside the cleanup pass made a watcher on six
651
896
  // sessions shell out dozens of times every five seconds, all night.
@@ -653,6 +898,10 @@ export async function herdWatch(argv, { write = console.log, once = false } = {}
653
898
  for (const session of current) {
654
899
  const previous = seen.get(session.name);
655
900
  seen.set(session.name, session.state);
901
+ // The ledger write goes exactly where the notification decision already
902
+ // is (0011 R5). Every transition, not only the ones worth a phone call —
903
+ // "it worked for six hours and never asked me anything" is history too.
904
+ if (previous !== undefined && previous !== session.state) recordObservedTransition(session);
656
905
  if (!shouldNotify(previous, session.state, interesting)) continue;
657
906
  await deliver(session, config, write);
658
907
  }
@@ -663,9 +912,56 @@ export async function herdWatch(argv, { write = console.log, once = false } = {}
663
912
  }
664
913
  }
665
914
 
915
+ /**
916
+ * What a reply to each kind of blocked has to look like.
917
+ *
918
+ * Sent with the notification rather than checked on the way back, because the
919
+ * herd cannot know what a given engine's menu accepts and guessing wrong would
920
+ * mean silently refusing to deliver a valid answer. Telling the human is the
921
+ * part that is always safe.
922
+ */
923
+ const ANSWER_HINT = {
924
+ menu: "it is on a numbered menu — reply with the number.",
925
+ permission: "it is asking permission — reply y or n.",
926
+ question: "it asked a question — reply in words.",
927
+ };
928
+
929
+ /** Ask every remote member how it is, so the roster's cache is this tick's. */
930
+ async function refreshRemotes() {
931
+ const remotes = roster().filter((s) => s.kind === "remote");
932
+ if (!remotes.length) return;
933
+ const remote = await import("./herd-remote.mjs");
934
+ await Promise.all(remotes.map((s) => remote.pingRemote(s.name).catch(() => null)));
935
+ }
936
+
937
+ /**
938
+ * Write one observed transition, and close the open task when the session has
939
+ * stopped needing the CPU.
940
+ *
941
+ * This is what makes a prompt submitted WITHOUT `--wait` still end up with an
942
+ * outcome and an artifact: the watcher is running anyway, and it is looking at
943
+ * exactly the transition that ends the task.
944
+ */
945
+ function recordObservedTransition(session) {
946
+ const open = openTask(session.name);
947
+ recordTransition(session.name, session.state, { id: open?.id || null, kind: session.blockedOn || null });
948
+ if (!open) return;
949
+ if (!TERMINAL_STATES.includes(session.state)) return;
950
+ const screen = session.kind === "remote" ? "" : capture(session.name, { lines: 400 });
951
+ endTask(session.name, open.id, {
952
+ state: session.state,
953
+ artifact: session.kind === "remote" ? "" : screenDelta(open.baseline, screen),
954
+ });
955
+ }
956
+
666
957
  async function deliver(session, config, write) {
667
958
  const tail = capture(session.name, { lines: 30 }).split("\n").slice(-12).join("\n");
668
- const message = `${session.name} (${session.engine}) is ${session.state} in ${tilde(session.cwd)}\n\n${tail}`;
959
+ // The sub-kind (0011 R4) tells the human what shape of answer is wanted
960
+ // before they read the screen — a menu wants a digit, a permission wants a
961
+ // y or an n, and a question wants a sentence.
962
+ const asking = session.blockedOn ? ` (${session.blockedOn})` : "";
963
+ const message = `${session.name} (${session.engine}) is ${session.state}${asking} in ${tilde(session.cwd)}`
964
+ + `${session.blockedOn ? `\n\n${ANSWER_HINT[session.blockedOn]}` : ""}\n\n${tail}`;
669
965
  if (!config.notify.ask) {
670
966
  const r = await ingestApproval({ message, kind: "notify", script: "herd", session: session.name });
671
967
  write(r.ok ? info(`notified: ${session.name} → ${session.state}`) : warn(`notify failed (${r.error || r.status}) — run \`moshcode login\``));
@@ -740,6 +1036,496 @@ export function herdStop(argv, { write = console.log } = {}) {
740
1036
  return EXIT.matched;
741
1037
  }
742
1038
 
1039
+ // ---------------------------------------------------------------------------
1040
+ // Hooks — believe the engine, not the paint (0011 R1)
1041
+ // ---------------------------------------------------------------------------
1042
+
1043
+ export async function herdHooks(argv, { write = console.log } = {}) {
1044
+ const {
1045
+ hookableEngines, hooksStatus, installHooks, removeHooks, hookDiff, hookFile,
1046
+ } = await import("./herd-hooks.mjs");
1047
+
1048
+ const positional = argv.filter((a) => !a.startsWith("-"));
1049
+ const [verb = "status", target] = positional;
1050
+ const dryRun = argv.includes("--dry-run");
1051
+ const json = argv.includes("--json");
1052
+ const supported = hookableEngines();
1053
+
1054
+ const targets = (() => {
1055
+ if (!target || target === "all") return supported;
1056
+ const resolved = resolveEngine(target);
1057
+ return resolved ? [resolved[0]] : [];
1058
+ })();
1059
+
1060
+ if (verb !== "status" && !targets.length) {
1061
+ write(err(`no engine named ${JSON.stringify(target)}`));
1062
+ write(info(`engines with hook specs: ${supported.join(", ") || "none yet"}`));
1063
+ return EXIT.usage;
1064
+ }
1065
+
1066
+ if (verb === "status") {
1067
+ const rows = (target && target !== "all" ? targets : supported).map((engine) => hooksStatus(engine));
1068
+ if (json) { write(JSON.stringify(rows, null, 2)); return EXIT.matched; }
1069
+ if (!rows.length) { write(info("no engine in this release ships a hook spec.")); return EXIT.matched; }
1070
+ for (const row of rows) {
1071
+ if (!row.readable) { write(err(`${row.engine} — ${row.error}`)); continue; }
1072
+ const state = row.installed ? ok(`${row.engine} — hooks installed`)
1073
+ : row.partial ? warn(`${row.engine} — hooks are out of date, re-run install`)
1074
+ : info(`${row.engine} — no hooks; sessions are classified from the screen`);
1075
+ write(state);
1076
+ write(ash(` ${row.file}`));
1077
+ for (const e of row.events) {
1078
+ write(` ${e.installed && e.current ? acid("✓") : e.installed ? amber("~") : ash("·")} ${(e.label || e.event).padEnd(16)} ${ash(`→ ${e.state}`)}`);
1079
+ }
1080
+ }
1081
+ const unsupported = Object.keys(ENGINES).filter((k) => !supported.includes(k));
1082
+ if (unsupported.length && !target) write(info(`no hook spec yet: ${unsupported.join(", ")} — those stay on the screen rules.`));
1083
+ return EXIT.matched;
1084
+ }
1085
+
1086
+ if (verb !== "install" && verb !== "remove") {
1087
+ write(err("usage: moshcode herd hooks <install|remove|status> [<engine>|all] [--dry-run] [--json]"));
1088
+ return EXIT.usage;
1089
+ }
1090
+
1091
+ const results = targets.map((engine) => (verb === "install"
1092
+ ? installHooks(engine, { dryRun })
1093
+ : removeHooks(engine, { dryRun })));
1094
+
1095
+ if (json) {
1096
+ write(JSON.stringify(results.map((r) => ({
1097
+ engine: r.engine, ok: r.ok, file: r.file ?? hookFile(r.engine), dryRun: Boolean(r.dryRun),
1098
+ ...(r.changes ? { changes: r.changes } : {}), ...(r.removed !== undefined ? { removed: r.removed } : {}),
1099
+ ...(r.error ? { error: String(r.error.message || r.error) } : {}),
1100
+ })), null, 2));
1101
+ return results.every((r) => r.ok) ? EXIT.matched : EXIT.usage;
1102
+ }
1103
+
1104
+ for (const result of results) {
1105
+ if (!result.ok) { write(err(`${result.engine} — ${result.error?.message || result.error}`)); continue; }
1106
+ if (dryRun) {
1107
+ const diff = hookDiff(result.before, result.after);
1108
+ write(info(`${result.engine} — ${result.file} (dry run)`));
1109
+ write(diff.split("\n").some((l) => l.startsWith("+") || l.startsWith("-")) ? diff : ash(" nothing would change"));
1110
+ continue;
1111
+ }
1112
+ if (verb === "install") {
1113
+ const added = result.changes.filter((c) => c.change !== "unchanged");
1114
+ write(added.length
1115
+ ? ok(`${result.engine} — ${added.length} hook${added.length === 1 ? "" : "s"} installed (${added.map((c) => c.label).join(", ")})`)
1116
+ : ok(`${result.engine} — already installed`));
1117
+ if (added.length) {
1118
+ write(info("sessions started from the herd now report state directly."));
1119
+ write(info("screen rules remain the fallback for everything else."));
1120
+ }
1121
+ } else {
1122
+ write(result.removed ? ok(`${result.engine} — ${result.removed} hook(s) removed; back to the screen rules.`) : info(`${result.engine} — nothing of ours was in there.`));
1123
+ }
1124
+ }
1125
+ return results.every((r) => r.ok) ? EXIT.matched : EXIT.usage;
1126
+ }
1127
+
1128
+ // ---------------------------------------------------------------------------
1129
+ // Doctor — the things that actually go wrong (0011 R3)
1130
+ // ---------------------------------------------------------------------------
1131
+
1132
+ export async function herdDoctor(argv, { write = console.log } = {}) {
1133
+ const { hookableEngines, hooksStatus } = await import("./herd-hooks.mjs");
1134
+ const substrate = detectSubstrate();
1135
+ const checks = [];
1136
+ const add = (name, level, detail, fix = null) => checks.push({ name, level, detail, ...(fix ? { fix } : {}) });
1137
+
1138
+ // 1. Somewhere to run.
1139
+ if (substrate === "tmux") add("substrate", "ok", `tmux, socket ${HERD_SOCKET}`);
1140
+ else if (substrate === "pty") add("substrate", "warn", "script(1) — sessions work but cannot be resized", "install tmux");
1141
+ else add("substrate", "fail", "nothing to run sessions on", substrateNote(null));
1142
+
1143
+ // 2. Does the manifest still describe reality?
1144
+ const rows = roster();
1145
+ const remembered = rows.filter((s) => !s.alive);
1146
+ if (remembered.length) add("manifest", "warn", `${remembered.length} remembered session(s) the runtime no longer has: ${remembered.map((s) => s.name).join(", ")}`, "moshcode restore · moshcode herd prune");
1147
+ else add("manifest", "ok", `${rows.length} session(s), all accounted for`);
1148
+
1149
+ // 3. Can we write where the state lives? A silently unwritable status dir is
1150
+ // a herd where every hook report is lost and nothing anywhere says so.
1151
+ const statusDir = path.join(herdDir(), "status");
1152
+ try {
1153
+ fs.mkdirSync(statusDir, { recursive: true, mode: 0o700 });
1154
+ const probe = path.join(statusDir, `.doctor-${process.pid}`);
1155
+ fs.writeFileSync(probe, "");
1156
+ fs.rmSync(probe, { force: true });
1157
+ add("status dir", "ok", statusDir);
1158
+ } catch (error) {
1159
+ add("status dir", "fail", `${statusDir} is not writable (${error.code || error.message})`, "hook reports are being dropped — fix the permissions on ~/.moshcode/herd");
1160
+ }
1161
+
1162
+ // 4. Hook reports that have gone stale — an engine that stopped reporting is
1163
+ // a roster quietly back on the screen rules.
1164
+ const stale = [];
1165
+ for (const row of rows) {
1166
+ if (row.kind === "remote" || !row.alive) continue;
1167
+ const file = path.join(statusDir, `${row.name}.json`);
1168
+ try {
1169
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
1170
+ const age = Date.now() - Number(raw.at || 0);
1171
+ if (age > Math.min(Number(raw.ttl) || 0, 15 * 60 * 1000)) stale.push(`${row.name} (${humanAge(age)} old)`);
1172
+ } catch { /* no report is not a stale report */ }
1173
+ }
1174
+ if (stale.length) add("hook reports", "warn", `expired: ${stale.join(", ")}`, "moshcode herd hooks status — the engine may have stopped reporting");
1175
+ else add("hook reports", "ok", "none expired");
1176
+
1177
+ // 5. The hooks themselves.
1178
+ for (const engine of hookableEngines()) {
1179
+ const status = hooksStatus(engine);
1180
+ if (!status.readable) add(`hooks: ${engine}`, "fail", status.error, "fix the file, then moshcode herd hooks install");
1181
+ else if (status.installed) add(`hooks: ${engine}`, "ok", status.file);
1182
+ else if (status.partial) add(`hooks: ${engine}`, "warn", "installed but out of date", `moshcode herd hooks install ${engine}`);
1183
+ else add(`hooks: ${engine}`, "warn", "not installed — this engine is classified from its screen", `moshcode herd hooks install ${engine}`);
1184
+ }
1185
+
1186
+ // 6. rules.json, which until now failed silently by design.
1187
+ const rules = inspectUserRules();
1188
+ if (!rules.present) add("rules.json", "ok", "none — using the built-in rules");
1189
+ else if (rules.ok) add("rules.json", "ok", `${rules.patterns} pattern(s) loaded`);
1190
+ else {
1191
+ add("rules.json", "fail", `${rules.problems.length} problem(s) — the whole file is being ignored`,
1192
+ rules.problems.map((p) => `${p.where}: ${p.error}`).join(" · "));
1193
+ }
1194
+
1195
+ const worst = checks.some((c) => c.level === "fail") ? "fail" : checks.some((c) => c.level === "warn") ? "warn" : "ok";
1196
+ if (argv.includes("--json")) {
1197
+ write(JSON.stringify({ ok: worst !== "fail", level: worst, herdDir: herdDir(), substrate, checks }, null, 2));
1198
+ return worst === "fail" ? EXIT.infra : EXIT.matched;
1199
+ }
1200
+ for (const check of checks) {
1201
+ const mark = check.level === "ok" ? acid("✓") : check.level === "warn" ? amber("!") : danger("✗");
1202
+ write(`${mark} ${bone(check.name.padEnd(16))} ${check.level === "ok" ? ash(check.detail) : check.detail}`);
1203
+ if (check.fix) write(` ${ash("→")} ${acid(check.fix)}`);
1204
+ }
1205
+ return worst === "fail" ? EXIT.infra : EXIT.matched;
1206
+ }
1207
+
1208
+ // ---------------------------------------------------------------------------
1209
+ // The ledger's read verbs (0011 R6–R7)
1210
+ // ---------------------------------------------------------------------------
1211
+
1212
+ /**
1213
+ * Close an open task whose session has already stopped.
1214
+ *
1215
+ * A prompt submitted without `--wait`, on a box with no watcher running, leaves
1216
+ * a task nobody ever came back to. Reading the ledger IS coming back to it: the
1217
+ * session's state is looked up here anyway, so recording what it says costs
1218
+ * nothing and turns "open forever" into the outcome that actually happened.
1219
+ */
1220
+ function reconcile(session) {
1221
+ const open = openTask(session);
1222
+ if (!open) return;
1223
+ const row = findSession(session);
1224
+ if (!row || row.kind === "remote") return;
1225
+ if (!TERMINAL_STATES.includes(row.state)) return;
1226
+ endTask(session, open.id, {
1227
+ state: row.state,
1228
+ artifact: screenDelta(open.baseline, capture(session, { lines: 400 })),
1229
+ });
1230
+ }
1231
+
1232
+ export function herdTasks(argv, { write = console.log } = {}) {
1233
+ const json = argv.includes("--json");
1234
+ const positional = argv.filter((a) => !a.startsWith("-"));
1235
+ const name = positional[0];
1236
+ if (!name) {
1237
+ write(err("usage: moshcode herd tasks <session> [--json]"));
1238
+ const known = ledgerSessions();
1239
+ write(info(known.length ? `sessions with history: ${known.join(", ")}` : "nothing has been prompted through the herd yet."));
1240
+ return EXIT.usage;
1241
+ }
1242
+ reconcile(name);
1243
+ const tasks = readTasks(name);
1244
+ if (json) { write(JSON.stringify(tasks, null, 2)); return EXIT.matched; }
1245
+ if (!tasks.length) {
1246
+ write(info(`no tasks recorded for ${JSON.stringify(name)} — ${acid(`moshcode herd prompt ${name} "…"`)} starts one.`));
1247
+ return EXIT.matched;
1248
+ }
1249
+ write(table(tasks.map((t) => [
1250
+ bone(t.id),
1251
+ ash(clock(t.submitted)),
1252
+ paintState(t.status === "open" ? (t.state || "working") : t.state),
1253
+ dim(t.durationMs != null ? humanAge(t.durationMs) : humanAge(Date.now() - (t.submitted || Date.now()))),
1254
+ ash(`"${oneLine(t.text, 48)}"`),
1255
+ ]), { columns: ["task", "at", "state", "took", "prompt"], header: false, indent: 2 }));
1256
+ const open = tasks.filter((t) => t.status === "open").length;
1257
+ if (open) write(info(`${open} still open — ${acid("moshcode herd watch")} closes them as they land.`));
1258
+ return EXIT.matched;
1259
+ }
1260
+
1261
+ export function herdTask(argv, { write = console.log } = {}) {
1262
+ const json = argv.includes("--json");
1263
+ const id = argv.find((a) => !a.startsWith("-"));
1264
+ if (!id) { write(err("usage: moshcode herd task <id> [--json]")); return EXIT.usage; }
1265
+ const task = findTask(id);
1266
+ if (!task) { write(err(`no task ${JSON.stringify(id)} — ${acid("moshcode herd tasks <session>")}`)); return EXIT.gone; }
1267
+ if (json) { write(JSON.stringify(task, null, 2)); return EXIT.matched; }
1268
+
1269
+ write(`${bone(task.id)} ${ash(`· ${task.session} · ${clock(task.submitted)}`)}`);
1270
+ write(`${ash("prompt")} ${task.text}`);
1271
+ write("");
1272
+ for (const [i, step] of task.transitions.entries()) {
1273
+ const next = task.transitions[i + 1]?.ts ?? task.endedAt ?? Date.now();
1274
+ write(` ${ash(clock(step.ts))} ${paintState(step.state)}${step.kind ? ash(`:${step.kind}`) : ""} ${dim(humanAge(next - step.ts))}`);
1275
+ }
1276
+ if (task.status === "closed") write(` ${ash(clock(task.endedAt))} ${paintState(task.state)} ${dim("(end)")}`);
1277
+ else write(` ${ash("…")} ${amber("open")}`);
1278
+ if (task.artifact) {
1279
+ write("");
1280
+ write(ash(task.truncated ? `output (last ${task.artifact.length} of ${task.artifactChars} chars):` : "output:"));
1281
+ write(task.artifact);
1282
+ }
1283
+ return EXIT.matched;
1284
+ }
1285
+
1286
+ export function herdLog(argv, { write = console.log } = {}) {
1287
+ const json = argv.includes("--json");
1288
+ const name = argv.find((a) => !a.startsWith("-"));
1289
+ if (!name) { write(err("usage: moshcode herd log <session> [--json]")); return EXIT.usage; }
1290
+ const entries = readLog(name);
1291
+ if (json) { write(JSON.stringify(entries, null, 2)); return EXIT.matched; }
1292
+ if (!entries.length) { write(info(`no history for ${JSON.stringify(name)} yet.`)); return EXIT.matched; }
1293
+ for (const entry of entries) {
1294
+ // The end of a task and a transition into the same state are two different
1295
+ // records, and a log that printed them identically would read as the herd
1296
+ // seeing everything twice.
1297
+ const label = entry.event === "submit" ? acid("submit")
1298
+ : entry.event === "end" ? `${paintState(entry.state)}${ash(" ✓")}`
1299
+ : paintState(entry.state);
1300
+ write(` ${ash(clock(entry.ts))} ${label}`
1301
+ + `${entry.kind ? ash(`:${entry.kind}`) : ""} ${dim(entry.id || "")}`
1302
+ + `${entry.text ? ` ${ash(`"${oneLine(entry.text, 40)}"`)}` : ""}`);
1303
+ }
1304
+ return EXIT.matched;
1305
+ }
1306
+
1307
+ export function herdStats(argv, { write = console.log } = {}) {
1308
+ const json = argv.includes("--json");
1309
+ const name = argv.find((a) => !a.startsWith("-"));
1310
+ const sessions = name ? [name] : ledgerSessions();
1311
+ if (!sessions.length) { write(info("nothing has been prompted through the herd yet.")); return EXIT.matched; }
1312
+ const all = sessions.map((s) => taskStats(s));
1313
+ if (json) { write(JSON.stringify(all, null, 2)); return EXIT.matched; }
1314
+ for (const s of all) {
1315
+ const parts = Object.entries(s.totals)
1316
+ .filter(([, ms]) => ms > 0)
1317
+ .sort((a, b) => b[1] - a[1])
1318
+ .map(([state, ms]) => `${state} ${humanAge(ms)}`);
1319
+ write(`${bone(s.session.padEnd(12))} ${parts.join(ash(" · ")) || ash("no transitions recorded")}`);
1320
+ // The line the whole feature is for. Blocked time is not the agent being
1321
+ // slow; it is the agent finished and waiting for a person.
1322
+ if (s.totals.blocked) write(` ${amber(`blocked ${humanAge(s.totals.blocked)}`)} ${ash(`over ${s.blockedSpells} spell(s) — that one is you`)}`);
1323
+ }
1324
+ return EXIT.matched;
1325
+ }
1326
+
1327
+ const clock = (ts) => (Number.isFinite(ts) ? new Date(ts).toTimeString().slice(0, 5) : " : ");
1328
+ const oneLine = (text, max) => {
1329
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
1330
+ return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
1331
+ };
1332
+
1333
+ // ---------------------------------------------------------------------------
1334
+ // Remote members (0011 R11)
1335
+ // ---------------------------------------------------------------------------
1336
+
1337
+ export async function herdRemote(argv, { write = console.log } = {}) {
1338
+ const remote = await import("./herd-remote.mjs");
1339
+ const json = argv.includes("--json");
1340
+ const positional = [];
1341
+ let kind = "run";
1342
+ for (let i = 0; i < argv.length; i++) {
1343
+ const a = argv[i];
1344
+ if (a === "--kind") kind = String(argv[++i] || "");
1345
+ else if (a.startsWith("--kind=")) kind = a.slice(7);
1346
+ else if (!a.startsWith("-")) positional.push(a);
1347
+ }
1348
+ const [verb = "list", name, url] = positional;
1349
+
1350
+ if (verb === "list") {
1351
+ const rows = remote.listRemotes();
1352
+ if (json) { write(JSON.stringify(rows, null, 2)); return EXIT.matched; }
1353
+ if (!rows.length) {
1354
+ write(info("no remote members — `moshcode herd remote add <name> <url> --kind a2a|run`"));
1355
+ return EXIT.matched;
1356
+ }
1357
+ write(table(rows.map((r) => [
1358
+ bone(r.name), ash(r.remoteKind), paintState(r.status?.state || "unknown"),
1359
+ ash(r.url), dim(r.status?.at ? `${humanAge(Date.now() - r.status.at)} ago` : "never asked"),
1360
+ ]), { columns: ["name", "kind", "state", "url", "seen"], header: false, indent: 2 }));
1361
+ for (const r of rows) {
1362
+ if (!process.env[remote.tokenEnvVar(r.name)]) write(ash(` ${r.name}: no ${remote.tokenEnvVar(r.name)} in the environment — requests go unauthenticated`));
1363
+ }
1364
+ return EXIT.matched;
1365
+ }
1366
+
1367
+ if (verb === "add") {
1368
+ if (!name || !url) { write(err("usage: moshcode herd remote add <name> <url> [--kind a2a|run]")); return EXIT.usage; }
1369
+ const added = remote.addRemote(name, url, { kind });
1370
+ if (!added.ok) { write(err(String(added.error?.message || added.error))); return EXIT.usage; }
1371
+ write(ok(`${bone(name)} — ${kind} member at ${added.url}`));
1372
+ write(info(`auth: export ${remote.tokenEnvVar(name)}=… (never written to the manifest, never synced)`));
1373
+ const pinged = await remote.pingRemote(name);
1374
+ write(pinged.ok ? ok(`it answers — ${pinged.state}`) : warn(`no answer yet: ${pinged.error?.message || pinged.error}`));
1375
+ return EXIT.matched;
1376
+ }
1377
+
1378
+ if (verb === "remove" || verb === "rm") {
1379
+ if (!name) { write(err("usage: moshcode herd remote remove <name>")); return EXIT.usage; }
1380
+ const removed = remote.removeRemote(name);
1381
+ if (!removed.ok) { write(err(String(removed.error?.message || removed.error))); return EXIT.gone; }
1382
+ write(ok(`${name} is off the roster. the agent at the far end is untouched.`));
1383
+ return EXIT.matched;
1384
+ }
1385
+
1386
+ if (verb === "ping") {
1387
+ const names = name ? [name] : remote.listRemotes().map((r) => r.name);
1388
+ if (!names.length) { write(info("no remote members to ping.")); return EXIT.matched; }
1389
+ const results = [];
1390
+ for (const one of names) {
1391
+ const pinged = await remote.pingRemote(one);
1392
+ results.push({ name: one, ok: pinged.ok, state: pinged.state || "unknown", error: pinged.error ? String(pinged.error.message || pinged.error) : null });
1393
+ if (!json) write(pinged.ok ? ok(`${one} — ${pinged.state}`) : err(`${one} — ${pinged.error?.message || pinged.error}`));
1394
+ }
1395
+ if (json) write(JSON.stringify(results, null, 2));
1396
+ return results.every((r) => r.ok) ? EXIT.matched : EXIT.gone;
1397
+ }
1398
+
1399
+ if (verb === "card") {
1400
+ if (!name) { write(err("usage: moshcode herd remote card <name>")); return EXIT.usage; }
1401
+ const card = await remote.discoverCard(name);
1402
+ if (!card.ok) { write(err(String(card.error?.message || card.error))); return EXIT.gone; }
1403
+ write(JSON.stringify(card.card, null, 2));
1404
+ return EXIT.matched;
1405
+ }
1406
+
1407
+ write(err("usage: moshcode herd remote <list|add|remove|ping|card> [args…]"));
1408
+ return EXIT.usage;
1409
+ }
1410
+
1411
+ // ---------------------------------------------------------------------------
1412
+ // serve — the herd over A2A (0011 R9–R10)
1413
+ // ---------------------------------------------------------------------------
1414
+
1415
+ export async function herdServe(argv, { write = console.log } = {}) {
1416
+ const serve = await import("./herd-serve.mjs");
1417
+ const flag = (name, fallback) => {
1418
+ const at = argv.indexOf(`--${name}`);
1419
+ if (at >= 0 && argv[at + 1] && !argv[at + 1].startsWith("--")) return argv[at + 1];
1420
+ const inline = argv.find((a) => a.startsWith(`--${name}=`));
1421
+ return inline ? inline.slice(name.length + 3) : fallback;
1422
+ };
1423
+
1424
+ const rawPort = flag("port", String(serve.DEFAULT_SERVE_PORT));
1425
+ const port = /^\d+$/.test(rawPort) && Number(rawPort) >= 1 && Number(rawPort) <= 65535 ? Number(rawPort) : null;
1426
+ if (port === null) { write(err(`--port needs a decimal integer from 1 to 65535, got ${JSON.stringify(rawPort)}`)); return EXIT.usage; }
1427
+ const bind = flag("bind", "127.0.0.1");
1428
+ const exposeAutonomous = argv.includes("--expose-autonomous");
1429
+
1430
+ const { api, token } = serve.serveCredentials();
1431
+ if (!token) {
1432
+ // Not a warning. With nothing to verify tokens against, every request would
1433
+ // have to be refused, and a server that refuses everything is a confusing
1434
+ // way to spell "log in first".
1435
+ write(err("not logged in — `moshcode login` first. herd serve has no unauthenticated mode."));
1436
+ return EXIT.usage;
1437
+ }
1438
+
1439
+ const base = `http://${bind}:${port}`;
1440
+ const server = serve.createHerdServer({ api, exposeAutonomous, base });
1441
+ await new Promise((resolve, reject) => {
1442
+ server.once("error", reject);
1443
+ server.listen(port, bind, resolve);
1444
+ }).catch((error) => { write(err(`could not listen on ${bind}:${port} — ${error.message}`)); });
1445
+ if (!server.listening) return EXIT.infra;
1446
+
1447
+ const exposed = serve.servedSessions({ exposeAutonomous });
1448
+ write(ok(`herd A2A ${serve.A2A_PROTOCOL_VERSION} on ${base}/`));
1449
+ write(info(`card: ${base}/.well-known/agent-card.json · members: ${exposed.map((s) => s.name).join(", ") || "none yet"}`));
1450
+ write(info(`auth: moshcode login tokens verified against ${api} — every request, loopback included`));
1451
+ const hidden = roster().filter((s) => s.kind !== "remote" && readManifest().sessions[s.name]?.agent).length;
1452
+ if (hidden && !exposeAutonomous) {
1453
+ write(info(`${hidden} autonomous session(s) withheld — an engine with approvals bypassed plus a network prompt is the worst pairing on the menu. --expose-autonomous overrides.`));
1454
+ }
1455
+ if (bind !== "127.0.0.1" && bind !== "localhost") {
1456
+ write(warn("! bound past loopback — message/send is keystrokes into a real pty. prefer a tailnet address or a reverse proxy with TLS."));
1457
+ }
1458
+ return new Promise(() => {}); // serve until killed
1459
+ }
1460
+
1461
+ // ---------------------------------------------------------------------------
1462
+ // eval — which engine is best at THIS repo (0011 R13)
1463
+ // ---------------------------------------------------------------------------
1464
+
1465
+ export async function herdEval(argv, { write = console.log } = {}) {
1466
+ const evals = await import("./herd-eval.mjs");
1467
+ let dataset = null, engines = "", judge = "rules", threshold = evals.DEFAULT_THRESHOLD;
1468
+ let json = false, keep = false, timeoutMs = 10 * 60 * 1000;
1469
+ for (let i = 0; i < argv.length; i++) {
1470
+ const a = argv[i];
1471
+ if (a === "--dataset") dataset = argv[++i];
1472
+ else if (a.startsWith("--dataset=")) dataset = a.slice(10);
1473
+ else if (a === "--engines") engines = argv[++i];
1474
+ else if (a.startsWith("--engines=")) engines = a.slice(10);
1475
+ else if (a === "--judge") judge = argv[++i];
1476
+ else if (a.startsWith("--judge=")) judge = a.slice(8);
1477
+ else if (a === "--threshold") threshold = Number(argv[++i]);
1478
+ else if (a.startsWith("--threshold=")) threshold = Number(a.slice(12));
1479
+ else if (a === "--timeout") timeoutMs = parseDuration(argv[++i], timeoutMs);
1480
+ else if (a === "--keep") keep = true;
1481
+ else if (a === "--json") json = true;
1482
+ }
1483
+ if (!dataset || !engines) {
1484
+ write(err("usage: moshcode herd eval --dataset <file> --engines a,b [--judge <engine>|rules] [--threshold 0.8]"));
1485
+ write(info("a dataset row is { \"prompt\": \"…\", \"expect\": \"pattern\" } or { \"prompt\": \"…\", \"rubric\": \"…\" } — jsonl, json, or csv"));
1486
+ return EXIT.usage;
1487
+ }
1488
+ if (!Number.isFinite(threshold) || threshold < 0 || threshold > 1) {
1489
+ write(err(`--threshold is a score between 0 and 1, got ${JSON.stringify(String(threshold))}`));
1490
+ return EXIT.usage;
1491
+ }
1492
+
1493
+ const loaded = evals.loadDataset(path.resolve(dataset));
1494
+ if (!loaded.ok) { write(err(String(loaded.error?.message || loaded.error))); return EXIT.usage; }
1495
+ const { keys, unknown } = evals.resolveEngines(engines);
1496
+ if (unknown.length) { write(err(`no engine named ${unknown.join(", ")}`)); return EXIT.usage; }
1497
+ if (!keys.length) { write(err("--engines needs at least one engine")); return EXIT.usage; }
1498
+ if (judge !== "rules" && !resolveEngine(judge)) { write(err(`no engine named ${JSON.stringify(judge)} to judge with`)); return EXIT.usage; }
1499
+ if (!requireSubstrate(write)) return EXIT.infra;
1500
+
1501
+ if (!json) write(info(`${loaded.cases.length} case(s) × ${keys.length} engine(s), judged by ${judge}`));
1502
+ const report = await evals.runEval({
1503
+ cases: loaded.cases, engines: keys, judge: judge === "rules" ? "rules" : resolveEngine(judge)[0],
1504
+ threshold, timeoutMs, keep, waitFor,
1505
+ out: json ? () => {} : (line) => write(ash(line)),
1506
+ });
1507
+
1508
+ if (json) write(JSON.stringify(report, null, 2));
1509
+ else {
1510
+ write("");
1511
+ for (const engine of report.engines) {
1512
+ if (!engine.ok) { write(err(`${engine.engine.padEnd(12)} could not run — ${engine.error}`)); continue; }
1513
+ const pct = `${Math.round(engine.score * 100)}%`;
1514
+ const line = `${bone(engine.engine.padEnd(12))} ${engine.score >= report.threshold ? acid(pct) : amber(pct)} ${ash(`${engine.passed}/${engine.cases.length} clean`)}`;
1515
+ write(engine.unscorable ? `${line} ${amber(`· ${engine.unscorable} unscorable`)}` : line);
1516
+ for (const c of engine.cases.filter((c) => c.score < 1)) write(ash(` ${c.id}: ${c.why}`));
1517
+ }
1518
+ write("");
1519
+ if (report.outcome === "pass") write(ok(`every engine is at or above ${report.threshold}.`));
1520
+ else if (report.outcome === "below") write(warn(`below ${report.threshold}: ${report.below.join(", ")}`));
1521
+ else write(err(`the harness could not run: ${report.broken.map((b) => `${b.engine} (${b.error})`).join(", ")}`));
1522
+ }
1523
+ // Three outcomes, three codes: CI has to tell a worse agent from a broken
1524
+ // harness, and one non-zero code cannot say both.
1525
+ if (report.outcome === "pass") return EXIT.matched;
1526
+ return report.outcome === "below" ? EXIT.below : EXIT.infra;
1527
+ }
1528
+
743
1529
  // ---------------------------------------------------------------------------
744
1530
  // Dispatch
745
1531
  // ---------------------------------------------------------------------------
@@ -764,6 +1550,12 @@ const VERBS = {
764
1550
  read: herdRead, prompt: herdPrompt, "send-keys": herdSendKeys,
765
1551
  wait: herdWait, restore: herdRestore, report: herdReport,
766
1552
  notify: herdNotify, watch: herdWatch, stop: herdStop,
1553
+ // PRD 0011. Same shape as everything above: one verb, `--json` on all of
1554
+ // them, and no second API anywhere — `serve` is this surface answering a
1555
+ // socket rather than a parallel one.
1556
+ hooks: herdHooks, doctor: herdDoctor,
1557
+ tasks: herdTasks, task: herdTask, log: herdLog, stats: herdStats,
1558
+ remote: herdRemote, serve: herdServe, eval: herdEval,
767
1559
  };
768
1560
 
769
1561
  export async function herdCommand(argv = [], { write = console.log } = {}) {