huddle-cli 0.1.1 → 0.1.5

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/QUICKSTART.md +63 -2
  3. package/README.md +15 -0
  4. package/dist/adapters/codex.js +3 -0
  5. package/dist/app/live.js +142 -0
  6. package/dist/app/page.js +775 -0
  7. package/dist/app/qr.js +47 -0
  8. package/dist/app/server.js +433 -0
  9. package/dist/args.js +18 -0
  10. package/dist/attach/connect.js +3 -3
  11. package/dist/attach/plug.js +8 -1
  12. package/dist/cli.js +46 -4
  13. package/dist/connectors/cli.js +121 -0
  14. package/dist/connectors/github.js +162 -0
  15. package/dist/connectors/hub.js +234 -0
  16. package/dist/connectors/types.js +3 -0
  17. package/dist/mcp/context-tools.js +2 -0
  18. package/dist/mcp/server.js +42 -3
  19. package/dist/orchestrator/brain.js +3 -1
  20. package/dist/orchestrator/budget.js +283 -0
  21. package/dist/orchestrator/busy.js +106 -0
  22. package/dist/orchestrator/council.js +11 -5
  23. package/dist/orchestrator/launcher.js +3 -1
  24. package/dist/orchestrator/learn.js +3 -7
  25. package/dist/orchestrator/planner.js +32 -5
  26. package/dist/orchestrator/qa.js +9 -1
  27. package/dist/orchestrator/scheduler.js +105 -19
  28. package/dist/platform.js +104 -0
  29. package/dist/room/mail.js +19 -6
  30. package/dist/room/server.js +82 -2
  31. package/dist/room/user.js +54 -0
  32. package/dist/ui/app.js +511 -69
  33. package/dist/ui/brief.js +494 -0
  34. package/dist/ui/catalog.js +1 -1
  35. package/dist/ui/dashboard.js +92 -21
  36. package/dist/ui/export.js +1194 -0
  37. package/dist/ui/narrator.js +56 -0
  38. package/dist/ui/story.js +118 -93
  39. package/dist/ui/talk.js +176 -0
  40. package/dist/ui/term.js +31 -0
  41. package/dist/ui/transcript.js +434 -0
  42. package/dist/ui/undo.js +131 -0
  43. package/dist/ui/watch.js +4 -0
  44. package/dist/update.js +84 -0
  45. package/package.json +3 -1
package/dist/ui/app.js CHANGED
@@ -10,6 +10,8 @@
10
10
  //
11
11
  // Everything happens here. Claude, Codex, Gemini and Cursor run in the
12
12
  // background as Huddle's workers; you watch the board, never their terminals.
13
+ import { issueGoal, issueMentions, issueRef, runAsync } from "../connectors/github.js";
14
+ import { connectorLine, startHub, statusLine, toolsNote } from "../connectors/hub.js";
13
15
  import fs from "node:fs";
14
16
  import os from "node:os";
15
17
  import path from "node:path";
@@ -30,15 +32,21 @@ import { pickAsker } from "../orchestrator/ask.js";
30
32
  import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
31
33
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
32
34
  import { runBoard } from "../orchestrator/scheduler.js";
33
- import { runQa, shouldQa } from "../orchestrator/qa.js";
35
+ import { QA_ROUNDS, runQa, shouldQa } from "../orchestrator/qa.js";
36
+ import { Budget, HARD_AT, SOFT_AT, budgetFlag, budgetNotes, budgetUse, costPreview, parseTokens, runHistory } from "../orchestrator/budget.js";
34
37
  import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
35
38
  import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
36
39
  import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
37
40
  import { logoLines } from "./logo.js";
38
- import { Dashboard } from "./dashboard.js";
41
+ import { Dashboard, estHint, progressLine, taskCounts, taskMeter } from "./dashboard.js";
39
42
  import { learnFromRun } from "../orchestrator/learn.js";
43
+ import { briefLines, liftPointers, placePointers, writeBrief } from "./brief.js";
40
44
  import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
41
45
  import { splitPathTokens } from "../room/context.js";
46
+ import { narration, narratorLine } from "./narrator.js";
47
+ import { clearCommands, openApp, saveReport, takeCommands, writeLive } from "../app/live.js";
48
+ import { KeyLine, Talk, addressee, questionLines, settledLine } from "./talk.js";
49
+ import { sayToAgents, USER_ID } from "../room/user.js";
42
50
  // 0 = pick a free port per run, so sessions never collide.
43
51
  export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
44
52
  // ---- paths & repo ----------------------------------------------------------
@@ -115,7 +123,7 @@ export function readPrefs() {
115
123
  return {};
116
124
  }
117
125
  }
