moshcode 0.59.0 → 0.60.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/README.md +121 -7
- package/bin/moshcode.mjs +2 -2
- package/package.json +1 -1
- package/prd/0011-herd-agent-protocol.md +391 -0
- package/prd/README.md +1 -0
- package/src/cli-schema.mjs +100 -6
- package/src/commands.mjs +84 -10
- package/src/cost.mjs +121 -2
- package/src/engines.mjs +32 -0
- package/src/herd-cli.mjs +812 -20
- package/src/herd-eval.mjs +301 -0
- package/src/herd-hooks.mjs +285 -0
- package/src/herd-remote.mjs +365 -0
- package/src/herd-serve.mjs +515 -0
- package/src/herd-state.mjs +167 -10
- package/src/herd-tasks.mjs +377 -0
- package/src/herd.mjs +89 -7
- package/src/templates.mjs +32 -5
- package/src/tools.mjs +43 -0
- package/src/tui.mjs +1 -1
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
|
-
/**
|
|
22
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
472
|
-
|
|
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
|
|
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) {
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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 } = {}) {
|