huddle-cli 0.1.1 → 0.1.3
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/CHANGELOG.md +38 -0
- package/QUICKSTART.md +34 -1
- package/README.md +3 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +26 -2
- package/dist/mcp/server.js +19 -0
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +10 -4
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +31 -4
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +82 -16
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +53 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +452 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +4 -0
- package/package.json +1 -1
package/dist/ui/app.js
CHANGED
|
@@ -30,15 +30,21 @@ import { pickAsker } from "../orchestrator/ask.js";
|
|
|
30
30
|
import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
|
|
31
31
|
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
32
32
|
import { runBoard } from "../orchestrator/scheduler.js";
|
|
33
|
-
import { runQa, shouldQa } from "../orchestrator/qa.js";
|
|
33
|
+
import { QA_ROUNDS, runQa, shouldQa } from "../orchestrator/qa.js";
|
|
34
|
+
import { Budget, HARD_AT, SOFT_AT, budgetFlag, budgetNotes, budgetUse, costPreview, parseTokens, runHistory } from "../orchestrator/budget.js";
|
|
34
35
|
import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
35
36
|
import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
|
|
36
37
|
import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
|
|
37
38
|
import { logoLines } from "./logo.js";
|
|
38
|
-
import { Dashboard } from "./dashboard.js";
|
|
39
|
+
import { Dashboard, estHint, progressLine, taskCounts, taskMeter } from "./dashboard.js";
|
|
39
40
|
import { learnFromRun } from "../orchestrator/learn.js";
|
|
41
|
+
import { briefLines, liftPointers, placePointers, writeBrief } from "./brief.js";
|
|
40
42
|
import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
|
|
41
43
|
import { splitPathTokens } from "../room/context.js";
|
|
44
|
+
import { narration, narratorLine } from "./narrator.js";
|
|
45
|
+
import { clearCommands, openApp, saveReport, takeCommands, writeLive } from "../app/live.js";
|
|
46
|
+
import { KeyLine, Talk, addressee, questionLines, settledLine } from "./talk.js";
|
|
47
|
+
import { sayToAgents, USER_ID } from "../room/user.js";
|
|
42
48
|
// 0 = pick a free port per run, so sessions never collide.
|
|
43
49
|
export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
|
|
44
50
|
// ---- paths & repo ----------------------------------------------------------
|
|
@@ -115,7 +121,7 @@ export function readPrefs() {
|
|
|
115
121
|
return {};
|
|
116
122
|
}
|
|
117
123
|
}
|
|
118
|
-
function writePrefs(p) {
|
|
124
|
+
export function writePrefs(p) {
|
|
119
125
|
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
|
|
120
126
|
fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
|
|
121
127
|
}
|
|
@@ -321,9 +327,12 @@ export async function offerSetup(rl, results, force = false) {
|
|
|
321
327
|
}
|
|
322
328
|
/**
|
|
323
329
|
* The live status block under the run's output, one line per agent:
|
|
324
|
-
* ● claude 60% Build the CLI adapter
|
|
325
|
-
*
|
|
326
|
-
*
|
|
330
|
+
* ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
|
|
331
|
+
* ● codex ······ — Build the HTTP API · no report yet · 3m20s
|
|
332
|
+
* ○ cursor idle · 2 merged
|
|
333
|
+
* The bar is the agent's own last huddle_progress %, never a guess. Every line
|
|
334
|
+
* fits `W` cells: the bar goes first on a narrow screen, then the title gives
|
|
335
|
+
* way, never the numbers.
|
|
327
336
|
*/
|
|
328
337
|
export function statusLines(stats, W, now = Date.now()) {
|
|
329
338
|
const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
|
|
@@ -333,15 +342,33 @@ export function statusLines(stats, W, now = Date.now()) {
|
|
|
333
342
|
const done = a.tasks + a.reviews + (a.qa ?? 0);
|
|
334
343
|
return head + C.faint(done ? `idle · ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
|
|
335
344
|
}
|
|
336
|
-
const pct = a.pct === undefined ? C.faint(" · ") : C.violet(`${String(a.pct).padStart(3)}%`);
|
|
337
345
|
const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
|
|
338
|
-
const said = a.noteAt === undefined ? "no
|
|
339
|
-
const
|
|
346
|
+
const said = a.noteAt === undefined ? "no report yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
|
|
347
|
+
const est = estHint(a.est, now - a.busySince);
|
|
348
|
+
const meter = taskMeter(a.pct ?? null, W >= 100 ? 8 : W >= 70 ? 6 : 0);
|
|
349
|
+
const base = (quiet > 120_000 ? C.yellow(` · ${said}`) : C.faint(` · ${said}`)) +
|
|
340
350
|
C.faint(` · ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` · ${fmtTokens(a.liveTokens)} tok` : ""}`);
|
|
341
|
-
|
|
342
|
-
|
|
351
|
+
// The estimate is a hint: it goes before the title would have to drop under 8 cells.
|
|
352
|
+
const withEst = est ? base + C.faint(" · ") + est : base;
|
|
353
|
+
const tail = W - width(head) - width(meter) - 1 - width(withEst) >= 8 ? withEst : base;
|
|
354
|
+
const room = Math.max(8, W - width(head) - width(meter) - 1 - width(tail));
|
|
355
|
+
return truncate(`${head}${meter} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`, W);
|
|
343
356
|
});
|
|
344
357
|
}
|
|
358
|
+
/**
|
|
359
|
+
* The pinned block during a run: the overall bar, then (once agents are on tasks) a status line each.
|
|
360
|
+
* BUILD ███████░░░░░ 6/9 tasks · 12m03s
|
|
361
|
+
* ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
|
|
362
|
+
*/
|
|
363
|
+
export function runLines(stats, W, now = Date.now()) {
|
|
364
|
+
const r = stats.run;
|
|
365
|
+
const top = r ? ` ${progressLine(r, now - stats.startedAt, W - 2)}` : C.faint(` ⏱ ${fmtDuration(now - stats.startedAt)}`);
|
|
366
|
+
return [top, ...(!r || r.phase === "BUILD" || r.phase === "QA" ? statusLines(stats, W, now) : [])];
|
|
367
|
+
}
|
|
368
|
+
/** The pinned block with the narrator on top: what's happening now in plain words, and whether you're needed. */
|
|
369
|
+
export function liveLines(stats, W, now = Date.now()) {
|
|
370
|
+
return [narratorLine(stats, W, now), ...runLines(stats, W, now)];
|
|
371
|
+
}
|
|
345
372
|
export function newStats(workers) {
|
|
346
373
|
return {
|
|
347
374
|
startedAt: Date.now(),
|
|
@@ -441,6 +468,7 @@ async function prepareRepo(print, rl, goal = "") {
|
|
|
441
468
|
print(` ${C.green("✓")} git repo ready`);
|
|
442
469
|
}
|
|
443
470
|
ensureInit();
|
|
471
|
+
liftPointers(repoRoot()); // the brief's pointer is out of CLAUDE.md / AGENTS.md while the team works (ui/brief.ts)
|
|
444
472
|
const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
|
|
445
473
|
if (dirty.length) {
|
|
446
474
|
if (!rl) {
|
|
@@ -519,28 +547,55 @@ function phase(print, name, sub = "") {
|
|
|
519
547
|
const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
|
|
520
548
|
print(`\n${head}${rest}`);
|
|
521
549
|
}
|
|
522
|
-
|
|
550
|
+
const plural = (k, one) => `${k} ${k === 1 ? one : one + "s"}`;
|
|
551
|
+
function printPlan(print, goal, plan, workers, verbose = process.env.HUDDLE_VERBOSE === "1", canType = false) {
|
|
523
552
|
const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
|
|
524
553
|
phase(print, "PLAN", `${plan.tasks.length} task(s) · by ${plan.by}${took}${plan.note ? " — " + plan.note : ""}`);
|
|
525
|
-
|
|
554
|
+
// First, in plain words: what this run makes and what using it looks like (the planner's "plain").
|
|
555
|
+
for (const l of plainLines(goal, plan))
|
|
556
|
+
print(l);
|
|
557
|
+
print(C.faint(" who does what:"));
|
|
526
558
|
const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
|
|
527
559
|
const likely = likelyAgents(plan.tasks, workers);
|
|
528
560
|
plan.tasks.forEach((t, i) => {
|
|
529
561
|
const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
|
|
530
562
|
const fit = likely[i];
|
|
531
|
-
|
|
532
|
-
const
|
|
563
|
+
// Whose idea it was is in TEAMWORK and huddle story; here it only pushed the title off the screen.
|
|
564
|
+
const idea = verbose && t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
|
|
565
|
+
const who = fit ? ` ${C.faint("→")} ${dot(fit)} ${fit}` : "";
|
|
533
566
|
const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
|
|
534
567
|
print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
|
|
535
568
|
});
|
|
536
569
|
print(C.faint(` ${plan.tasks.length} task(s), ${Math.min(startable, workers.length)} of ${workers.length} agents can start right away`));
|
|
537
|
-
// The council's agreements, which every agent can huddle_recall mid-task.
|
|
570
|
+
// The council's agreements, which every agent can huddle_recall mid-task. For the
|
|
571
|
+
// person watching they're engineering detail: one line, the rest on request.
|
|
572
|
+
const d = plan.council?.decisions?.length ?? 0;
|
|
573
|
+
const r = plan.council?.risks?.length ?? 0;
|
|
574
|
+
if (verbose)
|
|
575
|
+
for (const l of planDetails(plan))
|
|
576
|
+
print(l);
|
|
577
|
+
else if (d || r)
|
|
578
|
+
print(` ${C.violet("◆")} ${C.faint(`the agents agreed ${[d ? plural(d, "technical decision") : "", r ? `${plural(r, "risk")} to watch` : ""].filter(Boolean).join(" and ")} · `)}${canType ? `type ${C.bold("details")}` : C.bold("huddle story")}${C.faint(canType ? " to read them" : " has them")}`);
|
|
579
|
+
}
|
|
580
|
+
/** " What you'll get: …" / " For example: …" — the plan for someone who isn't a programmer. */
|
|
581
|
+
export function plainLines(goal, plan) {
|
|
582
|
+
// No summary from the planner (a template plan, an older brain): the goal as you typed it.
|
|
583
|
+
if (!plan.plain?.what)
|
|
584
|
+
return [` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`];
|
|
585
|
+
const label = (s) => ` ${C.bold(pad(s, 15))} `;
|
|
586
|
+
const out = [`${label("What you'll get")}${oneLine(plan.plain.what, space(label("What you'll get")))}`];
|
|
587
|
+
if (plan.plain?.example)
|
|
588
|
+
out.push(`${label("For example")}${oneLine(plan.plain.example, space(label("For example")))}`);
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
/** The council's decisions and risks in full, for `details` (and HUDDLE_VERBOSE=1). */
|
|
592
|
+
export function planDetails(plan) {
|
|
593
|
+
const out = [];
|
|
538
594
|
for (const d of plan.council?.decisions ?? [])
|
|
539
|
-
|
|
540
|
-
for (const r of plan.council?.risks ?? [])
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
595
|
+
out.push(` ${C.violet("◆")} ${C.faint("decided:")} ${d}`);
|
|
596
|
+
for (const r of plan.council?.risks ?? [])
|
|
597
|
+
out.push(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${r.body}`);
|
|
598
|
+
return out;
|
|
544
599
|
}
|
|
545
600
|
/** Show a spinner line while a promise settles (TTY only). */
|
|
546
601
|
async function withSpinner(label, p) {
|
|
@@ -556,7 +611,21 @@ async function withSpinner(label, p) {
|
|
|
556
611
|
process.stdout.write("\x1b[1A\x1b[2K"); // remove the spinner line
|
|
557
612
|
}
|
|
558
613
|
}
|
|
614
|
+
/** A run, and the brief's pointers put back however it ended (plan mode, a stop, a crash). */
|
|
615
|
+
/** The current run's typing line and open questions, closed however the run ends (a throw included). */
|
|
616
|
+
let endTalkNow = null;
|
|
559
617
|
export async function runGoal(goal, opts) {
|
|
618
|
+
try {
|
|
619
|
+
await runGoalOnce(goal, opts);
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
endTalkNow?.();
|
|
623
|
+
endTalkNow = null;
|
|
624
|
+
if (isGitRepo(process.cwd()))
|
|
625
|
+
placePointers(repoRoot(), { enabled: readPrefs().brief !== false });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
async function runGoalOnce(goal, opts) {
|
|
560
629
|
// Every line fits the terminal (logs keep everything). In a one-shot run on a
|
|
561
630
|
// terminal, the per-agent status lines sit under the output (the Dock).
|
|
562
631
|
let dock = null;
|
|
@@ -567,6 +636,13 @@ export async function runGoal(goal, opts) {
|
|
|
567
636
|
if (flagIn)
|
|
568
637
|
goal = goal.replace(flagIn[0], " ").replace(/\s+/g, " ").trim();
|
|
569
638
|
const councilFlag = opts.council ?? (flagIn ? !flagIn[2] : undefined);
|
|
639
|
+
// --budget=5M, typed at the prompt too. Past 90% no new work starts; past 120% the agents stop (budget.ts).
|
|
640
|
+
const typedBudget = budgetFlag(goal);
|
|
641
|
+
goal = typedBudget.goal;
|
|
642
|
+
const budgetAsked = opts.budget ?? typedBudget.budget ?? readPrefs().budget;
|
|
643
|
+
const budget = new Budget(budgetAsked === undefined ? null : parseTokens(budgetAsked));
|
|
644
|
+
if (budgetAsked !== undefined && !budget.limit)
|
|
645
|
+
print(C.yellow(` ! --budget=${budgetAsked} isn't a token count (try 5M or 800k) — no budget this run`));
|
|
570
646
|
const workers = workersFrom(opts.results);
|
|
571
647
|
// One-shot `huddle join` (no rl): a failure exits 1, and the hints name CLI commands.
|
|
572
648
|
if (!workers.length && !opts.room?.server.remote.workers().length) {
|
|
@@ -586,6 +662,195 @@ export async function runGoal(goal, opts) {
|
|
|
586
662
|
// Teammates' agents (from `huddle join` on their machines) work alongside yours.
|
|
587
663
|
const bridge = opts.room?.server.remote;
|
|
588
664
|
const remoteNow = () => (bridge?.workers() ?? []).map((r) => ({ id: r.id, remote: r }));
|
|
665
|
+
// The run's live numbers from its first step: the per-agent status lines and the overall bar.
|
|
666
|
+
const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
|
|
667
|
+
let board = null; // this run's Room, once its board is seeded: the bar counts its tasks
|
|
668
|
+
let logged = ""; // the last progress line a log got
|
|
669
|
+
const progress = (p) => {
|
|
670
|
+
let counts = {};
|
|
671
|
+
try {
|
|
672
|
+
if (board)
|
|
673
|
+
counts = taskCounts(board.listTasks());
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
/* a closed db: keep the last counts */
|
|
677
|
+
}
|
|
678
|
+
stats.run = { ...(stats.run ?? { phase: "PLAN", done: 0, total: 0, failed: 0 }), ...p, ...counts };
|
|
679
|
+
// A log (piped, no dock) gets one plain line each time the board's numbers move — never redraws.
|
|
680
|
+
const r = stats.run;
|
|
681
|
+
if (!opts.print && !process.stdout.isTTY && board && (r.phase === "BUILD" || r.phase === "QA")) {
|
|
682
|
+
const key = progressLine(r, null, 200, { on: false });
|
|
683
|
+
if (key !== logged)
|
|
684
|
+
print(C.faint(` ${(logged = key)} · ${fmtDuration(Date.now() - stats.startedAt)}`));
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
// You, during the run: agents' questions for you and what you type to them. `huddle join`
|
|
688
|
+
// on a terminal gets a typing line under the pinned block (KeyLine); the app has its prompt.
|
|
689
|
+
const oneShotTty = !opts.rl && !opts.print && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
690
|
+
// Every run has one: with no terminal to type in, the dashboard (huddle app) answers and talks.
|
|
691
|
+
const talk = opts.talk ?? new Talk();
|
|
692
|
+
let keys = null;
|
|
693
|
+
let interrupted = 0;
|
|
694
|
+
let stopNow = null; // set once the build can be stopped cleanly
|
|
695
|
+
let liveTick = null;
|
|
696
|
+
let sessionId;
|
|
697
|
+
let planNow = null;
|
|
698
|
+
const userNotes = []; // typed before the build starts: delivered when it does
|
|
699
|
+
// The whole run's off switch: the council, the plan, the build and QA all stop on it.
|
|
700
|
+
const life = new AbortController();
|
|
701
|
+
if (opts.signal?.aborted)
|
|
702
|
+
life.abort();
|
|
703
|
+
opts.signal?.addEventListener("abort", () => life.abort());
|
|
704
|
+
/** You asked it to stop (ctrl-c, `stop` in the dashboard): the build stops cleanly and still reports; before the build, everything stops. */
|
|
705
|
+
const requestStop = () => {
|
|
706
|
+
if (life.signal.aborted)
|
|
707
|
+
return;
|
|
708
|
+
print(C.yellow(" stopping agents…"));
|
|
709
|
+
if (stopNow)
|
|
710
|
+
stopNow();
|
|
711
|
+
else
|
|
712
|
+
life.abort();
|
|
713
|
+
};
|
|
714
|
+
if (talk) {
|
|
715
|
+
talk.onShow = (q, until) => {
|
|
716
|
+
stats.ask = { from: q.from, until };
|
|
717
|
+
print();
|
|
718
|
+
for (const l of questionLines(q, until))
|
|
719
|
+
print(l);
|
|
720
|
+
if (process.stdout.isTTY)
|
|
721
|
+
process.stdout.write("\x07"); // a bell: you may be in another window
|
|
722
|
+
act(q.from, `${C.yellow("⚑ asks you:")} ${q.question}`);
|
|
723
|
+
};
|
|
724
|
+
talk.onSettled = (q, a) => {
|
|
725
|
+
const next = talk.current();
|
|
726
|
+
stats.ask = next ? { from: next.q.from, until: next.until } : undefined;
|
|
727
|
+
print(settledLine(q, a));
|
|
728
|
+
};
|
|
729
|
+
talk.details = () => {
|
|
730
|
+
if (!planNow)
|
|
731
|
+
return print(C.faint(" no plan yet — the agents are still designing it"));
|
|
732
|
+
const d = planDetails(planNow);
|
|
733
|
+
if (!d.length)
|
|
734
|
+
return print(C.faint(" this plan has no separate decisions or risks"));
|
|
735
|
+
for (const l of d)
|
|
736
|
+
print(l);
|
|
737
|
+
};
|
|
738
|
+
talk.say = (line) => {
|
|
739
|
+
const { to, text } = addressee(line);
|
|
740
|
+
if (!text)
|
|
741
|
+
return;
|
|
742
|
+
if (!board) {
|
|
743
|
+
userNotes.push(text);
|
|
744
|
+
return print(` ${C.violet("✉")} ${C.bold("you")}${C.faint(":")} ${oneLine(text, space(" ✉ you: ", " · noted, every agent gets it when building starts"))} ${C.faint("· noted, every agent gets it when building starts")}`);
|
|
745
|
+
}
|
|
746
|
+
const r = sayToAgents(board, text, to);
|
|
747
|
+
if (to && !r.now.length)
|
|
748
|
+
return print(C.yellow(` ! ${to} isn't working on anything right now — say it without @ and every agent that starts next gets it`));
|
|
749
|
+
const who = r.now.length ? r.now.join(", ") : "every agent that starts next";
|
|
750
|
+
const head = ` ${C.violet("✉")} ${C.bold("you")} ${C.faint("→")} ${who}${C.faint(":")} `;
|
|
751
|
+
print(`${head}${oneLine(text, space(head))}`);
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
755
|
+
/** The run is over: open questions go with option 1, the typing line goes away. */
|
|
756
|
+
const endTalk = () => {
|
|
757
|
+
if (liveTick)
|
|
758
|
+
clearInterval(liveTick);
|
|
759
|
+
liveTick = null;
|
|
760
|
+
writeLive(root, snapshot(true));
|
|
761
|
+
talk?.close();
|
|
762
|
+
if (talk)
|
|
763
|
+
talk.say = talk.details = null;
|
|
764
|
+
stats.ask = undefined;
|
|
765
|
+
keys?.stop();
|
|
766
|
+
keys = null;
|
|
767
|
+
};
|
|
768
|
+
endTalkNow = endTalk;
|
|
769
|
+
// The dashboard's view of this run (.huddle/live.json) and what it sends back (.huddle/inbox/): app/live.ts.
|
|
770
|
+
const councilSoFar = [];
|
|
771
|
+
const snapshot = (done = false) => {
|
|
772
|
+
const cur = talk.current();
|
|
773
|
+
return {
|
|
774
|
+
pid: process.pid,
|
|
775
|
+
goal,
|
|
776
|
+
startedAt: stats.startedAt,
|
|
777
|
+
updatedAt: Date.now(),
|
|
778
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
779
|
+
...(planNow?.plain ? { plain: planNow.plain } : {}),
|
|
780
|
+
narrator: narration(stats),
|
|
781
|
+
...(stats.run ? { run: stats.run } : {}),
|
|
782
|
+
agents: [...stats.agents].map(([id, a]) => ({
|
|
783
|
+
id,
|
|
784
|
+
busy: !!a.busySince,
|
|
785
|
+
...(a.doing ? { doing: a.doing } : {}),
|
|
786
|
+
...(a.pct !== undefined ? { pct: a.pct } : {}),
|
|
787
|
+
tokens: a.tokens + Math.max(0, a.liveTokens),
|
|
788
|
+
tasks: a.tasks,
|
|
789
|
+
reviews: a.reviews,
|
|
790
|
+
})),
|
|
791
|
+
council: councilSoFar,
|
|
792
|
+
...(cur ? { ask: { id: cur.q.id, from: cur.q.from, question: cur.q.question, options: cur.q.options, ...(cur.q.why ? { why: cur.q.why } : {}), until: cur.until } } : {}),
|
|
793
|
+
tokens: [...stats.agents.values()].reduce((n, a) => n + a.tokens + Math.max(0, a.liveTokens), 0),
|
|
794
|
+
...(budget.limit ? { budget: { limit: budget.limit, used: budget.tokens } } : {}),
|
|
795
|
+
...(done ? { done } : {}),
|
|
796
|
+
};
|
|
797
|
+
};
|
|
798
|
+
const command = (c) => {
|
|
799
|
+
if (c.type === "stop")
|
|
800
|
+
return requestStop();
|
|
801
|
+
if (c.type === "say")
|
|
802
|
+
return talk.say?.(c.text);
|
|
803
|
+
const cur = talk.current();
|
|
804
|
+
if (cur && cur.q.id === c.id)
|
|
805
|
+
talk.answer(c.line);
|
|
806
|
+
};
|
|
807
|
+
clearCommands(root);
|
|
808
|
+
writeLive(root, snapshot());
|
|
809
|
+
liveTick = setInterval(() => {
|
|
810
|
+
for (const c of takeCommands(root))
|
|
811
|
+
command(c);
|
|
812
|
+
writeLive(root, snapshot());
|
|
813
|
+
}, 1000);
|
|
814
|
+
// A one-shot run on a terminal (huddle join): the overall bar and who's on what, pinned under
|
|
815
|
+
// the output (the prompt app draws the same lines in its footer). Up for the council, then the build.
|
|
816
|
+
const startDock = () => {
|
|
817
|
+
if (dock || opts.print || !process.stdout.isTTY)
|
|
818
|
+
return;
|
|
819
|
+
if (talk && oneShotTty && !keys)
|
|
820
|
+
keys = new KeyLine((l) => {
|
|
821
|
+
if (talk.answer(l))
|
|
822
|
+
return;
|
|
823
|
+
const t = l.trim();
|
|
824
|
+
if (/^(details|decisions|risks)$/i.test(t))
|
|
825
|
+
talk.details?.();
|
|
826
|
+
else if (t)
|
|
827
|
+
talk.say?.(t);
|
|
828
|
+
}, () => dock?.redraw(), () => {
|
|
829
|
+
// First ctrl-c: stop the agents and still print what got done. Second: leave now.
|
|
830
|
+
if (interrupted++) {
|
|
831
|
+
endTalk();
|
|
832
|
+
stopDock();
|
|
833
|
+
// What the terminal does on ctrl-c without raw mode: SIGINT to the whole
|
|
834
|
+
// foreground group, agents included. Windows has no process groups.
|
|
835
|
+
try {
|
|
836
|
+
if (process.platform === "win32")
|
|
837
|
+
throw new Error("no process groups");
|
|
838
|
+
process.kill(0, "SIGINT");
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
process.exit(130); // the scheduler's exit hook stops any running agents
|
|
842
|
+
}
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
requestStop();
|
|
846
|
+
print(C.faint(" (ctrl-c again to quit right away)"));
|
|
847
|
+
}).start();
|
|
848
|
+
dock = new Dock(() => [...liveLines(stats, (process.stdout.columns || 80) - 1), ...(keys ? [keys.line()] : [])]).start();
|
|
849
|
+
};
|
|
850
|
+
const stopDock = () => {
|
|
851
|
+
dock?.stop();
|
|
852
|
+
dock = null;
|
|
853
|
+
};
|
|
589
854
|
const brain = resolveBrain(opts.results, opts.probed);
|
|
590
855
|
const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
|
|
591
856
|
const members = councilMembers(workers);
|
|
@@ -597,6 +862,9 @@ export async function runGoal(goal, opts) {
|
|
|
597
862
|
let plan;
|
|
598
863
|
if (wantCouncil.yes) {
|
|
599
864
|
phase(print, "COUNCIL", `${members.map((m) => m.id).join(", ")} brainstorm before anyone codes · ${wantCouncil.why} (--no-council skips)`);
|
|
865
|
+
progress({ phase: "COUNCIL", step: 1 });
|
|
866
|
+
opts.onStats?.(stats);
|
|
867
|
+
startDock();
|
|
600
868
|
/** " ● codex adopts claude's: <body, cut to fit> · 14s" */
|
|
601
869
|
const say = (agent, label, body = "", took = "") => {
|
|
602
870
|
const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
|
|
@@ -605,6 +873,20 @@ export async function runGoal(goal, opts) {
|
|
|
605
873
|
opts.onActivity?.({ agent, text });
|
|
606
874
|
};
|
|
607
875
|
const onCouncil = (e) => {
|
|
876
|
+
if (e.type === "ratified")
|
|
877
|
+
councilSoFar.push({ agent: e.entry.agent, kind: e.entry.verdict === "object" ? "object" : e.entry.verdict === "skipped" ? "skipped" : "ok", text: e.entry.objections.join("\n") });
|
|
878
|
+
if (e.type === "entry" && e.entry.ok) {
|
|
879
|
+
const x = e.entry;
|
|
880
|
+
if (x.round === "proposal")
|
|
881
|
+
councilSoFar.push({ agent: x.agent, kind: "proposal", text: x.text.slice(0, 2000) });
|
|
882
|
+
for (const a of x.adopts ?? [])
|
|
883
|
+
councilSoFar.push({ agent: x.agent, kind: "adopt", of: a.of, text: a.text });
|
|
884
|
+
for (const f of x.flaws ?? [])
|
|
885
|
+
councilSoFar.push({ agent: x.agent, kind: "flaw", of: f.of, text: f.text });
|
|
886
|
+
}
|
|
887
|
+
const step = e.type === "round" ? (e.round === "proposal" ? 1 : 2) : e.type === "synthesizing" ? 3 : e.type === "ratify" ? 4 : 0;
|
|
888
|
+
if (step)
|
|
889
|
+
progress({ phase: "COUNCIL", step });
|
|
608
890
|
if (e.type === "round")
|
|
609
891
|
print(C.faint(` ${e.round === "proposal" ? "1/4 each agent proposes a design…" : "2/4 each agent reviews the others: what to adopt, what's flawed…"}`));
|
|
610
892
|
if (e.type === "synthesizing") {
|
|
@@ -660,20 +942,46 @@ export async function runGoal(goal, opts) {
|
|
|
660
942
|
catch {
|
|
661
943
|
/* no history: roster order */
|
|
662
944
|
}
|
|
663
|
-
|
|
945
|
+
try {
|
|
946
|
+
plan = await runCouncil(goal, crew, members, root, { brain, signal: life.signal, onEvent: onCouncil, pastSyntheses: past });
|
|
947
|
+
}
|
|
948
|
+
finally {
|
|
949
|
+
stopDock(); // the plan prints in full; the dock comes back for the build
|
|
950
|
+
}
|
|
664
951
|
}
|
|
665
952
|
else {
|
|
953
|
+
progress({ phase: "PLAN" });
|
|
954
|
+
opts.onStats?.(stats);
|
|
666
955
|
// A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
|
|
667
956
|
const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
|
|
668
|
-
plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal:
|
|
957
|
+
plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: life.signal, project }));
|
|
958
|
+
}
|
|
959
|
+
if (life.signal.aborted)
|
|
960
|
+
return endTalk();
|
|
961
|
+
planNow = plan;
|
|
962
|
+
progress({ phase: "PLAN", step: undefined, total: plan.tasks.length });
|
|
963
|
+
printPlan(print, goal, plan, workers, undefined, !!talk);
|
|
964
|
+
{
|
|
965
|
+
let history = runHistory(null);
|
|
966
|
+
try {
|
|
967
|
+
if (opts.room?.db)
|
|
968
|
+
history = runHistory(opts.room.db);
|
|
969
|
+
else if (fs.existsSync(dbPath())) {
|
|
970
|
+
const d = new RoomDB(dbPath());
|
|
971
|
+
history = runHistory(d);
|
|
972
|
+
d.close();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
/* no history: the defaults */
|
|
977
|
+
}
|
|
978
|
+
const qaRounds = shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles }) ? QA_ROUNDS : 0;
|
|
979
|
+
print(C.faint(` ${costPreview({ tasks: plan.tasks.length, agents: workers.length + remoteNow().length, qaRounds, council: plan.council, history, budget })}`));
|
|
669
980
|
}
|
|
670
|
-
if (opts.signal?.aborted)
|
|
671
|
-
return;
|
|
672
|
-
printPlan(print, goal, plan, workers);
|
|
673
981
|
if (opts.mode === "plan") {
|
|
674
982
|
print(C.faint("\n plan mode — nothing created or spawned. " +
|
|
675
983
|
(rl ? "shift+tab to switch mode and run it." : `Run it: huddle join ${JSON.stringify(goal)}`)));
|
|
676
|
-
return;
|
|
984
|
+
return endTalk();
|
|
677
985
|
}
|
|
678
986
|
if (opts.mode === "manual" && rl && !(await yes(rl, `\nStart with ${workers.map((w) => w.id).join(", ")}?`)))
|
|
679
987
|
return;
|
|
@@ -703,7 +1011,7 @@ export async function runGoal(goal, opts) {
|
|
|
703
1011
|
const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
|
|
704
1012
|
const taskIds = seedBoard(db, goal, plan.tasks);
|
|
705
1013
|
// Who planned it, for `huddle story` (the tasks themselves are task.added events).
|
|
706
|
-
db.publish("plan.made", { payload: { by: plan.by, note: plan.note ?? null, ms: plan.ms ?? null, tokens: plan.tokens ?? null, tasks: plan.tasks.length } });
|
|
1014
|
+
db.publish("plan.made", { payload: { by: plan.by, note: plan.note ?? null, ms: plan.ms ?? null, tokens: plan.tokens ?? null, tasks: plan.tasks.length, plain: plan.plain ?? null } });
|
|
707
1015
|
// Before the live listener below, so the council's memories aren't printed twice.
|
|
708
1016
|
if (plan.council)
|
|
709
1017
|
recordCouncil(db, plan.council, plan.tasks, taskIds);
|
|
@@ -715,19 +1023,20 @@ export async function runGoal(goal, opts) {
|
|
|
715
1023
|
print(` ${C.violet("◆")} ${C.faint("contract:")} ${ok ? "HUDDLE_CONTRACT.md on main" : "in the Room (couldn't commit HUDDLE_CONTRACT.md)"} ${C.faint(`· by ${by}` + (objected ? ` · ${plan.council.objections.filter((o) => o.taken).length}/${objected} objection(s) taken` : ""))}`);
|
|
716
1024
|
}
|
|
717
1025
|
const t0 = Date.now();
|
|
718
|
-
|
|
1026
|
+
board = db;
|
|
1027
|
+
// What you said while they designed it, and their questions for you from here on.
|
|
1028
|
+
for (const t of userNotes.splice(0))
|
|
1029
|
+
sayToAgents(db, t);
|
|
1030
|
+
sessionId = db.currentSession()?.id;
|
|
1031
|
+
// Someone to ask: a terminal you type in, or the dashboard open on this repo. Nobody: the agent's own pick, at once.
|
|
1032
|
+
room.askUser = (q) => (oneShotTty || opts.rl || openApp(root) ? talk.ask(q) : Promise.resolve({ choice: 1, text: "", by: "default" }));
|
|
1033
|
+
progress({ phase: "BUILD" });
|
|
719
1034
|
opts.onStats?.(stats);
|
|
720
1035
|
phase(print, "BUILD", `${plan.tasks.length} task(s) · ${workers.length + remoteNow().length} agent(s) · each in its own worktree, merged when tests pass`);
|
|
721
1036
|
print(C.faint(` room ${room.url} · tests: ${testCmd ?? "none yet"} · approvals: ${opts.approvals}`) +
|
|
722
1037
|
(rl ? C.faint(" · ctrl-t live view · ctrl-c stops") : ""));
|
|
723
1038
|
print();
|
|
724
|
-
|
|
725
|
-
// (The prompt app draws the same lines in its footer.)
|
|
726
|
-
if (!opts.print && process.stdout.isTTY)
|
|
727
|
-
dock = new Dock(() => [
|
|
728
|
-
C.faint(` ⏱ ${fmtDuration(Date.now() - t0)} · ${[...stats.agents.values()].reduce((n, a) => n + a.tasks, 0)} merged so far`),
|
|
729
|
-
...statusLines(stats, (process.stdout.columns || 80) - 1),
|
|
730
|
-
]).start();
|
|
1039
|
+
startDock();
|
|
731
1040
|
const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
|
|
732
1041
|
/** " ● codex <label> <body, cut to fit><suffix>" */
|
|
733
1042
|
const line = (agent, label, body = "", suffix = "") => {
|
|
@@ -750,8 +1059,7 @@ export async function runGoal(goal, opts) {
|
|
|
750
1059
|
for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*•]\s*/, "").trim()).filter(Boolean).slice(0, n))
|
|
751
1060
|
print(C.faint(` ${oneLine(l, space(" "))}`));
|
|
752
1061
|
};
|
|
753
|
-
let
|
|
754
|
-
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
1062
|
+
let talk_ = 0; // agent-to-agent messages this run, for the teamwork summary
|
|
755
1063
|
const unsub = db.onEvent((e) => {
|
|
756
1064
|
const p = JSON.parse(e.payload || "{}");
|
|
757
1065
|
if (e.type === "task.progress" && p.note) {
|
|
@@ -762,7 +1070,8 @@ export async function runGoal(goal, opts) {
|
|
|
762
1070
|
sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
|
|
763
1071
|
act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
|
|
764
1072
|
}
|
|
765
|
-
else if (e.type === "memory.added" && p.author !== "huddle") {
|
|
1073
|
+
else if (e.type === "memory.added" && p.author !== "huddle" && p.author !== USER_ID) {
|
|
1074
|
+
// (Your own words are on screen already, as the ✉ or → line.)
|
|
766
1075
|
act(undefined, `${C.violet("◆")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
|
|
767
1076
|
// Already on screen as its own line: a task's done note (✓ merged), a QA bug (✗ found), a message (a → b).
|
|
768
1077
|
if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ → \S+:/.test(String(p.body)))
|
|
@@ -780,13 +1089,29 @@ export async function runGoal(goal, opts) {
|
|
|
780
1089
|
if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
|
|
781
1090
|
return;
|
|
782
1091
|
if (p.from !== "huddle")
|
|
783
|
-
|
|
1092
|
+
talk_++;
|
|
784
1093
|
const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
|
|
785
1094
|
const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
|
|
786
1095
|
print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
|
|
787
1096
|
act(undefined, `${arrow}${oneLine(p.body, 200)}`);
|
|
788
1097
|
}
|
|
789
1098
|
});
|
|
1099
|
+
// The budget's hard line stops the run as Ctrl-C does: one signal for the board and QA.
|
|
1100
|
+
const stopRun = new AbortController();
|
|
1101
|
+
stopNow = () => stopRun.abort();
|
|
1102
|
+
if (life.signal.aborted)
|
|
1103
|
+
stopRun.abort();
|
|
1104
|
+
life.signal.addEventListener("abort", () => stopRun.abort());
|
|
1105
|
+
budget.add(plan.tokens ?? 0);
|
|
1106
|
+
budget.onLevel((level, b) => {
|
|
1107
|
+
if (level === "soft")
|
|
1108
|
+
print(C.yellow(` ! budget: ${budgetUse(b)}${b.used < SOFT_AT ? ", about 90% with the work still in flight" : ""} — no new tasks, QA rounds, buddy checks or pairings; running tasks finish`));
|
|
1109
|
+
else {
|
|
1110
|
+
print(C.yellow(` ! budget: ${budgetUse(b)} — past ${Math.round(HARD_AT * 100)}%, stopping the running agents`));
|
|
1111
|
+
stopRun.abort();
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
const unwatchBudget = budget.watch();
|
|
790
1115
|
const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
|
|
791
1116
|
const cost = (ms, tokens, cached) => C.faint(` · ${fmtDuration(ms)} · ${tok(tokens, cached)}`);
|
|
792
1117
|
if (remoteNow().length && !(remote && sync))
|
|
@@ -808,7 +1133,8 @@ export async function runGoal(goal, opts) {
|
|
|
808
1133
|
workers,
|
|
809
1134
|
testCmd: (worktree) => detectTestCmd(worktree),
|
|
810
1135
|
approvals: opts.approvals,
|
|
811
|
-
signal:
|
|
1136
|
+
signal: stopRun.signal,
|
|
1137
|
+
budget,
|
|
812
1138
|
approveMerge: opts.mode === "manual" && rl
|
|
813
1139
|
? async ({ agent, title, diffstat }) => {
|
|
814
1140
|
print(`\n ${tag(agent)} ${C.violet("◎ ready")} ${title}`);
|
|
@@ -826,7 +1152,7 @@ export async function runGoal(goal, opts) {
|
|
|
826
1152
|
stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
|
|
827
1153
|
const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
|
|
828
1154
|
if (e.type === "start" && a)
|
|
829
|
-
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined }); // -1 = not reported yet
|
|
1155
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined, est: db.getTask(e.task)?.est_seconds ?? undefined }); // -1 = not reported yet
|
|
830
1156
|
if (e.type === "tokens" && a)
|
|
831
1157
|
a.liveTokens = e.tokens;
|
|
832
1158
|
if ((e.type === "merged" || e.type === "failed") && a)
|
|
@@ -931,6 +1257,13 @@ export async function runGoal(goal, opts) {
|
|
|
931
1257
|
act(e.agent, `${C.violet("⇉")} pairs with ${e.with}: ${e.part}`);
|
|
932
1258
|
sayOne(e.agent, `${C.violet("⇉ pairs with " + e.with)} on “${oneLine(e.title, 40)}”: takes`, e.part, C.faint(" · " + oneLine(e.reason, 50)));
|
|
933
1259
|
}
|
|
1260
|
+
// Quiet past the silence window, but working (a long test run): not taken over (busy.ts).
|
|
1261
|
+
if (e.type === "busy") {
|
|
1262
|
+
act(e.agent, `${C.faint("⧗ running a long command")} ${C.faint("· " + e.why)}`);
|
|
1263
|
+
sayOne(e.agent, C.faint("⧗ running a long command ·"), `quiet ${fmtDuration(e.quietMs)}, but busy: ${e.why}`);
|
|
1264
|
+
}
|
|
1265
|
+
if (e.type === "start" || e.type === "merged" || e.type === "failed")
|
|
1266
|
+
progress({});
|
|
934
1267
|
if (e.type === "stuck")
|
|
935
1268
|
print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" · ")));
|
|
936
1269
|
},
|
|
@@ -940,11 +1273,17 @@ export async function runGoal(goal, opts) {
|
|
|
940
1273
|
// they file become routed fix tasks, run through this same board.
|
|
941
1274
|
let qaNote = "";
|
|
942
1275
|
let qaSum = ""; // the RESULT's checks line
|
|
1276
|
+
let qaCut = false; // QA left undone by the budget
|
|
943
1277
|
if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
|
|
944
|
-
if (
|
|
1278
|
+
if (budget.stopping) {
|
|
1279
|
+
qaCut = true;
|
|
1280
|
+
print(C.faint("\n QA skipped — the token budget is nearly spent"));
|
|
1281
|
+
}
|
|
1282
|
+
else if (res.failed)
|
|
945
1283
|
print(C.faint(`\n QA skipped — ${res.failed} task(s) didn't merge; it checks finished builds only`));
|
|
946
1284
|
else {
|
|
947
1285
|
phase(print, "QA", "agents that didn't write it check the build · --no-qa skips");
|
|
1286
|
+
progress({ phase: "QA" });
|
|
948
1287
|
const qa = await runQa({
|
|
949
1288
|
db,
|
|
950
1289
|
room,
|
|
@@ -954,18 +1293,24 @@ export async function runGoal(goal, opts) {
|
|
|
954
1293
|
workers,
|
|
955
1294
|
approvals: opts.approvals,
|
|
956
1295
|
testCmd: (wt) => detectTestCmd(wt),
|
|
957
|
-
signal:
|
|
1296
|
+
signal: stopRun.signal,
|
|
1297
|
+
budget,
|
|
958
1298
|
runFixes: () => runBoard(boardOpts),
|
|
959
1299
|
onEvent: (e) => {
|
|
960
1300
|
seen.push(e);
|
|
961
1301
|
if (process.env.HUDDLE_EVENTS_FILE)
|
|
962
1302
|
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
|
|
963
1303
|
qaLine(e, tag, print, act, stats);
|
|
1304
|
+
if (e.type === "round")
|
|
1305
|
+
progress({ round: e.round });
|
|
1306
|
+
else if (e.type === "bug" || e.type === "fixes")
|
|
1307
|
+
progress({});
|
|
964
1308
|
},
|
|
965
1309
|
});
|
|
966
1310
|
res.merged += qa.fixed;
|
|
967
1311
|
res.failed += qa.failed;
|
|
968
1312
|
res.stopped ||= qa.stopped;
|
|
1313
|
+
qaCut = !!qa.budget;
|
|
969
1314
|
qaSum = `${qa.rounds} round(s) · ${qa.bugs} bug(s) found, ${qa.fixed} fixed · ` + (qa.clean ? C.green("last round clean") : C.yellow("last round's fixes not re-checked"));
|
|
970
1315
|
qaNote = qa.clean
|
|
971
1316
|
? `QA: ${qa.rounds} round(s) by agents re-checking the integrated build; ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, the last round came back clean.`
|
|
@@ -973,6 +1318,10 @@ export async function runGoal(goal, opts) {
|
|
|
973
1318
|
}
|
|
974
1319
|
}
|
|
975
1320
|
unsub();
|
|
1321
|
+
unwatchBudget();
|
|
1322
|
+
room.askUser = undefined;
|
|
1323
|
+
endTalk();
|
|
1324
|
+
const undone = budget.level === "ok" ? [] : db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
976
1325
|
const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
|
|
977
1326
|
const parked = db.listTasks().filter((t) => t.status === "needs_you");
|
|
978
1327
|
const takeovers = new Map(); // task id -> the branch a rescuer continued on
|
|
@@ -982,13 +1331,23 @@ export async function runGoal(goal, opts) {
|
|
|
982
1331
|
takeovers.set(ev.task_id, String(p.branch));
|
|
983
1332
|
}
|
|
984
1333
|
const learned = learnFromRun({ db, root, since: runStart, events: seen });
|
|
1334
|
+
progress({ phase: "DONE" });
|
|
1335
|
+
board = null;
|
|
1336
|
+
// The hand-off brief for whoever works here next, solo (ui/brief.ts).
|
|
1337
|
+
const brief = writeBrief({
|
|
1338
|
+
root,
|
|
1339
|
+
events: db.allEvents(),
|
|
1340
|
+
getHandoff: (id) => db.getHandoff(id),
|
|
1341
|
+
testCmd: detectTestCmd(root),
|
|
1342
|
+
parked: parked.map((t) => ({ title: t.title, branch: takeovers.get(t.id) ?? worktreeFor(root, t.claimed_by ?? "", t.id).branch })),
|
|
1343
|
+
enabled: readPrefs().brief !== false,
|
|
1344
|
+
});
|
|
985
1345
|
if (ownRoom) {
|
|
986
1346
|
room.close();
|
|
987
1347
|
db.close();
|
|
988
1348
|
}
|
|
989
1349
|
opts.onStats?.(stats);
|
|
990
|
-
|
|
991
|
-
dock = null;
|
|
1350
|
+
stopDock();
|
|
992
1351
|
// RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
|
|
993
1352
|
phase(print, "RESULT", "what got built, how it was checked, who did what");
|
|
994
1353
|
const head = res.stopped ? C.yellow("■ stopped") : res.failed ? C.yellow("◎") : C.green("✓");
|
|
@@ -1007,9 +1366,12 @@ export async function runGoal(goal, opts) {
|
|
|
1007
1366
|
print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` — ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
|
|
1008
1367
|
print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
|
|
1009
1368
|
}
|
|
1010
|
-
if (res.merged)
|
|
1011
|
-
await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
|
|
1012
|
-
|
|
1369
|
+
if (res.merged) {
|
|
1370
|
+
const report = await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
|
|
1371
|
+
if (sessionId && report.trim())
|
|
1372
|
+
saveReport(root, sessionId, report);
|
|
1373
|
+
}
|
|
1374
|
+
const team = teamworkLines(plan.council, seen, talk_);
|
|
1013
1375
|
if (team.length) {
|
|
1014
1376
|
print(`\n${C.violet("TEAMWORK")} ${C.faint("· how the agents worked together · huddle story for all of it")}`);
|
|
1015
1377
|
for (const t of team) {
|
|
@@ -1017,7 +1379,9 @@ export async function runGoal(goal, opts) {
|
|
|
1017
1379
|
print(`${label}${oneLine(t.text, space(label))}`);
|
|
1018
1380
|
}
|
|
1019
1381
|
}
|
|
1020
|
-
print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
|
|
1382
|
+
print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}${budget.limit ? ` · ${budgetUse(budget)}` : ""}`)}`);
|
|
1383
|
+
for (const l of budgetNotes(budget, { left: undone, qaCut }))
|
|
1384
|
+
print(C.yellow(` ! ${l}`));
|
|
1021
1385
|
for (const [id, a] of stats.agents) {
|
|
1022
1386
|
if (!a.tasks && !a.reviews && !a.qa) {
|
|
1023
1387
|
print(` ${tag(id)} ${C.faint("idle this run")}`);
|
|
@@ -1034,6 +1398,9 @@ export async function runGoal(goal, opts) {
|
|
|
1034
1398
|
}
|
|
1035
1399
|
if (learned.length)
|
|
1036
1400
|
print(C.faint(` ◆ learned ${learned.length} lesson(s) for next time · huddle lessons to see them`));
|
|
1401
|
+
if (brief)
|
|
1402
|
+
for (const l of briefLines(brief))
|
|
1403
|
+
print(l);
|
|
1037
1404
|
if (res.merged && remote && sync) {
|
|
1038
1405
|
const p = pushMerged(root);
|
|
1039
1406
|
print(p.ok ? ` ${C.green("↑")} git push · all in one go ${C.faint("→ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
|
|
@@ -1185,6 +1552,7 @@ You run read-only because Huddle only asks you for this write-up; the run itself
|
|
|
1185
1552
|
* built, how to run it, what's left. Written read-only by one agent from the
|
|
1186
1553
|
* merged diff and the agents' done notes; falls back to the raw notes.
|
|
1187
1554
|
*/
|
|
1555
|
+
/** WHAT YOU GOT: the brain's write-up of the run (or the agents' notes). Returns the text, for the dashboard. */
|
|
1188
1556
|
async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
1189
1557
|
const stat = git(["diff", "--stat", `${startHead}..HEAD`], root).out;
|
|
1190
1558
|
let summary = "";
|
|
@@ -1212,6 +1580,7 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
|
1212
1580
|
say(n);
|
|
1213
1581
|
if (stat)
|
|
1214
1582
|
print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" · see it all: git log")));
|
|
1583
|
+
return summary || notes.join("\n");
|
|
1215
1584
|
}
|
|
1216
1585
|
/** Answer a question about the repo, read-only. */
|
|
1217
1586
|
async function answer(print, question, results, signal) {
|
|
@@ -1243,11 +1612,8 @@ export function printBoard(print = (l = "") => console.log(l)) {
|
|
|
1243
1612
|
const s = db.currentSession();
|
|
1244
1613
|
print(`\n${C.violet("BOARD")} ${C.faint("· " + (s?.goal ?? "(no goal yet)"))}`);
|
|
1245
1614
|
for (const t of db.listTasks()) {
|
|
1246
|
-
const w = 16;
|
|
1247
|
-
const filled = Math.round((t.progress_pct / 100) * w);
|
|
1248
|
-
const bar = C.violet("█".repeat(filled)) + C.faint("░".repeat(w - filled));
|
|
1249
1615
|
const who = t.claimed_by ? `${dot(t.claimed_by)} ${t.claimed_by}` : C.faint("unclaimed");
|
|
1250
|
-
print(` ${statusLabel(t.status)} ${
|
|
1616
|
+
print(` ${statusLabel(t.status)} ${taskMeter(t.progress_pct, 16, t.status)} ${t.title} ${who}`);
|
|
1251
1617
|
}
|
|
1252
1618
|
const mem = db.recall();
|
|
1253
1619
|
if (mem.length) {
|
|
@@ -1304,7 +1670,8 @@ const HELP = `
|
|
|
1304
1670
|
${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
|
|
1305
1671
|
${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
|
|
1306
1672
|
|
|
1307
|
-
${C.faint("While agents work you
|
|
1673
|
+
${C.faint("While agents work, what you type goes to them (@claude … for one); a question for you shows with numbered options.")}
|
|
1674
|
+
${C.faint("Questions? end with ? · details: the plan's technical decisions · next <goal>: queue another run.")}
|
|
1308
1675
|
`;
|
|
1309
1676
|
// Things people type out of habit that are shell commands, not goals.
|
|
1310
1677
|
const SHELL_WORDS = new Set(["mkdir", "rm", "mv", "cp", "touch", "echo", "git", "ls", "pwd", "cat", "npm", "npx", "node", "pnpm", "yarn", "python", "python3", "code", "tree", "head", "tail"]);
|
|
@@ -1336,20 +1703,21 @@ export async function runApp() {
|
|
|
1336
1703
|
const queue = [];
|
|
1337
1704
|
let stats = null;
|
|
1338
1705
|
let ticker = null;
|
|
1339
|
-
|
|
1340
|
-
|
|
1706
|
+
const talk = new Talk(); // agents' questions for you, your lines to them, during a run
|
|
1707
|
+
// Footer under the prompt, like Claude Code's mode line; during a run, the overall bar
|
|
1708
|
+
// ("BUILD ███░░ 6/9 tasks · 12m") and then one line per agent (runLines).
|
|
1709
|
+
const footer = () => {
|
|
1710
|
+
if (!run)
|
|
1711
|
+
return idleFooter();
|
|
1712
|
+
const W = (out.columns || 80) - 1;
|
|
1713
|
+
const chip = ` ${MODE_INFO[state.mode].chip} `;
|
|
1714
|
+
const queued = queue.length ? C.faint(` · ${queue.length} queued`) : "";
|
|
1341
1715
|
if (!stats)
|
|
1342
|
-
return C.violet("● planning…");
|
|
1343
|
-
const
|
|
1344
|
-
return `${
|
|
1716
|
+
return [`${chip}${C.violet("● planning…")}${queued}`];
|
|
1717
|
+
const [top] = runLines(stats, W - width(chip) - width(queued) + 2);
|
|
1718
|
+
return [narratorLine(stats, W), `${chip}${top.trimStart()}${queued}`, ...runLines(stats, W).slice(1)];
|
|
1345
1719
|
};
|
|
1346
|
-
|
|
1347
|
-
const footer = () => run
|
|
1348
|
-
? [
|
|
1349
|
-
` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` · ${queue.length} queued`) : ""}`,
|
|
1350
|
-
...(stats ? statusLines(stats, (out.columns || 80) - 1) : []),
|
|
1351
|
-
]
|
|
1352
|
-
: [` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
|
|
1720
|
+
const idleFooter = () => [` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
|
|
1353
1721
|
let reserved = 1; // footer lines reserved under the prompt
|
|
1354
1722
|
const drawFooter = () => {
|
|
1355
1723
|
if (!promptShown || asking || !out.isTTY)
|
|
@@ -1771,6 +2139,7 @@ export async function runApp() {
|
|
|
1771
2139
|
room: r ?? undefined,
|
|
1772
2140
|
onActivity: (a) => dash.push(a),
|
|
1773
2141
|
qa,
|
|
2142
|
+
talk,
|
|
1774
2143
|
});
|
|
1775
2144
|
}
|
|
1776
2145
|
catch (e) {
|
|
@@ -1797,6 +2166,11 @@ export async function runApp() {
|
|
|
1797
2166
|
const line = raw.trim();
|
|
1798
2167
|
promptShown = false;
|
|
1799
2168
|
out.write("\x1b[2K"); // clear the footer line we just moved onto
|
|
2169
|
+
// An agent asked you something: this line is the answer (Enter = the recommended option).
|
|
2170
|
+
if (run && talk.current() && !/^(stop|exit|quit|view|help|\?|details)$/i.test(line)) {
|
|
2171
|
+
talk.answer(line);
|
|
2172
|
+
return showPrompt();
|
|
2173
|
+
}
|
|
1800
2174
|
if (!line)
|
|
1801
2175
|
return showPrompt();
|
|
1802
2176
|
const [cmd, ...rest] = line.split(/\s+/);
|
|
@@ -1925,11 +2299,20 @@ export async function runApp() {
|
|
|
1925
2299
|
shell(line);
|
|
1926
2300
|
print(C.faint(` (ran as a shell command — say "build ${line}" if you meant a goal)`));
|
|
1927
2301
|
}
|
|
1928
|
-
else if (cmd === "
|
|
2302
|
+
else if (cmd === "details" && run)
|
|
2303
|
+
talk.details?.();
|
|
2304
|
+
else if (cmd === "ask" || (cmd !== "build" && cmd !== "join" && cmd !== "next" && isQuestion(line))) {
|
|
1929
2305
|
await answer(print, cmd === "ask" ? arg : line, results);
|
|
1930
2306
|
}
|
|
2307
|
+
else if (run && cmd !== "build" && cmd !== "join" && cmd !== "next") {
|
|
2308
|
+
// During a run, what you type goes to the agents (room/user.ts). A new goal waits: "next <goal>".
|
|
2309
|
+
if (talk.say)
|
|
2310
|
+
talk.say(line);
|
|
2311
|
+
else
|
|
2312
|
+
print(C.faint(" the agents are wrapping up — say it again in a moment, or `next <goal>` for a new run"));
|
|
2313
|
+
}
|
|
1931
2314
|
else {
|
|
1932
|
-
const goal = cmd === "build" || cmd === "join" ? arg : line;
|
|
2315
|
+
const goal = cmd === "build" || cmd === "join" || cmd === "next" ? arg : line;
|
|
1933
2316
|
if (!goal)
|
|
1934
2317
|
print(C.faint(" usage: build <goal>"));
|
|
1935
2318
|
else if (run) {
|