118
- function writePrefs(p) {
126
+ export function writePrefs(p) {
119
127
  fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
120
128
  fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
121
129
  }
@@ -321,9 +329,12 @@ export async function offerSetup(rl, results, force = false) {
321
329
  }
322
330
  /**
323
331
  * The live status block under the run's output, one line per agent:
324
- * ● claude 60% Build the CLI adapter · note 20s ago · 1m12s
325
- * ○ cursor idle · 2 done
326
- * Every line fits `W` cells: the title gives way, never the numbers.
332
+ * ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
333
+ * ● codex ······ — Build the HTTP API · no report yet · 3m20s
334
+ * ○ cursor idle · 2 merged
335
+ * The bar is the agent's own last huddle_progress %, never a guess. Every line
336
+ * fits `W` cells: the bar goes first on a narrow screen, then the title gives
337
+ * way, never the numbers.
327
338
  */
328
339
  export function statusLines(stats, W, now = Date.now()) {
329
340
  const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
@@ -333,15 +344,33 @@ export function statusLines(stats, W, now = Date.now()) {
333
344
  const done = a.tasks + a.reviews + (a.qa ?? 0);
334
345
  return head + C.faint(done ? `idle · ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
335
346
  }
336
- const pct = a.pct === undefined ? C.faint(" · ") : C.violet(`${String(a.pct).padStart(3)}%`);
337
347
  const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
338
- const said = a.noteAt === undefined ? "no note yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
339
- const tail = (quiet > 120_000 ? C.yellow(` · ${said}`) : C.faint(` · ${said}`)) +
348
+ const said = a.noteAt === undefined ? "no report yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
349
+ const est = estHint(a.est, now - a.busySince);
350
+ const meter = taskMeter(a.pct ?? null, W >= 100 ? 8 : W >= 70 ? 6 : 0);
351
+ const base = (quiet > 120_000 ? C.yellow(` · ${said}`) : C.faint(` · ${said}`)) +
340
352
  C.faint(` · ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` · ${fmtTokens(a.liveTokens)} tok` : ""}`);
341
- const room = Math.max(8, W - width(head) - 5 - width(tail));
342
- return `${head}${pct} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`;
353
+ // The estimate is a hint: it goes before the title would have to drop under 8 cells.
354
+ const withEst = est ? base + C.faint(" · ") + est : base;
355
+ const tail = W - width(head) - width(meter) - 1 - width(withEst) >= 8 ? withEst : base;
356
+ const room = Math.max(8, W - width(head) - width(meter) - 1 - width(tail));
357
+ return truncate(`${head}${meter} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`, W);
343
358
  });
344
359
  }
360
+ /**
361
+ * The pinned block during a run: the overall bar, then (once agents are on tasks) a status line each.
362
+ * BUILD ███████░░░░░ 6/9 tasks · 12m03s
363
+ * ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
364
+ */
365
+ export function runLines(stats, W, now = Date.now()) {
366
+ const r = stats.run;
367
+ const top = r ? ` ${progressLine(r, now - stats.startedAt, W - 2)}` : C.faint(` ⏱ ${fmtDuration(now - stats.startedAt)}`);
368
+ return [top, ...(!r || r.phase === "BUILD" || r.phase === "QA" ? statusLines(stats, W, now) : [])];
369
+ }
370
+ /** The pinned block with the narrator on top: what's happening now in plain words, and whether you're needed. */
371
+ export function liveLines(stats, W, now = Date.now()) {
372
+ return [narratorLine(stats, W, now), ...runLines(stats, W, now)];
373
+ }
345
374
  export function newStats(workers) {
346
375
  return {
347
376
  startedAt: Date.now(),
@@ -441,6 +470,7 @@ async function prepareRepo(print, rl, goal = "") {
441
470
  print(` ${C.green("✓")} git repo ready`);
442
471
  }
443
472
  ensureInit();
473
+ liftPointers(repoRoot()); // the brief's pointer is out of CLAUDE.md / AGENTS.md while the team works (ui/brief.ts)
444
474
  const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
445
475
  if (dirty.length) {
446
476
  if (!rl) {
@@ -519,28 +549,55 @@ function phase(print, name, sub = "") {
519
549
  const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
520
550
  print(`\n${head}${rest}`);
521
551
  }
522
- function printPlan(print, goal, plan, workers) {
552
+ const plural = (k, one) => `${k} ${k === 1 ? one : one + "s"}`;
553
+ function printPlan(print, goal, plan, workers, verbose = process.env.HUDDLE_VERBOSE === "1", canType = false) {
523
554
  const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
524
555
  phase(print, "PLAN", `${plan.tasks.length} task(s) · by ${plan.by}${took}${plan.note ? " — " + plan.note : ""}`);
525
- print(` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`);
556
+ // First, in plain words: what this run makes and what using it looks like (the planner's "plain").
557
+ for (const l of plainLines(goal, plan))
558
+ print(l);
559
+ print(C.faint(" who does what:"));
526
560
  const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
527
561
  const likely = likelyAgents(plan.tasks, workers);
528
562
  plan.tasks.forEach((t, i) => {
529
563
  const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
530
564
  const fit = likely[i];
531
- const idea = t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
532
- const who = fit ? ` ${C.faint("→ likely")} ${dot(fit)} ${fit}` : "";
565
+ // Whose idea it was is in TEAMWORK and huddle story; here it only pushed the title off the screen.
566
+ const idea = verbose && t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
567
+ const who = fit ? ` ${C.faint("→")} ${dot(fit)} ${fit}` : "";
533
568
  const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
534
569
  print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
535
570
  });
536
571
  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.
572
+ // The council's agreements, which every agent can huddle_recall mid-task. For the
573
+ // person watching they're engineering detail: one line, the rest on request.
574
+ const d = plan.council?.decisions?.length ?? 0;
575
+ const r = plan.council?.risks?.length ?? 0;
576
+ if (verbose)
577
+ for (const l of planDetails(plan))
578
+ print(l);
579
+ else if (d || r)
580
+ 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")}`);
581
+ }
582
+ /** " What you'll get: …" / " For example: …" — the plan for someone who isn't a programmer. */
583
+ export function plainLines(goal, plan) {
584
+ // No summary from the planner (a template plan, an older brain): the goal as you typed it.
585
+ if (!plan.plain?.what)
586
+ return [` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`];
587
+ const label = (s) => ` ${C.bold(pad(s, 15))} `;
588
+ const out = [`${label("What you'll get")}${oneLine(plan.plain.what, space(label("What you'll get")))}`];
589
+ if (plan.plain?.example)
590
+ out.push(`${label("For example")}${oneLine(plan.plain.example, space(label("For example")))}`);
591
+ return out;
592
+ }
593
+ /** The council's decisions and risks in full, for `details` (and HUDDLE_VERBOSE=1). */
594
+ export function planDetails(plan) {
595
+ const out = [];
538
596
  for (const d of plan.council?.decisions ?? [])
539
- print(` ${C.violet("◆")} ${C.faint("decided:")} ${oneLine(d, space(" ◆ decided: "))}`);
540
- for (const r of plan.council?.risks ?? []) {
541
- const label = ` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} `;
542
- print(`${label}${oneLine(r.body, space(label))}`);
543
- }
597
+ out.push(` ${C.violet("◆")} ${C.faint("decided:")} ${d}`);
598
+ for (const r of plan.council?.risks ?? [])
599
+ out.push(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${r.body}`);
600
+ return out;
544
601
  }
545
602
  /** Show a spinner line while a promise settles (TTY only). */
546
603
  async function withSpinner(label, p) {
@@ -556,7 +613,24 @@ async function withSpinner(label, p) {
556
613
  process.stdout.write("\x1b[1A\x1b[2K"); // remove the spinner line
557
614
  }
558
615
  }
616
+ /** A run, and the brief's pointers put back however it ended (plan mode, a stop, a crash). */
617
+ /** The current run's typing line and open questions, closed however the run ends (a throw included). */
618
+ let endTalkNow = null;
619
+ let hubNow = null;
559
620
  export async function runGoal(goal, opts) {
621
+ try {
622
+ await runGoalOnce(goal, opts);
623
+ }
624
+ finally {
625
+ endTalkNow?.();
626
+ endTalkNow = null;
627
+ hubNow?.close();
628
+ hubNow = null;
629
+ if (isGitRepo(process.cwd()))
630
+ placePointers(repoRoot(), { enabled: readPrefs().brief !== false });
631
+ }
632
+ }
633
+ async function runGoalOnce(goal, opts) {
560
634
  // Every line fits the terminal (logs keep everything). In a one-shot run on a
561
635
  // terminal, the per-agent status lines sit under the output (the Dock).
562
636
  let dock = null;
@@ -567,6 +641,13 @@ export async function runGoal(goal, opts) {
567
641
  if (flagIn)
568
642
  goal = goal.replace(flagIn[0], " ").replace(/\s+/g, " ").trim();
569
643
  const councilFlag = opts.council ?? (flagIn ? !flagIn[2] : undefined);
644
+ // --budget=5M, typed at the prompt too. Past 90% no new work starts; past 120% the agents stop (budget.ts).
645
+ const typedBudget = budgetFlag(goal);
646
+ goal = typedBudget.goal;
647
+ const budgetAsked = opts.budget ?? typedBudget.budget ?? readPrefs().budget;
648
+ const budget = new Budget(budgetAsked === undefined ? null : parseTokens(budgetAsked));
649
+ if (budgetAsked !== undefined && !budget.limit)
650
+ print(C.yellow(` ! --budget=${budgetAsked} isn't a token count (try 5M or 800k) — no budget this run`));
570
651
  const workers = workersFrom(opts.results);
571
652
  // One-shot `huddle join` (no rl): a failure exits 1, and the hints name CLI commands.
572
653
  if (!workers.length && !opts.room?.server.remote.workers().length) {
@@ -583,9 +664,240 @@ export async function runGoal(goal, opts) {
583
664
  }
584
665
  const root = repoRoot();
585
666
  const rl = opts.rl;
667
+ // `huddle build #12`: the GitHub issue, verbatim, is the goal (connectors/github.ts).
668
+ const issueNo = issueRef(goal);
669
+ if (issueNo !== null) {
670
+ const r = await runAsync(["gh", "issue", "view", String(issueNo), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 30_000 });
671
+ if (r.code !== 0) {
672
+ print(C.red(`✗ couldn't read GitHub issue #${issueNo}: `) + ((r.stderr || r.stdout).trim().split("\n")[0] || "gh failed") + C.faint(r.code === 127 ? " (install the GitHub CLI: https://cli.github.com)" : ""));
673
+ if (!opts.rl)
674
+ process.exitCode = 1;
675
+ return;
676
+ }
677
+ const issue = JSON.parse(r.stdout);
678
+ goal = issueGoal(issue);
679
+ print(` ${C.violet("◆")} ${C.faint("goal from GitHub issue")} #${issue.number}: ${issue.title}`);
680
+ }
681
+ else {
682
+ // "fix #12 and add a test": the goal stays yours, the issue comes along (when gh can read it).
683
+ for (const n of issueMentions(goal)) {
684
+ const r = await runAsync(["gh", "issue", "view", String(n), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 20_000 });
685
+ if (r.code !== 0)
686
+ continue;
687
+ try {
688
+ const issue = JSON.parse(r.stdout);
689
+ goal += `\n\n--- ${issueGoal(issue).replace(/^Resolve /, "")}`;
690
+ print(` ${C.violet("◆")} ${C.faint("with GitHub issue")} #${issue.number}: ${issue.title}`);
691
+ }
692
+ catch {
693
+ /* not an issue: leave the goal as typed */
694
+ }
695
+ }
696
+ }
697
+ // GitHub, Jira, … for every agent this run, through the Plug (connectors/hub.ts).
698
+ // Started now, so they connect while the plan is made, and the planner knows what agents can read.
699
+ const hub = startHub({ root });
700
+ hubNow = hub;
701
+ let saidTools = false;
702
+ const outside = async () => {
703
+ await Promise.race([hub.ready, new Promise((r) => setTimeout(r, 30_000))]);
704
+ if (!saidTools && hub.status().length)
705
+ print(C.faint(` connectors: ${hub.status().map(statusLine).join(" · ")}`));
706
+ saidTools = true;
707
+ return toolsNote(hub);
708
+ };
586
709
  // Teammates' agents (from `huddle join` on their machines) work alongside yours.
587
710
  const bridge = opts.room?.server.remote;
588
711
  const remoteNow = () => (bridge?.workers() ?? []).map((r) => ({ id: r.id, remote: r }));
712
+ // The run's live numbers from its first step: the per-agent status lines and the overall bar.
713
+ const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
714
+ let board = null; // this run's Room, once its board is seeded: the bar counts its tasks
715
+ let logged = ""; // the last progress line a log got
716
+ const progress = (p) => {
717
+ let counts = {};
718
+ try {
719
+ if (board)
720
+ counts = taskCounts(board.listTasks());
721
+ }
722
+ catch {
723
+ /* a closed db: keep the last counts */
724
+ }
725
+ stats.run = { ...(stats.run ?? { phase: "PLAN", done: 0, total: 0, failed: 0 }), ...p, ...counts };
726
+ // A log (piped, no dock) gets one plain line each time the board's numbers move — never redraws.
727
+ const r = stats.run;
728
+ if (!opts.print && !process.stdout.isTTY && board && (r.phase === "BUILD" || r.phase === "QA")) {
729
+ const key = progressLine(r, null, 200, { on: false });
730
+ if (key !== logged)
731
+ print(C.faint(` ${(logged = key)} · ${fmtDuration(Date.now() - stats.startedAt)}`));
732
+ }
733
+ };
734
+ // You, during the run: agents' questions for you and what you type to them. `huddle join`
735
+ // on a terminal gets a typing line under the pinned block (KeyLine); the app has its prompt.
736
+ const oneShotTty = !opts.rl && !opts.print && !!process.stdin.isTTY && !!process.stdout.isTTY;
737
+ // Every run has one: with no terminal to type in, the dashboard (huddle app) answers and talks.
738
+ const talk = opts.talk ?? new Talk();
739
+ let keys = null;
740
+ let interrupted = 0;
741
+ let stopNow = null; // set once the build can be stopped cleanly
742
+ let liveTick = null;
743
+ let sessionId;
744
+ let planNow = null;
745
+ const userNotes = []; // typed before the build starts: delivered when it does
746
+ // The whole run's off switch: the council, the plan, the build and QA all stop on it.
747
+ const life = new AbortController();
748
+ if (opts.signal?.aborted)
749
+ life.abort();
750
+ opts.signal?.addEventListener("abort", () => life.abort());
751
+ /** You asked it to stop (ctrl-c, `stop` in the dashboard): the build stops cleanly and still reports; before the build, everything stops. */
752
+ const requestStop = () => {
753
+ if (life.signal.aborted)
754
+ return;
755
+ print(C.yellow(" stopping agents…"));
756
+ if (stopNow)
757
+ stopNow();
758
+ else
759
+ life.abort();
760
+ };
761
+ if (talk) {
762
+ talk.onShow = (q, until) => {
763
+ stats.ask = { from: q.from, until };
764
+ print();
765
+ for (const l of questionLines(q, until))
766
+ print(l);
767
+ if (process.stdout.isTTY)
768
+ process.stdout.write("\x07"); // a bell: you may be in another window
769
+ act(q.from, `${C.yellow("⚑ asks you:")} ${q.question}`);
770
+ };
771
+ talk.onSettled = (q, a) => {
772
+ const next = talk.current();
773
+ stats.ask = next ? { from: next.q.from, until: next.until } : undefined;
774
+ print(settledLine(q, a));
775
+ };
776
+ talk.details = () => {
777
+ if (!planNow)
778
+ return print(C.faint(" no plan yet — the agents are still designing it"));
779
+ const d = planDetails(planNow);
780
+ if (!d.length)
781
+ return print(C.faint(" this plan has no separate decisions or risks"));
782
+ for (const l of d)
783
+ print(l);
784
+ };
785
+ talk.say = (line) => {
786
+ const { to, text } = addressee(line);
787
+ if (!text)
788
+ return;
789
+ if (!board) {
790
+ userNotes.push(text);
791
+ 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")}`);
792
+ }
793
+ const r = sayToAgents(board, text, to);
794
+ if (to && !r.now.length)
795
+ return print(C.yellow(` ! ${to} isn't working on anything right now — say it without @ and every agent that starts next gets it`));
796
+ const who = r.now.length ? r.now.join(", ") : "every agent that starts next";
797
+ const head = ` ${C.violet("✉")} ${C.bold("you")} ${C.faint("→")} ${who}${C.faint(":")} `;
798
+ print(`${head}${oneLine(text, space(head))}`);
799
+ };
800
+ }
801
+ const act = (agent, text) => opts.onActivity?.({ agent, text });
802
+ /** The run is over: open questions go with option 1, the typing line goes away. */
803
+ const endTalk = () => {
804
+ if (liveTick)
805
+ clearInterval(liveTick);
806
+ liveTick = null;
807
+ writeLive(root, snapshot(true));
808
+ talk?.close();
809
+ if (talk)
810
+ talk.say = talk.details = null;
811
+ stats.ask = undefined;
812
+ keys?.stop();
813
+ keys = null;
814
+ };
815
+ endTalkNow = endTalk;
816
+ // The dashboard's view of this run (.huddle/live.json) and what it sends back (.huddle/inbox/): app/live.ts.
817
+ const councilSoFar = [];
818
+ const snapshot = (done = false) => {
819
+ const cur = talk.current();
820
+ return {
821
+ pid: process.pid,
822
+ goal,
823
+ startedAt: stats.startedAt,
824
+ updatedAt: Date.now(),
825
+ ...(sessionId ? { session: sessionId } : {}),
826
+ ...(planNow?.plain ? { plain: planNow.plain } : {}),
827
+ narrator: narration(stats),
828
+ ...(stats.run ? { run: stats.run } : {}),
829
+ agents: [...stats.agents].map(([id, a]) => ({
830
+ id,
831
+ busy: !!a.busySince,
832
+ ...(a.doing ? { doing: a.doing } : {}),
833
+ ...(a.pct !== undefined ? { pct: a.pct } : {}),
834
+ tokens: a.tokens + Math.max(0, a.liveTokens),
835
+ tasks: a.tasks,
836
+ reviews: a.reviews,
837
+ })),
838
+ council: councilSoFar,
839
+ ...(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 } } : {}),
840
+ tokens: [...stats.agents.values()].reduce((n, a) => n + a.tokens + Math.max(0, a.liveTokens), 0),
841
+ ...(budget.limit ? { budget: { limit: budget.limit, used: budget.tokens } } : {}),
842
+ ...(done ? { done } : {}),
843
+ };
844
+ };
845
+ const command = (c) => {
846
+ if (c.type === "stop")
847
+ return requestStop();
848
+ if (c.type === "say")
849
+ return talk.say?.(c.text);
850
+ const cur = talk.current();
851
+ if (cur && cur.q.id === c.id)
852
+ talk.answer(c.line);
853
+ };
854
+ clearCommands(root);
855
+ writeLive(root, snapshot());
856
+ liveTick = setInterval(() => {
857
+ for (const c of takeCommands(root))
858
+ command(c);
859
+ writeLive(root, snapshot());
860
+ }, 1000);
861
+ // A one-shot run on a terminal (huddle join): the overall bar and who's on what, pinned under
862
+ // the output (the prompt app draws the same lines in its footer). Up for the council, then the build.
863
+ const startDock = () => {
864
+ if (dock || opts.print || !process.stdout.isTTY)
865
+ return;
866
+ if (talk && oneShotTty && !keys)
867
+ keys = new KeyLine((l) => {
868
+ if (talk.answer(l))
869
+ return;
870
+ const t = l.trim();
871
+ if (/^(details|decisions|risks)$/i.test(t))
872
+ talk.details?.();
873
+ else if (t)
874
+ talk.say?.(t);
875
+ }, () => dock?.redraw(), () => {
876
+ // First ctrl-c: stop the agents and still print what got done. Second: leave now.
877
+ if (interrupted++) {
878
+ endTalk();
879
+ stopDock();
880
+ // What the terminal does on ctrl-c without raw mode: SIGINT to the whole
881
+ // foreground group, agents included. Windows has no process groups.
882
+ try {
883
+ if (process.platform === "win32")
884
+ throw new Error("no process groups");
885
+ process.kill(0, "SIGINT");
886
+ }
887
+ catch {
888
+ process.exit(130); // the scheduler's exit hook stops any running agents
889
+ }
890
+ return;
891
+ }
892
+ requestStop();
893
+ print(C.faint(" (ctrl-c again to quit right away)"));
894
+ }).start();
895
+ dock = new Dock(() => [...liveLines(stats, (process.stdout.columns || 80) - 1), ...(keys ? [keys.line()] : [])]).start();
896
+ };
897
+ const stopDock = () => {
898
+ dock?.stop();
899
+ dock = null;
900
+ };
589
901
  const brain = resolveBrain(opts.results, opts.probed);
590
902
  const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
591
903
  const members = councilMembers(workers);
@@ -597,6 +909,9 @@ export async function runGoal(goal, opts) {
597
909
  let plan;
598
910
  if (wantCouncil.yes) {
599
911
  phase(print, "COUNCIL", `${members.map((m) => m.id).join(", ")} brainstorm before anyone codes · ${wantCouncil.why} (--no-council skips)`);
912
+ progress({ phase: "COUNCIL", step: 1 });
913
+ opts.onStats?.(stats);
914
+ startDock();
600
915
  /** " ● codex adopts claude's: <body, cut to fit> · 14s" */
601
916
  const say = (agent, label, body = "", took = "") => {
602
917
  const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
@@ -605,6 +920,20 @@ export async function runGoal(goal, opts) {
605
920
  opts.onActivity?.({ agent, text });
606
921
  };
607
922
  const onCouncil = (e) => {
923
+ if (e.type === "ratified")
924
+ councilSoFar.push({ agent: e.entry.agent, kind: e.entry.verdict === "object" ? "object" : e.entry.verdict === "skipped" ? "skipped" : "ok", text: e.entry.objections.join("\n") });
925
+ if (e.type === "entry" && e.entry.ok) {
926
+ const x = e.entry;
927
+ if (x.round === "proposal")
928
+ councilSoFar.push({ agent: x.agent, kind: "proposal", text: x.text.slice(0, 2000) });
929
+ for (const a of x.adopts ?? [])
930
+ councilSoFar.push({ agent: x.agent, kind: "adopt", of: a.of, text: a.text });
931
+ for (const f of x.flaws ?? [])
932
+ councilSoFar.push({ agent: x.agent, kind: "flaw", of: f.of, text: f.text });
933
+ }
934
+ const step = e.type === "round" ? (e.round === "proposal" ? 1 : 2) : e.type === "synthesizing" ? 3 : e.type === "ratify" ? 4 : 0;
935
+ if (step)
936
+ progress({ phase: "COUNCIL", step });
608
937
  if (e.type === "round")
609
938
  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
939
  if (e.type === "synthesizing") {
@@ -660,20 +989,46 @@ export async function runGoal(goal, opts) {
660
989
  catch {
661
990
  /* no history: roster order */
662
991
  }
663
- plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil, pastSyntheses: past });
992
+ try {
993
+ plan = await runCouncil(goal, crew, members, root, { brain, signal: life.signal, onEvent: onCouncil, pastSyntheses: past, outside: await outside() });
994
+ }
995
+ finally {
996
+ stopDock(); // the plan prints in full; the dock comes back for the build
997
+ }
664
998
  }
665
999
  else {
1000
+ progress({ phase: "PLAN" });
1001
+ opts.onStats?.(stats);
666
1002
  // A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
667
1003
  const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
668
- plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: opts.signal, project }));
1004
+ plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: life.signal, project, outside: await outside() }));
1005
+ }
1006
+ if (life.signal.aborted)
1007
+ return endTalk();
1008
+ planNow = plan;
1009
+ progress({ phase: "PLAN", step: undefined, total: plan.tasks.length });
1010
+ printPlan(print, goal, plan, workers, undefined, !!talk);
1011
+ {
1012
+ let history = runHistory(null);
1013
+ try {
1014
+ if (opts.room?.db)
1015
+ history = runHistory(opts.room.db);
1016
+ else if (fs.existsSync(dbPath())) {
1017
+ const d = new RoomDB(dbPath());
1018
+ history = runHistory(d);
1019
+ d.close();
1020
+ }
1021
+ }
1022
+ catch {
1023
+ /* no history: the defaults */
1024
+ }
1025
+ const qaRounds = shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles }) ? QA_ROUNDS : 0;
1026
+ print(C.faint(` ${costPreview({ tasks: plan.tasks.length, agents: workers.length + remoteNow().length, qaRounds, council: plan.council, history, budget })}`));
669
1027
  }
