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/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 · note 20s ago · 1m12s
325
- * ○ cursor idle · 2 done
326
- * Every line fits `W` cells: the title gives way, never the numbers.
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 note yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
339
- const tail = (quiet > 120_000 ? C.yellow(` · ${said}`) : C.faint(` · ${said}`)) +
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
- 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}`;
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
- function printPlan(print, goal, plan, workers) {
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
- print(` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`);
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
- const idea = t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
532
- const who = fit ? ` ${C.faint("→ likely")} ${dot(fit)} ${fit}` : "";
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
- 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
- }
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
- plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil, pastSyntheses: past });
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: opts.signal, project }));
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
- const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
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
- // 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();
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 talk = 0; // agent-to-agent messages this run, for the teamwork summary
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
- talk++;
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: opts.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 (res.failed)
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: opts.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
- dock?.stop();
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
- const team = teamworkLines(plan.council, seen, talk);
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)} ${bar} ${String(t.progress_pct).padStart(3)}% ${t.title} ${who}`);
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 can keep typing: board, approvals and questions work live; a new goal is queued.")}
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
- /** "⏱ 1m12s · 3 merged so far" — the per-agent lines (statusLines) go under it. */
1340
- const liveStats = () => {
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 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`)}`;
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
- // 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")}`];
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 === "ask" || (cmd !== "build" && cmd !== "join" && isQuestion(line))) {
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) {