670
- if (opts.signal?.aborted)
671
- return;
672
- printPlan(print, goal, plan, workers);
673
1028
  if (opts.mode === "plan") {
674
1029
  print(C.faint("\n plan mode — nothing created or spawned. " +
675
1030
  (rl ? "shift+tab to switch mode and run it." : `Run it: huddle join ${JSON.stringify(goal)}`)));
676
- return;
1031
+ return endTalk();
677
1032
  }
678
1033
  if (opts.mode === "manual" && rl && !(await yes(rl, `\nStart with ${workers.map((w) => w.id).join(", ")}?`)))
679
1034
  return;
@@ -701,9 +1056,10 @@ export async function runGoal(goal, opts) {
701
1056
  const seen = [];
702
1057
  db.clearBoard();
703
1058
  const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
1059
+ room.connectors = hub;
704
1060
  const taskIds = seedBoard(db, goal, plan.tasks);
705
1061
  // 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 } });
1062
+ 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
1063
  // Before the live listener below, so the council's memories aren't printed twice.
708
1064
  if (plan.council)
709
1065
  recordCouncil(db, plan.council, plan.tasks, taskIds);
@@ -715,19 +1071,20 @@ export async function runGoal(goal, opts) {
715
1071
  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
1072
  }
717
1073
  const t0 = Date.now();
718
- const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
1074
+ board = db;
1075
+ // What you said while they designed it, and their questions for you from here on.
1076
+ for (const t of userNotes.splice(0))
1077
+ sayToAgents(db, t);
1078
+ sessionId = db.currentSession()?.id;
1079
+ // Someone to ask: a terminal you type in, or the dashboard open on this repo. Nobody: the agent's own pick, at once.
1080
+ room.askUser = (q) => (oneShotTty || opts.rl || openApp(root) ? talk.ask(q) : Promise.resolve({ choice: 1, text: "", by: "default" }));
1081
+ progress({ phase: "BUILD" });
719
1082
  opts.onStats?.(stats);
720
1083
  phase(print, "BUILD", `${plan.tasks.length} task(s) · ${workers.length + remoteNow().length} agent(s) · each in its own worktree, merged when tests pass`);
721
1084
  print(C.faint(` room ${room.url} · tests: ${testCmd ?? "none yet"} · approvals: ${opts.approvals}`) +
722
1085
  (rl ? C.faint(" · ctrl-t live view · ctrl-c stops") : ""));
723
1086
  print();
724
- // A one-shot run on a terminal (huddle join): who's on what, pinned under the output.
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();
1087
+ startDock();
731
1088
  const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
732
1089
  /** " ● codex <label> <body, cut to fit><suffix>" */
733
1090
  const line = (agent, label, body = "", suffix = "") => {
@@ -750,8 +1107,7 @@ export async function runGoal(goal, opts) {
750
1107
  for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*•]\s*/, "").trim()).filter(Boolean).slice(0, n))
751
1108
  print(C.faint(` ${oneLine(l, space(" "))}`));
752
1109
  };
753
- let talk = 0; // agent-to-agent messages this run, for the teamwork summary
754
- const act = (agent, text) => opts.onActivity?.({ agent, text });
1110
+ let talk_ = 0; // agent-to-agent messages this run, for the teamwork summary
755
1111
  const unsub = db.onEvent((e) => {
756
1112
  const p = JSON.parse(e.payload || "{}");
757
1113
  if (e.type === "task.progress" && p.note) {
@@ -762,7 +1118,8 @@ export async function runGoal(goal, opts) {
762
1118
  sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
763
1119
  act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
764
1120
  }
765
- else if (e.type === "memory.added" && p.author !== "huddle") {
1121
+ else if (e.type === "memory.added" && p.author !== "huddle" && p.author !== USER_ID) {
1122
+ // (Your own words are on screen already, as the ✉ or → line.)
766
1123
  act(undefined, `${C.violet("◆")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
767
1124
  // Already on screen as its own line: a task's done note (✓ merged), a QA bug (✗ found), a message (a → b).
768
1125
  if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ → \S+:/.test(String(p.body)))
@@ -770,6 +1127,12 @@ export async function runGoal(goal, opts) {
770
1127
  const head = ` ${C.violet("◆")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
771
1128
  print(`${head}${oneLine(p.body, space(head))}`);
772
1129
  }
1130
+ else if (e.type === "connector.call" && e.agent_id) {
1131
+ // "codex github: read issue #12"
1132
+ const line = connectorLine(p);
1133
+ sayOne(e.agent_id, C.faint(line.head), line.body);
1134
+ act(e.agent_id, `${C.faint(line.head)} ${line.body}`);
1135
+ }
773
1136
  else if (e.type === "plan.announced" && e.agent_id) {
774
1137
  sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
775
1138
  act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
@@ -780,13 +1143,29 @@ export async function runGoal(goal, opts) {
780
1143
  if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
781
1144
  return;
782
1145
  if (p.from !== "huddle")
783
- talk++;
1146
+ talk_++;
784
1147
  const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
785
1148
  const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
786
1149
  print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
787
1150
  act(undefined, `${arrow}${oneLine(p.body, 200)}`);
788
1151
  }
789
1152
  });
1153
+ // The budget's hard line stops the run as Ctrl-C does: one signal for the board and QA.
1154
+ const stopRun = new AbortController();
1155
+ stopNow = () => stopRun.abort();
1156
+ if (life.signal.aborted)
1157
+ stopRun.abort();
1158
+ life.signal.addEventListener("abort", () => stopRun.abort());
1159
+ budget.add(plan.tokens ?? 0);
1160
+ budget.onLevel((level, b) => {
1161
+ if (level === "soft")
1162
+ 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`));
1163
+ else {
1164
+ print(C.yellow(` ! budget: ${budgetUse(b)} — past ${Math.round(HARD_AT * 100)}%, stopping the running agents`));
1165
+ stopRun.abort();
1166
+ }
1167
+ });
1168
+ const unwatchBudget = budget.watch();
790
1169
  const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
791
1170
  const cost = (ms, tokens, cached) => C.faint(` · ${fmtDuration(ms)} · ${tok(tokens, cached)}`);
792
1171
  if (remoteNow().length && !(remote && sync))
@@ -808,7 +1187,8 @@ export async function runGoal(goal, opts) {
808
1187
  workers,
809
1188
  testCmd: (worktree) => detectTestCmd(worktree),
810
1189
  approvals: opts.approvals,
811
- signal: opts.signal,
1190
+ signal: stopRun.signal,
1191
+ budget,
812
1192
  approveMerge: opts.mode === "manual" && rl
813
1193
  ? async ({ agent, title, diffstat }) => {
814
1194
  print(`\n ${tag(agent)} ${C.violet("◎ ready")} ${title}`);
@@ -826,7 +1206,7 @@ export async function runGoal(goal, opts) {
826
1206
  stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
827
1207
  const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
828
1208
  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
1209
+ 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
1210
  if (e.type === "tokens" && a)
831
1211
  a.liveTokens = e.tokens;
832
1212
  if ((e.type === "merged" || e.type === "failed") && a)
@@ -931,6 +1311,13 @@ export async function runGoal(goal, opts) {
931
1311
  act(e.agent, `${C.violet("⇉")} pairs with ${e.with}: ${e.part}`);
932
1312
  sayOne(e.agent, `${C.violet("⇉ pairs with " + e.with)} on “${oneLine(e.title, 40)}”: takes`, e.part, C.faint(" · " + oneLine(e.reason, 50)));
933
1313
  }
1314
+ // Quiet past the silence window, but working (a long test run): not taken over (busy.ts).
1315
+ if (e.type === "busy") {
1316
+ act(e.agent, `${C.faint("⧗ running a long command")} ${C.faint("· " + e.why)}`);
1317
+ sayOne(e.agent, C.faint("⧗ running a long command ·"), `quiet ${fmtDuration(e.quietMs)}, but busy: ${e.why}`);
1318
+ }
1319
+ if (e.type === "start" || e.type === "merged" || e.type === "failed")
1320
+ progress({});
934
1321
  if (e.type === "stuck")
935
1322
  print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" · ")));
936
1323
  },
@@ -940,11 +1327,17 @@ export async function runGoal(goal, opts) {
940
1327
  // they file become routed fix tasks, run through this same board.
941
1328
  let qaNote = "";
942
1329
  let qaSum = ""; // the RESULT's checks line
1330
+ let qaCut = false; // QA left undone by the budget
943
1331
  if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
944
- if (res.failed)
1332
+ if (budget.stopping) {
1333
+ qaCut = true;
1334
+ print(C.faint("\n QA skipped — the token budget is nearly spent"));
1335
+ }
1336
+ else if (res.failed)
945
1337
  print(C.faint(`\n QA skipped — ${res.failed} task(s) didn't merge; it checks finished builds only`));
946
1338
  else {
947
1339
  phase(print, "QA", "agents that didn't write it check the build · --no-qa skips");
1340
+ progress({ phase: "QA" });
948
1341
  const qa = await runQa({
949
1342
  db,
950
1343
  room,
@@ -954,18 +1347,24 @@ export async function runGoal(goal, opts) {
954
1347
  workers,
955
1348
  approvals: opts.approvals,
956
1349
  testCmd: (wt) => detectTestCmd(wt),
957
- signal: opts.signal,
1350
+ signal: stopRun.signal,
1351
+ budget,
958
1352
  runFixes: () => runBoard(boardOpts),
959
1353
  onEvent: (e) => {
960
1354
  seen.push(e);
961
1355
  if (process.env.HUDDLE_EVENTS_FILE)
962
1356
  fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
963
1357
  qaLine(e, tag, print, act, stats);
1358
+ if (e.type === "round")
1359
+ progress({ round: e.round });
1360
+ else if (e.type === "bug" || e.type === "fixes")
1361
+ progress({});
964
1362
  },
965
1363
  });
966
1364
  res.merged += qa.fixed;
967
1365
  res.failed += qa.failed;
968
1366
  res.stopped ||= qa.stopped;
1367
+ qaCut = !!qa.budget;
969
1368
  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
1369
  qaNote = qa.clean
971
1370
  ? `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 +1372,13 @@ export async function runGoal(goal, opts) {
973
1372
  }
974
1373
  }
975
1374
  unsub();
1375
+ unwatchBudget();
1376
+ room.askUser = undefined;
1377
+ room.connectors = undefined;
1378
+ hub.close();
1379
+ hubNow = null;
1380
+ endTalk();
1381
+ const undone = budget.level === "ok" ? [] : db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
976
1382
  const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
977
1383
  const parked = db.listTasks().filter((t) => t.status === "needs_you");
978
1384
  const takeovers = new Map(); // task id -> the branch a rescuer continued on
@@ -981,14 +1387,26 @@ export async function runGoal(goal, opts) {
981
1387
  if (ev.task_id && p.kind === "rescue" && p.branch)
982
1388
  takeovers.set(ev.task_id, String(p.branch));
983
1389
  }
1390
+ // What `huddle undo` takes back: every commit this run put on the branch.
1391
+ db.publish("git.range", { payload: { start: startHead, end: git(["rev-parse", "HEAD"], root).out, branch: git(["symbolic-ref", "--short", "HEAD"], root).out } });
984
1392
  const learned = learnFromRun({ db, root, since: runStart, events: seen });
1393
+ progress({ phase: "DONE" });
1394
+ board = null;
1395
+ // The hand-off brief for whoever works here next, solo (ui/brief.ts).
1396
+ const brief = writeBrief({
1397
+ root,
1398
+ events: db.allEvents(),
1399
+ getHandoff: (id) => db.getHandoff(id),
1400
+ testCmd: detectTestCmd(root),
1401
+ parked: parked.map((t) => ({ title: t.title, branch: takeovers.get(t.id) ?? worktreeFor(root, t.claimed_by ?? "", t.id).branch })),
1402
+ enabled: readPrefs().brief !== false,
1403
+ });
985
1404
  if (ownRoom) {
986
1405
  room.close();
987
1406
  db.close();
988
1407
  }
989
1408
  opts.onStats?.(stats);
990
- dock?.stop();
991
- dock = null;
1409
+ stopDock();
992
1410
  // RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
993
1411
  phase(print, "RESULT", "what got built, how it was checked, who did what");
994
1412
  const head = res.stopped ? C.yellow("■ stopped") : res.failed ? C.yellow("◎") : C.green("✓");
@@ -1007,9 +1425,12 @@ export async function runGoal(goal, opts) {
1007
1425
  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
1426
  print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
1009
1427
  }
1010
- if (res.merged)
1011
- await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
1012
- const team = teamworkLines(plan.council, seen, talk);
1428
+ if (res.merged) {
1429
+ const report = await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
1430
+ if (sessionId && report.trim())
1431
+ saveReport(root, sessionId, report);
1432
+ }
1433
+ const team = teamworkLines(plan.council, seen, talk_);
1013
1434
  if (team.length) {
1014
1435
  print(`\n${C.violet("TEAMWORK")} ${C.faint("· how the agents worked together · huddle story for all of it")}`);
1015
1436
  for (const t of team) {
@@ -1017,7 +1438,9 @@ export async function runGoal(goal, opts) {
1017
1438
  print(`${label}${oneLine(t.text, space(label))}`);
1018
1439
  }
1019
1440
  }
1020
- print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
1441
+ print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}${budget.limit ? ` · ${budgetUse(budget)}` : ""}`)}`);
1442
+ for (const l of budgetNotes(budget, { left: undone, qaCut }))
1443
+ print(C.yellow(` ! ${l}`));
1021
1444
  for (const [id, a] of stats.agents) {
1022
1445
  if (!a.tasks && !a.reviews && !a.qa) {
1023
1446
  print(` ${tag(id)} ${C.faint("idle this run")}`);
@@ -1034,6 +1457,9 @@ export async function runGoal(goal, opts) {
1034
1457
  }
1035
1458
  if (learned.length)
1036
1459
  print(C.faint(` ◆ learned ${learned.length} lesson(s) for next time · huddle lessons to see them`));
1460
+ if (brief)
1461
+ for (const l of briefLines(brief))
1462
+ print(l);
1037
1463
  if (res.merged && remote && sync) {
1038
1464
  const p = pushMerged(root);
1039
1465
  print(p.ok ? ` ${C.green("↑")} git push · all in one go ${C.faint("→ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
@@ -1185,6 +1611,7 @@ You run read-only because Huddle only asks you for this write-up; the run itself
1185
1611
  * built, how to run it, what's left. Written read-only by one agent from the
1186
1612
  * merged diff and the agents' done notes; falls back to the raw notes.
1187
1613
  */
1614
+ /** WHAT YOU GOT: the brain's write-up of the run (or the agents' notes). Returns the text, for the dashboard. */
1188
1615
  async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
1189
1616
  const stat = git(["diff", "--stat", `${startHead}..HEAD`], root).out;
1190
1617
  let summary = "";
@@ -1212,6 +1639,7 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
1212
1639
  say(n);
1213
1640
  if (stat)
1214
1641
  print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" · see it all: git log")));
1642
+ return summary || notes.join("\n");
1215
1643
  }
1216
1644
  /** Answer a question about the repo, read-only. */
1217
1645
  async function answer(print, question, results, signal) {
@@ -1243,11 +1671,8 @@ export function printBoard(print = (l = "") => console.log(l)) {
1243
1671
  const s = db.currentSession();
1244
1672
  print(`\n${C.violet("BOARD")} ${C.faint("· " + (s?.goal ?? "(no goal yet)"))}`);
1245
1673
  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
1674
  const who = t.claimed_by ? `${dot(t.claimed_by)} ${t.claimed_by}` : C.faint("unclaimed");
1250
- print(` ${statusLabel(t.status)} ${bar} ${String(t.progress_pct).padStart(3)}% ${t.title} ${who}`);
1675
+ print(` ${statusLabel(t.status)} ${taskMeter(t.progress_pct, 16, t.status)} ${t.title} ${who}`);
1251
1676
  }
1252
1677
  const mem = db.recall();
1253
1678
  if (mem.length) {
@@ -1304,7 +1729,8 @@ const HELP = `
1304
1729
  ${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
1305
1730
  ${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
1306
1731
 
1307
- ${C.faint("While agents work you can keep typing: board, approvals and questions work live; a new goal is queued.")}
1732
+ ${C.faint("While agents work, what you type goes to them (@claude … for one); a question for you shows with numbered options.")}
1733
+ ${C.faint("Questions? end with ? · details: the plan's technical decisions · next <goal>: queue another run.")}
1308
1734
  `;
1309
1735
  // Things people type out of habit that are shell commands, not goals.
1310
1736
  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 +1762,21 @@ export async function runApp() {
1336
1762
  const queue = [];
1337
1763
  let stats = null;
1338
1764
  let ticker = null;
1339
- /** "⏱ 1m12s · 3 merged so far" — the per-agent lines (statusLines) go under it. */
1340
- const liveStats = () => {
1765
+ const talk = new Talk(); // agents' questions for you, your lines to them, during a run
1766
+ // Footer under the prompt, like Claude Code's mode line; during a run, the overall bar
1767
+ // ("BUILD ███░░ 6/9 tasks · 12m") and then one line per agent (runLines).
1768
+ const footer = () => {
1769
+ if (!run)
1770
+ return idleFooter();
1771
+ const W = (out.columns || 80) - 1;
1772
+ const chip = ` ${MODE_INFO[state.mode].chip} `;
1773
+ const queued = queue.length ? C.faint(` · ${queue.length} queued`) : "";
1341
1774
  if (!stats)
1342
- return C.violet("● planning…");
1343
- const merged = [...stats.agents.values()].reduce((n, a) => n + a.tasks, 0);
1344
- return `${C.faint("⏱")} ${fmtDuration(Date.now() - stats.startedAt)} ${C.faint(`· ${merged} merged so far`)}`;
1775
+ return [`${chip}${C.violet("● planning…")}${queued}`];
1776
+ const [top] = runLines(stats, W - width(chip) - width(queued) + 2);
1777
+ return [narratorLine(stats, W), `${chip}${top.trimStart()}${queued}`, ...runLines(stats, W).slice(1)];
1345
1778
  };
1346
- // Footer under the prompt, like Claude Code's mode line; during a run, one more line per agent.
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")}`];
1779
+ 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
1780
  let reserved = 1; // footer lines reserved under the prompt
1354
1781
  const drawFooter = () => {
1355
1782
  if (!promptShown || asking || !out.isTTY)
@@ -1771,6 +2198,7 @@ export async function runApp() {
1771
2198
  room: r ?? undefined,
1772
2199
  onActivity: (a) => dash.push(a),
1773
2200
  qa,
2201
+ talk,
1774
2202
  });
1775
2203
  }
1776
2204
  catch (e) {
@@ -1797,6 +2225,11 @@ export async function runApp() {
1797
2225
  const line = raw.trim();
1798
2226
  promptShown = false;
1799
2227
  out.write("\x1b[2K"); // clear the footer line we just moved onto
2228
+ // An agent asked you something: this line is the answer (Enter = the recommended option).
2229
+ if (run && talk.current() && !/^(stop|exit|quit|view|help|\?|details)$/i.test(line)) {
2230
+ talk.answer(line);
2231
+ return showPrompt();
2232
+ }
1800
2233
  if (!line)
1801
2234
  return showPrompt();
1802
2235
  const [cmd, ...rest] = line.split(/\s+/);
@@ -1925,11 +2358,20 @@ export async function runApp() {
1925
2358
  shell(line);
1926
2359
  print(C.faint(` (ran as a shell command — say "build ${line}" if you meant a goal)`));
1927
2360
  }
1928
- else if (cmd === "ask" || (cmd !== "build" && cmd !== "join" && isQuestion(line))) {
2361
+ else if (cmd === "details" && run)
2362
+ talk.details?.();
2363
+ else if (cmd === "ask" || (cmd !== "build" && cmd !== "join" && cmd !== "next" && isQuestion(line))) {
1929
2364
  await answer(print, cmd === "ask" ? arg : line, results);
1930
2365
  }
2366
+ else if (run && cmd !== "build" && cmd !== "join" && cmd !== "next") {
2367
+ // During a run, what you type goes to the agents (room/user.ts). A new goal waits: "next <goal>".
2368
+ if (talk.say)
2369
+ talk.say(line);
2370
+ else
2371
+ print(C.faint(" the agents are wrapping up — say it again in a moment, or `next <goal>` for a new run"));
2372
+ }
1931
2373
  else {
1932
- const goal = cmd === "build" || cmd === "join" ? arg : line;
2374
+ const goal = cmd === "build" || cmd === "join" || cmd === "next" ? arg : line;
1933
2375
  if (!goal)
1934
2376
  print(C.faint(" usage: build <goal>"));
1935
2377
  else if (run) {