huddle-cli 0.1.0 → 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.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
package/dist/ui/app.js CHANGED
@@ -15,7 +15,8 @@ import os from "node:os";
15
15
  import path from "node:path";
16
16
  import readline from "node:readline/promises";
17
17
  import { spawnSync } from "node:child_process";
18
- import { ADAPTERS, probeAsync } from "../adapters/index.js";
18
+ import { ADAPTERS, applySkips, installCommand, noInstallHint, probeAsync } from "../adapters/index.js";
19
+ import { gitFix, runShell, spawnToolSync } from "../platform.js";
19
20
  import { RoomDB } from "../room/db.js";
20
21
  import { startRoom } from "../room/server.js";
21
22
  import { openFunnel, openTunnel, tailscaleIp } from "../room/tunnel.js";
@@ -23,17 +24,27 @@ import { openRelayLink } from "../room/relaylink.js";
23
24
  import { canonicalRemote, pullLatest, pushMerged, readRepoConfig, remoteInfo, writeRepoConfig } from "../sandbox/sync.js";
24
25
  import { githubRepo, githubUser, isCollaborator } from "../room/identity.js";
25
26
  import { isBigGoal, listsOwnTasks, planGoal, seedBoard } from "../orchestrator/planner.js";
26
- import { councilMembers, councilWanted, headline, recordCouncil, runCouncil } from "../orchestrator/council.js";
27
+ import { councilMembers, councilWanted, headline, pastSyntheses, recordCouncil, runCouncil } from "../orchestrator/council.js";
28
+ import { contractFile } from "../room/contract.js";
27
29
  import { pickAsker } from "../orchestrator/ask.js";
28
30
  import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
29
31
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
30
32
  import { runBoard } from "../orchestrator/scheduler.js";
31
- import { runQa, shouldQa } from "../orchestrator/qa.js";
32
- import { isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
33
- import { C, LiveLines, box, dot, pad, truncate } from "./term.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";
35
+ import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
36
+ import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
34
37
  import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
35
38
  import { logoLines } from "./logo.js";
36
- import { Dashboard } from "./dashboard.js";
39
+ import { Dashboard, estHint, progressLine, taskCounts, taskMeter } from "./dashboard.js";
40
+ import { learnFromRun } from "../orchestrator/learn.js";
41
+ import { briefLines, liftPointers, placePointers, writeBrief } from "./brief.js";
42
+ import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
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";
37
48
  // 0 = pick a free port per run, so sessions never collide.
38
49
  export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
39
50
  // ---- paths & repo ----------------------------------------------------------
@@ -80,8 +91,8 @@ export function detectTestCmd(root) {
80
91
  return null;
81
92
  }
82
93
  /**
83
- * The brain to use: your pick if it's usable, else Sonnet (fast, strong plans,
84
- * a fraction of Opus's tokens), else whichever agent can answer read-only.
94
+ * The brain to use: your pick if it's usable, else Claude Opus (the strongest
95
+ * plans — the brain writes every task spec), else whichever agent can answer read-only.
85
96
  */
86
97
  export function resolveBrain(results, probed = results) {
87
98
  const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
@@ -95,23 +106,46 @@ export function resolveBrain(results, probed = results) {
95
106
  return b;
96
107
  }
97
108
  if (ready.includes("claude"))
98
- return brainFrom("claude:sonnet");
109
+ return brainFrom("claude:opus");
99
110
  const asker = pickAsker(results.filter((r) => r.state === "ready").map((r) => r.adapter));
100
111
  return asker ? brainFrom(asker.id) : null;
101
112
  }
102
113
  // HUDDLE_HOME lets two Huddle identities share one machine (tests, demos).
103
- const PREFS = path.join(process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "prefs.json");
114
+ // (Read per call, so a test can point HUDDLE_HOME somewhere else.)
115
+ const prefsFile = () => path.join(process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "prefs.json");
104
116
  export function readPrefs() {
105
117
  try {
106
- return JSON.parse(fs.readFileSync(PREFS, "utf8"));
118
+ return JSON.parse(fs.readFileSync(prefsFile(), "utf8"));
107
119
  }
108
120
  catch {
109
121
  return {};
110
122
  }
111
123
  }
112
- function writePrefs(p) {
113
- fs.mkdirSync(path.dirname(PREFS), { recursive: true });
114
- fs.writeFileSync(PREFS, JSON.stringify(p, null, 2));
124
+ export function writePrefs(p) {
125
+ fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
126
+ fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
127
+ }
128
+ /** The agents you skipped (`huddle agents skip <id>`). */
129
+ export function skippedAgents() {
130
+ return new Set(readPrefs().skip ?? []);
131
+ }
132
+ /**
133
+ * Use or skip agents, remembered in prefs. Using one also forgets an old
134
+ * "don't install" answer, so Huddle offers it again.
135
+ */
136
+ export function setAgentChoice(ids, use) {
137
+ const p = readPrefs();
138
+ const skip = new Set(p.skip ?? []);
139
+ const declined = new Set(p.declinedInstall ?? []);
140
+ for (const id of ids) {
141
+ if (use) {
142
+ skip.delete(id);
143
+ declined.delete(id);
144
+ }
145
+ else
146
+ skip.add(id);
147
+ }
148
+ writePrefs({ ...p, skip: [...skip], declinedInstall: [...declined] });
115
149
  }
116
150
  // ---- welcome ---------------------------------------------------------------
117
151
  function tildify(p) {
@@ -167,16 +201,24 @@ export async function connectAgents() {
167
201
  return ` ${C.green("✓")} ${head} ready`;
168
202
  if (r.state === "not_installed")
169
203
  return ` ${C.faint("○")} ${head} ${C.faint("not installed")}`;
204
+ if (r.state === "skipped")
205
+ return ` ${C.faint("–")} ${head} ${C.faint(r.detail)}`;
170
206
  return ` ${C.yellow("!")} ${head} ${C.yellow("not signed in")} ${C.faint("— " + r.detail)}`;
171
207
  };
208
+ const skip = skippedAgents();
209
+ const probeOne = async (a) => applySkips([await probeAsync(a)], skip)[0];
172
210
  // Probe legacy CLIs first (silently) so they only appear when installed.
173
211
  for (const a of ADAPTERS.filter((a) => LEGACY.has(a.id)))
174
- results.set(a.id, await probeAsync(a));
175
- const visible = ADAPTERS.filter(shown);
212
+ results.set(a.id, await probeOne(a));
213
+ const shownLegacy = (a) => {
214
+ const r = results.get(a.id);
215
+ return r?.state === "ready" || (r?.state === "skipped" && r.was === "ready");
216
+ };
217
+ const visible = ADAPTERS.filter((a) => shown(a) || (LEGACY.has(a.id) && shownLegacy(a)));
176
218
  const live = new LiveLines((spin) => visible.map((a) => row(a, spin))).start();
177
219
  await Promise.all(visible.map(async (a) => {
178
220
  if (!results.has(a.id))
179
- results.set(a.id, await probeAsync(a));
221
+ results.set(a.id, await probeOne(a));
180
222
  live.draw();
181
223
  }));
182
224
  live.stop();
@@ -199,21 +241,52 @@ async function yes(rl, q, def = true) {
199
241
  questionHooks.after();
200
242
  }
201
243
  }
202
- /** Run a shell command with the terminal handed over (installers, logins). */
203
- function runInteractive(rl, cmd) {
204
- rl.pause();
205
- const r = typeof cmd === "string"
206
- ? spawnSync("sh", ["-c", cmd], { stdio: "inherit" })
207
- : spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit" });
208
- rl.resume();
244
+ /**
245
+ * Run a command with the terminal handed over (installers, logins). A string
246
+ * is a shell line — sh, or PowerShell on Windows (`irm … | iex`); an array is
247
+ * a CLI, spawned without a shell.
248
+ */
249
+ export function runInteractive(rl, cmd) {
250
+ rl?.pause();
251
+ bracketedPaste(false); // the other program gets a plain terminal
252
+ const r = typeof cmd === "string" ? runShell(cmd) : spawnToolSync(cmd, { stdio: "inherit" });
253
+ bracketedPaste(true);
254
+ rl?.resume();
209
255
  return r.status === 0;
210
256
  }
257
+ function rlIO(rl) {
258
+ return { ask: (q, def) => yes(rl, q, def), run: (cmd) => runInteractive(rl, cmd), print: (l = "") => console.log(l) };
259
+ }
260
+ /** Install one agent's CLI with this platform's command. False if it failed or there's none. */
261
+ export function installAgent(io, a) {
262
+ const cmd = installCommand(a);
263
+ if (!cmd) {
264
+ io.print(C.yellow(` ${a.id}: `) + C.faint(noInstallHint(a)));
265
+ return false;
266
+ }
267
+ io.print(C.violet(`\n↓ installing ${a.id}…`) + C.faint(` $ ${cmd}`));
268
+ const ok = io.run(cmd);
269
+ io.print(ok ? C.green(`✓ ${a.id} installed`) : C.red(`✗ ${a.id} install failed — see output above`));
270
+ return ok;
271
+ }
272
+ /** Hand the terminal to an agent's own sign-in. False if it has no login command. */
273
+ export function signIn(io, a) {
274
+ if (!a.install.login)
275
+ return false;
276
+ if (a.install.loginNote)
277
+ io.print(C.faint(` ${a.install.loginNote}`));
278
+ io.run(a.install.login);
279
+ return true;
280
+ }
281
+ /** The install line to show for an agent on this platform. */
282
+ export const installLine = (a) => installCommand(a) ?? noInstallHint(a);
211
283
  /**
212
284
  * Offer to install missing agents and sign in to logged-out ones — always with
213
285
  * the user's OK. A "no" is remembered so we don't nag on every launch; the
214
286
  * `agents` command asks again.
215
287
  */
216
288
  export async function offerSetup(rl, results, force = false) {
289
+ const io = rlIO(rl);
217
290
  const prefs = readPrefs();
218
291
  const declined = new Set(force ? [] : (prefs.declinedInstall ?? []));
219
292
  const missing = results.filter((r) => r.state === "not_installed" && !declined.has(r.adapter.id) && !LEGACY.has(r.adapter.id));
@@ -222,19 +295,14 @@ export async function offerSetup(rl, results, force = false) {
222
295
  if (missing.length) {
223
296
  console.log();
224
297
  for (const r of missing)
225
- console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint("$ " + r.adapter.install.cmd)}`);
298
+ console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint(installCommand(r.adapter) ? "$ " + installLine(r.adapter) : installLine(r.adapter))}`);
226
299
  const names = missing.map((r) => r.adapter.id).join(", ");
227
300
  if (await yes(rl, `\nInstall ${names}?`)) {
228
301
  for (const r of missing) {
229
- console.log(C.violet(`\n↓ installing ${r.adapter.id}…`));
230
- const ok = runInteractive(rl, r.adapter.install.cmd);
231
- console.log(ok ? C.green(`✓ ${r.adapter.id} installed`) : C.red(`✗ ${r.adapter.id} install failed — see output above`));
302
+ const ok = installAgent(io, r.adapter);
232
303
  changed ||= ok;
233
- if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`))) {
234
- if (r.adapter.install.loginNote)
235
- console.log(C.faint(` ${r.adapter.install.loginNote}`));
236
- runInteractive(rl, r.adapter.install.login);
237
- }
304
+ if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`)))
305
+ signIn(io, r.adapter);
238
306
  }
239
307
  }
240
308
  else {
@@ -247,9 +315,7 @@ export async function offerSetup(rl, results, force = false) {
247
315
  if (!login)
248
316
  continue;
249
317
  if (await yes(rl, `\n${r.adapter.id} isn't signed in. Sign in now?`)) {
250
- if (r.adapter.install.loginNote)
251
- console.log(C.faint(` ${r.adapter.install.loginNote}`));
252
- runInteractive(rl, login);
318
+ signIn(io, r.adapter);
253
319
  changed = true;
254
320
  }
255
321
  else {
@@ -259,6 +325,50 @@ export async function offerSetup(rl, results, force = false) {
259
325
  }
260
326
  return changed;
261
327
  }
328
+ /**
329
+ * The live status block under the run's output, one line per agent:
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.
336
+ */
337
+ export function statusLines(stats, W, now = Date.now()) {
338
+ const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
339
+ return [...stats.agents].map(([id, a]) => {
340
+ const head = ` ${a.busySince ? dot(id) : C.faint("○")} ${pad(id, idW)} `;
341
+ if (!a.busySince) {
342
+ const done = a.tasks + a.reviews + (a.qa ?? 0);
343
+ return head + C.faint(done ? `idle · ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
344
+ }
345
+ const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
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}`)) +
350
+ C.faint(` · ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` · ${fmtTokens(a.liveTokens)} tok` : ""}`);
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);
356
+ });
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
+ }
262
372
  export function newStats(workers) {
263
373
  return {
264
374
  startedAt: Date.now(),
@@ -340,7 +450,7 @@ async function prepareRepo(print, rl, goal = "") {
340
450
  if (!(await yes(rl, " Set it up? (git init + commit your current files)")))
341
451
  return false;
342
452
  if (spawnSync("git", ["--version"]).status !== 0) {
343
- print(C.red(" ✗ git isn't installed.") + C.faint(" macOS: xcode-select --install · Linux: your package manager · https://git-scm.com"));
453
+ print(C.red(" ✗ git isn't installed.") + C.faint(" " + gitFix()));
344
454
  return false;
345
455
  }
346
456
  const gi = path.join(process.cwd(), ".gitignore");
@@ -358,7 +468,8 @@ async function prepareRepo(print, rl, goal = "") {
358
468
  print(` ${C.green("✓")} git repo ready`);
359
469
  }
360
470
  ensureInit();
361
- const dirty = git(["status", "--porcelain"]).out.split("\n").filter((l) => l && !l.includes(".huddle/"));
471
+ liftPointers(repoRoot()); // the brief's pointer is out of CLAUDE.md / AGENTS.md while the team works (ui/brief.ts)
472
+ const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
362
473
  if (dirty.length) {
363
474
  if (!rl) {
364
475
  print(C.yellow(`! ${dirty.length} uncommitted change(s) — agents only see committed files.`));
@@ -423,23 +534,68 @@ export function likelyAgents(tasks, workers) {
423
534
  }
424
535
  return out;
425
536
  }
426
- function printPlan(print, goal, plan, workers) {
537
+ /** Room left on a line once its fixed parts are drawn: the terminal's width, or 120 in a log. */
538
+ const rawSpace = (...used) => (lineWidth() ?? 120) - used.reduce((n, u) => n + width(u), 0);
539
+ const space = (...used) => Math.max(24, rawSpace(...used));
540
+ /**
541
+ * A phase header. Every run reads the same way, top to bottom:
542
+ * ━━ COUNCIL ━━ → ━━ PLAN ━━ → ━━ BUILD ━━ → ━━ QA ━━ → ━━ RESULT ━━
543
+ */
544
+ function phase(print, name, sub = "") {
545
+ const head = `${C.violet("━━")} ${C.bold(C.violet(name))} ${C.violet("━━")}`;
546
+ // Cut to the terminal; a log keeps the whole line.
547
+ const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
548
+ print(`\n${head}${rest}`);
549
+ }
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) {
427
552
  const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
428
- print(`\n${C.violet("PLAN")} ${C.faint("· " + shortGoal(goal) + " · by " + plan.by + took + (plan.note ? " — " + plan.note : ""))}`);
553
+ phase(print, "PLAN", `${plan.tasks.length} task(s) · by ${plan.by}${took}${plan.note ? " — " + plan.note : ""}`);
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:"));
429
558
  const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
430
559
  const likely = likelyAgents(plan.tasks, workers);
431
560
  plan.tasks.forEach((t, i) => {
432
561
  const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
433
562
  const fit = likely[i];
434
- const idea = t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
435
- print(` ${C.faint(String(i + 1) + ".")} ${t.title}${after} ${fit ? C.faint("→ likely ") + dot(fit) + " " + fit : ""}${idea}`);
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}` : "";
566
+ const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
567
+ print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
436
568
  });
437
569
  print(C.faint(` ${plan.tasks.length} task(s), ${Math.min(startable, workers.length)} of ${workers.length} agents can start right away`));
438
- // 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 = [];
439
594
  for (const d of plan.council?.decisions ?? [])
440
- print(` ${C.violet("◆")} ${C.faint("decided:")} ${oneLine(d, 110)}`);
595
+ out.push(` ${C.violet("◆")} ${C.faint("decided:")} ${d}`);
441
596
  for (const r of plan.council?.risks ?? [])
442
- print(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${oneLine(r.body, 105)}`);
597
+ out.push(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${r.body}`);
598
+ return out;
443
599
  }
444
600
  /** Show a spinner line while a promise settles (TTY only). */
445
601
  async function withSpinner(label, p) {
@@ -455,13 +611,38 @@ async function withSpinner(label, p) {
455
611
  process.stdout.write("\x1b[1A\x1b[2K"); // remove the spinner line
456
612
  }
457
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;
458
617
  export async function runGoal(goal, opts) {
459
- const print = opts.print ?? ((l = "") => console.log(l));
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) {
629
+ // Every line fits the terminal (logs keep everything). In a one-shot run on a
630
+ // terminal, the per-agent status lines sit under the output (the Dock).
631
+ let dock = null;
632
+ const sink = opts.print ?? ((l = "") => (dock ? dock.log(l) : console.log(l)));
633
+ const print = (l = "") => sink(fit(l));
460
634
  // Typed at the prompt too: `build a todo app from scratch --no-council`.
461
635
  const flagIn = goal.match(/(^|\s)--(no-)?council(?=\s|$)/);
462
636
  if (flagIn)
463
637
  goal = goal.replace(flagIn[0], " ").replace(/\s+/g, " ").trim();
464
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`));
465
646
  const workers = workersFrom(opts.results);
466
647
  // One-shot `huddle join` (no rl): a failure exits 1, and the hints name CLI commands.
467
648
  if (!workers.length && !opts.room?.server.remote.workers().length) {
@@ -481,54 +662,326 @@ export async function runGoal(goal, opts) {
481
662
  // Teammates' agents (from `huddle join` on their machines) work alongside yours.
482
663
  const bridge = opts.room?.server.remote;
483
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
+ };
484
854
  const brain = resolveBrain(opts.results, opts.probed);
485
855
  const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
486
856
  const members = councilMembers(workers);
487
857
  // Files in the repo before the run: an (almost) empty repo makes a goal a project (goalSize in planner.ts).
488
- const repoFiles = git(["ls-files"], root).out.split("\n").filter(Boolean).length;
858
+ const repoFiles = git(["ls-files"], root).out.split(/\r?\n/).filter(Boolean).length;
489
859
  const wantCouncil = councilWanted(goal, { flag: councilFlag, members: members.length, repoFiles });
490
860
  if (councilFlag === true && !wantCouncil.yes)
491
861
  print(C.yellow(` ! no design council: ${wantCouncil.why}`));
492
862
  let plan;
493
863
  if (wantCouncil.yes) {
494
- print(`\n${C.violet("COUNCIL")} ${C.faint(`· ${members.map((m) => m.id).join(", ")} brainstorm before anyone codes · ${wantCouncil.why} (--no-council skips)`)}`);
495
- const say = (agent, text) => {
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();
868
+ /** " ● codex adopts claude's: <body, cut to fit> · 14s" */
869
+ const say = (agent, label, body = "", took = "") => {
870
+ const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
871
+ const text = `${label}${body ? " " + oneLine(body, space(head, took)) : ""}${took}`;
496
872
  print(` ${dot(agent)} ${pad(agent, 12)} ${text}`);
497
873
  opts.onActivity?.({ agent, text });
498
874
  };
499
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 });
500
890
  if (e.type === "round")
501
- print(C.faint(` ${e.round === "proposal" ? "1/3 each agent proposes a design…" : "2/3 each agent critiques the others…"}`));
502
- if (e.type === "synthesizing")
503
- print(C.faint(` 3/3 ${e.brain} merges the best ideas into one plan…`));
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…"}`));
892
+ if (e.type === "synthesizing") {
893
+ const head = ` 3/4 ${e.brain} writes the plan and its contract`;
894
+ print(C.faint(`${head}${e.why ? ` · ${oneLine(e.why, space(head, " · "))}` : "…"}`));
895
+ }
896
+ if (e.type === "ratify")
897
+ print(C.faint(` 4/4 ${e.agents.join(", ")} check ${e.by}'s contract…`));
898
+ if (e.type === "ratified") {
899
+ const r = e.entry;
900
+ const took = C.faint(` · ${fmtDuration(r.ms)}`);
901
+ if (r.verdict === "skipped")
902
+ return say(r.agent, C.faint(`ratify skipped (${r.skipped})`), "", took);
903
+ if (r.verdict === "object")
904
+ return say(r.agent, C.yellow(`objects (${r.objections.length}):`), r.objections.join(" · "), took);
905
+ return say(r.agent, r.verdict === "ok" ? C.green("✓ ok") : C.faint("no clear verdict · taken as ok"), "", took);
906
+ }
907
+ if (e.type === "revised")
908
+ print(e.ok ? ` ${C.green("✓")} ${e.by} revised the plan ${C.faint(`· ${e.taken} objection(s) taken, ${e.rejected} rejected`)}` : C.faint(` ${e.by} couldn't revise — the plan stands as ratified`));
504
909
  if (e.type !== "entry")
505
910
  return;
506
911
  const x = e.entry;
507
912
  const took = C.faint(` · ${fmtDuration(x.ms)}`);
508
913
  if (!x.ok)
509
- return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`) + took);
914
+ return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`), "", took);
510
915
  if (x.round === "proposal")
511
- return say(x.agent, `${C.faint("proposes:")} ${oneLine(headline(x.text, "ARCHITECTURE"), 100)}` + took);
512
- const flaw = headline(x.text, "FLAWS");
513
- const next = headline(x.text, "NEXT");
514
- say(x.agent, `${C.faint("flags:")} ${oneLine(flaw, 100)}` + took);
515
- if (next && next !== flaw)
516
- print(` ${" ".repeat(15)}${C.faint("next:")} ${oneLine(next, 100)}`);
916
+ return say(x.agent, C.faint("proposes:"), headline(x.text, "ARCHITECTURE"), took);
917
+ // A cross-review: what it took from whom, and what it found wrong in whose.
918
+ const adopt = x.adopts?.[0];
919
+ const flawPt = x.flaws?.[0];
920
+ if (!adopt && !flawPt)
921
+ return say(x.agent, C.faint("flags:"), headline(x.text, "FLAWS"), took);
922
+ if (adopt)
923
+ say(x.agent, `${C.green("+ adopts")} ${adopt.of}'s idea:`, adopt.text, took);
924
+ else
925
+ say(x.agent, `${C.yellow("! flaw in")} ${flawPt.of}'s idea:`, flawPt.text, took);
926
+ if (adopt && flawPt) {
927
+ const head = ` ${" ".repeat(15)}${C.yellow("! flaw in")} ${flawPt.of}'s idea: `;
928
+ print(`${head}${oneLine(flawPt.text, space(head))}`);
929
+ }
517
930
  };
518
- plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil });
931
+ // Who synthesized before in this repo's Room: ties rotate the job.
932
+ let past = {};
933
+ try {
934
+ if (opts.room?.db)
935
+ past = pastSyntheses(opts.room.db);
936
+ else if (fs.existsSync(dbPath())) {
937
+ const d = new RoomDB(dbPath());
938
+ past = pastSyntheses(d);
939
+ d.close();
940
+ }
941
+ }
942
+ catch {
943
+ /* no history: roster order */
944
+ }
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
+ }
519
951
  }
520
952
  else {
953
+ progress({ phase: "PLAN" });
954
+ opts.onStats?.(stats);
521
955
  // A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
522
956
  const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
523
- 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 })}`));
524
980
  }
525
- if (opts.signal?.aborted)
526
- return;
527
- printPlan(print, goal, plan, workers);
528
981
  if (opts.mode === "plan") {
529
982
  print(C.faint("\n plan mode — nothing created or spawned. " +
530
983
  (rl ? "shift+tab to switch mode and run it." : `Run it: huddle join ${JSON.stringify(goal)}`)));
531
- return;
984
+ return endTalk();
532
985
  }
533
986
  if (opts.mode === "manual" && rl && !(await yes(rl, `\nStart with ${workers.map((w) => w.id).join(", ")}?`)))
534
987
  return;
@@ -551,45 +1004,114 @@ export async function runGoal(goal, opts) {
551
1004
  const testCmd = detectTestCmd(root);
552
1005
  const ownRoom = !opts.room;
553
1006
  const db = opts.room?.db ?? new RoomDB(dbPath());
1007
+ // What this run teaches the next one is learned from its own records (orchestrator/learn.ts).
1008
+ const runStart = Date.now();
1009
+ const seen = [];
554
1010
  db.clearBoard();
555
1011
  const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
556
1012
  const taskIds = seedBoard(db, goal, plan.tasks);
557
1013
  // Who planned it, for `huddle story` (the tasks themselves are task.added events).
558
- 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 } });
559
1015
  // Before the live listener below, so the council's memories aren't printed twice.
560
1016
  if (plan.council)
561
1017
  recordCouncil(db, plan.council, plan.tasks, taskIds);
562
- print(`\n${C.violet("●")} room live ${C.faint("· " + room.url)} ${C.faint("· tests: " + (testCmd ?? "none yet"))}` +
563
- ` ${C.faint("· approvals: " + opts.approvals)}` +
564
- (rl ? C.faint(" · ctrl-t live view · keep typing — board, questions · ctrl-c stops") : "") +
565
- "\n");
1018
+ // The ratified contract goes on main before any task branches off it, so every worktree has it.
1019
+ if (plan.council?.contract) {
1020
+ const by = plan.council.synthesizer?.id ?? plan.council.synthesizedBy;
1021
+ const ok = commitFile(root, "HUDDLE_CONTRACT.md", contractFile(plan.council.contract, by, plan.council.members), "huddle: the council's contract");
1022
+ const objected = plan.council.objections?.length ?? 0;
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` : ""))}`);
1024
+ }
1025
+ const t0 = Date.now();
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" });
1034
+ opts.onStats?.(stats);
1035
+ phase(print, "BUILD", `${plan.tasks.length} task(s) · ${workers.length + remoteNow().length} agent(s) · each in its own worktree, merged when tests pass`);
1036
+ print(C.faint(` room ${room.url} · tests: ${testCmd ?? "none yet"} · approvals: ${opts.approvals}`) +
1037
+ (rl ? C.faint(" · ctrl-t live view · ctrl-c stops") : ""));
1038
+ print();
1039
+ startDock();
566
1040
  const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
567
- const act = (agent, text) => opts.onActivity?.({ agent, text });
1041
+ /** " ● codex <label> <body, cut to fit><suffix>" */
1042
+ const line = (agent, label, body = "", suffix = "") => {
1043
+ const head = ` ${tag(agent)} ${label}${body ? " " : ""}`;
1044
+ // A quoted title keeps its closing quote when it's cut.
1045
+ const q = /^“([\s\S]*)”$/.exec(body);
1046
+ // On a narrow screen a quoted title gives way entirely before the verdict after it does.
1047
+ if (q && rawSpace(head, suffix) < 14)
1048
+ return ` ${tag(agent)} ${label}${suffix}`;
1049
+ const text = q ? `“${oneLine(q[1], rawSpace(head, suffix) - 2)}”` : body ? oneLine(body, space(head, suffix)) : "";
1050
+ return `${head}${text}${suffix}`;
1051
+ };
1052
+ /** The parts after a title, longest first: the first that leaves the whole title (or 32 cells of it) wins. */
1053
+ const fitting = (agent, label, title, ...suffixes) => suffixes.find((x) => rawSpace(` ${tag(agent)} ${label} `, x) >= Math.min(32, width(title))) ?? suffixes[suffixes.length - 1];
1054
+ // Handoffs land just before a task starts: one "· read 3 handoffs" on the ▸ line, not a line each.
1055
+ const handoffs = new Map();
1056
+ const sayOne = (agent, label, body = "", suffix = "") => print(line(agent, label, body, suffix));
1057
+ /** Up to `n` lines of a teammate's notes, indented under the line that sent them. */
1058
+ const notesUnder = (notes, n = 3) => {
1059
+ for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*•]\s*/, "").trim()).filter(Boolean).slice(0, n))
1060
+ print(C.faint(` ${oneLine(l, space(" "))}`));
1061
+ };
1062
+ let talk_ = 0; // agent-to-agent messages this run, for the teamwork summary
568
1063
  const unsub = db.onEvent((e) => {
569
1064
  const p = JSON.parse(e.payload || "{}");
570
1065
  if (e.type === "task.progress" && p.note) {
571
1066
  const agent = (e.task_id ? db.getTask(e.task_id)?.claimed_by : "") ?? "";
572
- print(` ${tag(agent)} ${C.faint(String(p.pct).padStart(3) + "%")} ${oneLine(p.note, 100)}`);
1067
+ const st = stats.agents.get(agent);
1068
+ if (st)
1069
+ Object.assign(st, { pct: Number(p.pct) || 0, noteAt: Date.now() });
1070
+ sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
573
1071
  act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
574
1072
  }
575
- else if (e.type === "memory.added" && p.author !== "huddle") {
576
- print(` ${C.violet("◆")} ${pad(C.faint("memory"), 12)} ${oneLine(p.body, 100)}`);
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.)
577
1075
  act(undefined, `${C.violet("◆")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
1076
+ // Already on screen as its own line: a task's done note (✓ merged), a QA bug (✗ found), a message (a → b).
1077
+ if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ → \S+:/.test(String(p.body)))
1078
+ return;
1079
+ const head = ` ${C.violet("◆")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
1080
+ print(`${head}${oneLine(p.body, space(head))}`);
578
1081
  }
579
1082
  else if (e.type === "plan.announced" && e.agent_id) {
580
- print(` ${tag(e.agent_id)} ${C.faint("plans:")} ${oneLine(p.intent ?? "", 100)}`);
1083
+ sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
581
1084
  act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
582
1085
  }
583
1086
  else if (e.type === "agent.message" && p.kind !== "question") {
584
1087
  // Agent to agent, live: "codex → claude: renamed greet() to hello()". (Questions show as asks.)
585
- const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}`;
586
- print(` ${arrow}${C.faint(":")} ${oneLine(p.body, 110)}${p.kind === "answer" ? C.faint(" (answer)") : ""}`);
587
- act(undefined, `${arrow}${C.faint(":")} ${oneLine(p.body, 200)}`);
1088
+ // Huddle's own relay of a buddy check is the ◎ buddy check line itself.
1089
+ if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
1090
+ return;
1091
+ if (p.from !== "huddle")
1092
+ talk_++;
1093
+ const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
1094
+ const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
1095
+ print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
1096
+ act(undefined, `${arrow}${oneLine(p.body, 200)}`);
588
1097
  }
589
1098
  });
590
- const t0 = Date.now();
591
- const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
592
- opts.onStats?.(stats);
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();
593
1115
  const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
594
1116
  const cost = (ms, tokens, cached) => C.faint(` · ${fmtDuration(ms)} · ${tok(tokens, cached)}`);
595
1117
  if (remoteNow().length && !(remote && sync))
@@ -611,7 +1133,8 @@ export async function runGoal(goal, opts) {
611
1133
  workers,
612
1134
  testCmd: (worktree) => detectTestCmd(worktree),
613
1135
  approvals: opts.approvals,
614
- signal: opts.signal,
1136
+ signal: stopRun.signal,
1137
+ budget,
615
1138
  approveMerge: opts.mode === "manual" && rl
616
1139
  ? async ({ agent, title, diffstat }) => {
617
1140
  print(`\n ${tag(agent)} ${C.violet("◎ ready")} ${title}`);
@@ -621,6 +1144,7 @@ export async function runGoal(goal, opts) {
621
1144
  }
622
1145
  : undefined,
623
1146
  onEvent: (e) => {
1147
+ seen.push(e);
624
1148
  // HUDDLE_EVENTS_FILE=<path>: every scheduler event as a JSON line (evals, post-mortems).
625
1149
  if (process.env.HUDDLE_EVENTS_FILE)
626
1150
  fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
@@ -628,11 +1152,9 @@ export async function runGoal(goal, opts) {
628
1152
  stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
629
1153
  const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
630
1154
  if (e.type === "start" && a)
631
- Object.assign(a, { busySince: Date.now(), liveTokens: 0 });
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
632
1156
  if (e.type === "tokens" && a)
633
1157
  a.liveTokens = e.tokens;
634
- if (e.type === "start" && a)
635
- a.liveTokens = -1; // -1 = not reported yet
636
1158
  if ((e.type === "merged" || e.type === "failed") && a)
637
1159
  Object.assign(a, {
638
1160
  busySince: null,
@@ -641,19 +1163,20 @@ export async function runGoal(goal, opts) {
641
1163
  cached: a.cached + e.cached,
642
1164
  ms: a.ms + e.ms,
643
1165
  tasks: a.tasks + 1,
1166
+ doing: undefined,
644
1167
  });
645
1168
  if (e.type === "review" && a)
646
- Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
1169
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `review ${e.author}'s “${e.title}”`, pct: undefined, noteAt: undefined });
647
1170
  if (e.type === "reviewed" && a)
648
- Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1 });
1171
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1, doing: undefined });
649
1172
  if (e.type === "takeover") {
650
1173
  // The author's rounds end here (no merged/failed event for it): count them.
651
1174
  const f = e.kind === "rescue" ? stats.agents.get(e.from) : undefined;
652
1175
  if (f)
653
- Object.assign(f, { busySince: null, liveTokens: 0, tokens: f.tokens + e.tokens, cached: f.cached + e.cached, ms: f.ms + e.ms });
1176
+ Object.assign(f, { busySince: null, liveTokens: 0, tokens: f.tokens + e.tokens, cached: f.cached + e.cached, ms: f.ms + e.ms, doing: undefined });
654
1177
  const what = e.kind === "rescue" ? `took over ${e.from}'s` : `took part of ${e.from}'s task:`;
655
1178
  act(e.agent, `${C.yellow("⤳")} ${what} ${e.title} ${C.faint("· " + e.reason)}`);
656
- print(` ${tag(e.agent)} ${C.yellow("⤳ " + what)} “${e.title}” ${C.faint("· " + oneLine(e.reason, 110))}`);
1179
+ sayOne(e.agent, C.yellow("⤳ " + what), `“${e.title}”`, C.faint(" · " + oneLine(e.reason, 60)));
657
1180
  }
658
1181
  if (e.type === "start")
659
1182
  act(e.agent, `${C.violet("▸")} ${e.takeover ? `continuing ${e.takeover}'s work: ` : e.fixing ? "fixing after review: " : ""}${e.title}`);
@@ -665,41 +1188,82 @@ export async function runGoal(goal, opts) {
665
1188
  act(e.agent, `${C.violet("◎")} reviewing ${e.author}'s work`);
666
1189
  if (e.type === "reviewed")
667
1190
  act(e.agent, e.approve ? C.green("✓ approved " + e.author) : C.yellow("✎ asked " + e.author + " for changes"));
668
- if (e.type === "start")
669
- print(` ${tag(e.agent)} ${C.violet("▸")} ${e.takeover ? C.yellow(`continuing ${e.takeover}'s work: `) : e.fixing ? C.yellow("fixing after review: ") : ""}${e.title}` +
670
- (e.remote ? C.faint(` · offered to ${e.remote}`) : ""));
1191
+ if (e.type === "start") {
1192
+ const from = handoffs.get(e.agent) ?? [];
1193
+ handoffs.delete(e.agent);
1194
+ const label = `${C.violet("▸")}${e.takeover ? C.yellow(` continuing ${e.takeover}'s work:`) : e.fixing ? C.yellow(" fixing after review:") : ""}`;
1195
+ const read = from.length ? ` · read ${from.length} handoff${from.length === 1 ? "" : "s"} (${[...new Set(from)].join(", ")})` : "";
1196
+ const remote = e.remote ? ` · offered to ${e.remote}` : "";
1197
+ sayOne(e.agent, label, e.title, C.faint(fitting(e.agent, label, e.title, read + remote, from.length ? ` · read ${from.length} handoff(s)${remote}` : remote, "")));
1198
+ }
1199
+ if (e.type === "contract") {
1200
+ act(e.agent, `${C.violet("◆")} amended the contract (v${e.version}): ${e.section}: ${e.change}`);
1201
+ sayOne(e.agent, `${C.violet("◆ contract v" + e.version)} ${C.faint(e.section + ":")}`, e.change);
1202
+ }
671
1203
  if (e.type === "handoff")
672
- print(` ${tag(e.agent)} ${C.violet("⇢")} handoff from ${C.bold(e.from)} ${C.faint(`· ${e.files} file(s), ${e.symbols} export(s) · “${e.title}”`)}`);
1204
+ handoffs.set(e.agent, [...(handoffs.get(e.agent) ?? []), e.from]);
673
1205
  if (e.type === "declined")
674
- print(` ${tag(e.agent)} ${C.faint("✗ declined · back on the board")} ${C.faint(e.title)}`);
1206
+ sayOne(e.agent, C.faint("✗ declined · back on the board"), e.title);
675
1207
  if (e.type === "review")
676
- print(` ${tag(e.agent)} ${C.violet("◎")} reviewing ${e.author}'s ${C.faint("“" + e.title + "”")}`);
1208
+ sayOne(e.agent, `${C.violet("◎ reviews")} ${e.author}'s`, `“${e.title}”`);
677
1209
  if (e.type === "reviewed") {
678
1210
  if (e.skipped)
679
- print(` ${tag(e.agent)} ${C.faint("○ review skipped")} ${C.faint("· " + e.notes + " · " + fmtDuration(e.ms) + " · merging on tests alone")}`);
1211
+ sayOne(e.agent, C.faint("○ review skipped ·"), e.notes, C.faint(` · ${fmtDuration(e.ms)} · merging on tests alone`));
680
1212
  else if (e.approve)
681
1213
  print(` ${tag(e.agent)} ${C.green("✓ approved")} ${e.author}'s work ${C.faint("· " + fmtDuration(e.ms))}`);
682
1214
  else {
683
1215
  print(` ${tag(e.agent)} ${C.yellow("✎ asked for changes")} ${C.faint("· " + fmtDuration(e.ms) + " · back to " + e.author)}`);
684
- for (const l of e.notes.split("\n").slice(0, 4))
685
- print(C.faint(` ${oneLine(l, 110)}`));
1216
+ notesUnder(e.notes, 4);
686
1217
  }
687
1218
  }
688
- if (e.type === "merged")
689
- print(` ${tag(e.agent)} ${C.green("✓ merged")} ${e.title} ${C.faint("· " + e.reason)}${cost(e.ms, e.tokens, e.cached)}`);
1219
+ if (e.type === "merged") {
1220
+ const short = e.reason.replace(/\s*\([^)]*\)/g, "");
1221
+ const c = cost(e.ms, e.tokens, e.cached);
1222
+ sayOne(e.agent, C.green("✓ merged"), e.title, fitting(e.agent, "✓ merged", e.title, C.faint(" · " + e.reason) + c, C.faint(" · " + short) + c, C.faint(" · " + short.split(" · ")[0]) + c, c));
1223
+ }
690
1224
  if (e.type === "failed")
691
- print(` ${tag(e.agent)} ${e.needsYou ? C.yellow("! needs you") : C.red("✗ failed")} ${e.title} ${C.faint("· " + e.reason)}${cost(e.ms, e.tokens, e.cached)} ${C.faint("· log: " + tildify(e.log))}`);
1225
+ sayOne(e.agent, e.needsYou ? C.yellow("! needs you") : C.red("✗ failed"), e.title, C.faint(" · " + oneLine(e.reason, 50)) + cost(e.ms, e.tokens, e.cached) + C.faint(" · log: " + tildify(e.log)));
692
1226
  if (e.type === "conflict" && a)
693
- Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
1227
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `fix a merge conflict in “${e.title}”`, pct: undefined, noteAt: undefined });
694
1228
  if (e.type === "resolved" && a)
695
- Object.assign(a, { busySince: null, ms: a.ms + e.ms });
1229
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, doing: undefined });
696
1230
  if (e.type === "conflict") {
697
1231
  act(e.agent, `${C.yellow("⇄")} resolving a conflict in ${e.author}'s work`);
698
- print(` ${tag(e.agent)} ${C.yellow("⇄ resolving conflict")} ${C.faint("in " + e.files.join(", "))} ${e.title}` +
699
- C.faint(` · ${e.agent === e.author ? "its own work" : e.author + "'s work"} vs main · try ${e.attempt}`));
1232
+ sayOne(e.agent, C.yellow("⇄ resolving conflict"), `in ${e.files.join(", ")}: ${e.title}`, C.faint(` · ${e.agent === e.author ? "its own work" : e.author + "'s work"} vs main · try ${e.attempt}`));
700
1233
  }
701
1234
  if (e.type === "resolved")
702
- print(` ${tag(e.agent)} ${e.ok ? C.green("✓ resolved") : C.yellow("✗ resolution rejected")} ${C.faint("· " + e.reason + " · " + fmtDuration(e.ms))}`);
1235
+ sayOne(e.agent, e.ok ? C.green("✓ resolved") : C.yellow("✗ resolution rejected"), "", C.faint(" · " + oneLine(e.reason, 60) + " · " + fmtDuration(e.ms)));
1236
+ // The integration radar told both agents mid-task (their inbox has the details).
1237
+ if (e.type === "radar") {
1238
+ act(e.agent, `${C.yellow("⚠ radar")} ${e.kind === "overlap" ? "overlap" : "name mismatch"} with ${e.with}: ${e.detail}`);
1239
+ sayOne(e.agent, `${C.yellow(e.kind === "overlap" ? "⚠ radar: overlap" : "⚠ radar: name mismatch")} with ${e.with}:`, e.detail);
1240
+ }
1241
+ // A buddy check: another agent read the author's work in progress; notes went to its inbox.
1242
+ if (e.type === "checkpoint") {
1243
+ const ok = /^LOOKS GOOD/i.test(e.notes.trim());
1244
+ act(e.agent, `${C.violet("◎")} buddy check on ${e.author}'s work ${e.pct ? `at ${e.pct}%` : "mid-task"}: ${ok ? "looks good" : oneLine(e.notes, 80)}`);
1245
+ const verdict = ok ? C.green(" · looks good") : e.late ? C.yellow(" · came back after it landed (kept in shared memory)") : C.yellow(" · notes sent");
1246
+ sayOne(e.agent, `${C.violet("◎ buddy check")} ${e.author}'s`, `“${e.title}”`, ` ${e.pct ? `at ${e.pct}%` : "mid-task"}${verdict}`);
1247
+ if (!ok)
1248
+ notesUnder(e.notes);
1249
+ }
1250
+ // A note that needed an answer, settled: addressed (fixed or replied) or ignored.
1251
+ if (e.type === "followup") {
1252
+ const what = e.kind === "radar" ? "radar finding" : `${e.by}'s buddy note`;
1253
+ const how = e.how === "ignored" ? C.yellow("ignored") : C.green(e.how === "replied" ? "answered" : "addressed in code");
1254
+ sayOne(e.agent, `${C.violet("◎")} ${what} ${how} ${C.faint("·")}`, e.note.replace(/^buddy check from [^:]*:\s*/i, ""), C.faint(` · “${oneLine(e.title, 40)}”`));
1255
+ }
1256
+ if (e.type === "pair") {
1257
+ act(e.agent, `${C.violet("⇉")} pairs with ${e.with}: ${e.part}`);
1258
+ sayOne(e.agent, `${C.violet("⇉ pairs with " + e.with)} on “${oneLine(e.title, 40)}”: takes`, e.part, C.faint(" · " + oneLine(e.reason, 50)));
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({});
703
1267
  if (e.type === "stuck")
704
1268
  print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" · ")));
705
1269
  },
@@ -708,11 +1272,18 @@ export async function runGoal(goal, opts) {
708
1272
  // QA: agents that didn't write the code re-check the integrated main; bugs
709
1273
  // they file become routed fix tasks, run through this same board.
710
1274
  let qaNote = "";
1275
+ let qaSum = ""; // the RESULT's checks line
1276
+ let qaCut = false; // QA left undone by the budget
711
1277
  if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
712
- 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)
713
1283
  print(C.faint(`\n QA skipped — ${res.failed} task(s) didn't merge; it checks finished builds only`));
714
1284
  else {
715
- print(`\n${C.violet("QA")} ${C.faint("· agents that didn't write it check the build · --no-qa to skip")}`);
1285
+ phase(print, "QA", "agents that didn't write it check the build · --no-qa skips");
1286
+ progress({ phase: "QA" });
716
1287
  const qa = await runQa({
717
1288
  db,
718
1289
  room,
@@ -722,23 +1293,35 @@ export async function runGoal(goal, opts) {
722
1293
  workers,
723
1294
  approvals: opts.approvals,
724
1295
  testCmd: (wt) => detectTestCmd(wt),
725
- signal: opts.signal,
1296
+ signal: stopRun.signal,
1297
+ budget,
726
1298
  runFixes: () => runBoard(boardOpts),
727
1299
  onEvent: (e) => {
1300
+ seen.push(e);
728
1301
  if (process.env.HUDDLE_EVENTS_FILE)
729
1302
  fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
730
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({});
731
1308
  },
732
1309
  });
733
1310
  res.merged += qa.fixed;
734
1311
  res.failed += qa.failed;
735
1312
  res.stopped ||= qa.stopped;
1313
+ qaCut = !!qa.budget;
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"));
736
1315
  qaNote = qa.clean
737
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.`
738
1317
  : `QA: ${qa.rounds} round(s); ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, but the last round did NOT come back clean.`;
739
1318
  }
740
1319
  }
741
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);
742
1325
  const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
743
1326
  const parked = db.listTasks().filter((t) => t.status === "needs_you");
744
1327
  const takeovers = new Map(); // task id -> the branch a rescuer continued on
@@ -747,15 +1330,58 @@ export async function runGoal(goal, opts) {
747
1330
  if (ev.task_id && p.kind === "rescue" && p.branch)
748
1331
  takeovers.set(ev.task_id, String(p.branch));
749
1332
  }
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
+ });
750
1345
  if (ownRoom) {
751
1346
  room.close();
752
1347
  db.close();
753
1348
  }
754
1349
  opts.onStats?.(stats);
1350
+ stopDock();
1351
+ // RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
1352
+ phase(print, "RESULT", "what got built, how it was checked, who did what");
755
1353
  const head = res.stopped ? C.yellow("■ stopped") : res.failed ? C.yellow("◎") : C.green("✓");
756
1354
  const total = [...stats.agents.values()].reduce((n, a) => n + a.tokens, 0);
757
1355
  const totalCached = [...stats.agents.values()].reduce((n, a) => n + a.cached, 0);
758
- print(`\n${head} ${res.merged} merged, ${res.failed} failed ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
1356
+ print(`${head} ${res.merged} merged, ${res.failed} failed ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
1357
+ const testsNow = detectTestCmd(root);
1358
+ print(` ${C.faint(pad("tests", 9))} ${testsNow ? `${C.green("✓")} ${testsNow} passed before every merge` : C.yellow("no test command — merges weren't gated on tests")}`);
1359
+ if (qaSum)
1360
+ print(` ${C.faint(pad("QA", 9))} ${qaSum}`);
1361
+ for (const t of parked) {
1362
+ // Never resolved by force: the branch holds the agent's work exactly as it left it.
1363
+ // A taken-over task still lives on its first agent's branch.
1364
+ const tk = takeovers.get(t.id);
1365
+ const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
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}`));
1367
+ print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
1368
+ }
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_);
1375
+ if (team.length) {
1376
+ print(`\n${C.violet("TEAMWORK")} ${C.faint("· how the agents worked together · huddle story for all of it")}`);
1377
+ for (const t of team) {
1378
+ const label = ` ${C.faint(pad(t.label, 9))} `;
1379
+ print(`${label}${oneLine(t.text, space(label))}`);
1380
+ }
1381
+ }
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}`));
759
1385
  for (const [id, a] of stats.agents) {
760
1386
  if (!a.tasks && !a.reviews && !a.qa) {
761
1387
  print(` ${tag(id)} ${C.faint("idle this run")}`);
@@ -768,22 +1394,82 @@ export async function runGoal(goal, opts) {
768
1394
  ]
769
1395
  .filter(Boolean)
770
1396
  .join(" + ");
771
- print(` ${tag(id)} ${pad(did, 18)} ${pad(fmtDuration(a.ms), 7)} ${a.tasks ? tok(a.tokens, a.cached) : ""}`);
772
- }
773
- for (const t of parked) {
774
- // Never resolved by force: the branch holds the agent's work exactly as it left it.
775
- // A taken-over task still lives on its first agent's branch.
776
- const tk = takeovers.get(t.id);
777
- const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
778
- print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` — ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
779
- print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
1397
+ print(` ${tag(id)} ${pad(did, 27)} ${pad(fmtDuration(a.ms), 7)} ${a.tasks ? tok(a.tokens, a.cached) : ""}`);
780
1398
  }
1399
+ if (learned.length)
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);
781
1404
  if (res.merged && remote && sync) {
782
1405
  const p = pushMerged(root);
783
1406
  print(p.ok ? ` ${C.green("↑")} git push · all in one go ${C.faint("→ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
784
1407
  }
785
- if (res.merged)
786
- await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: detectTestCmd(root), qaNote }));
1408
+ }
1409
+ /**
1410
+ * The run's teamwork, one line each, from what the council and the scheduler
1411
+ * recorded — counts, not judgments. [] for a run where nobody worked with anybody.
1412
+ */
1413
+ export function teamworkLines(council, events, messages = 0) {
1414
+ const out = [];
1415
+ const n = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
1416
+ const of = (t) => events.filter((e) => e.type === t);
1417
+ if (council) {
1418
+ const adopts = council.critiques.flatMap((c) => c.adopts ?? []);
1419
+ const flaws = council.critiques.flatMap((c) => c.flaws ?? []);
1420
+ if (adopts.length || flaws.length) {
1421
+ // "claude's taken by codex, cursor": whose ideas the others built on.
1422
+ const takers = new Map();
1423
+ for (const a of adopts)
1424
+ takers.set(a.of, (takers.get(a.of) ?? new Set()).add(a.by));
1425
+ const whose = [...takers].sort((a, b) => b[1].size - a[1].size).map(([of, by]) => `${of}'s taken by ${[...by].join(", ")}`);
1426
+ out.push({ label: "ideas", text: `${n(adopts.length, "idea")} adopted, ${n(flaws.length, "flaw")} flagged${whose.length ? ` · ${whose.join(" · ")}` : ""}` });
1427
+ }
1428
+ const by = council.synthesizer?.id ?? council.synthesizedBy;
1429
+ const obj = council.objections ?? [];
1430
+ const okd = (council.ratify ?? []).filter((r) => r.verdict === "ok").length;
1431
+ if (council.contract)
1432
+ out.push({
1433
+ label: "contract",
1434
+ text: `written by ${by}${obj.length ? ` · ${obj.filter((o) => o.taken).length} of ${n(obj.length, "objection")} taken` : okd ? ` · ${okd} ratified it as is` : ""}`,
1435
+ });
1436
+ }
1437
+ const amends = of("contract");
1438
+ if (amends.length)
1439
+ out.push({ label: "contract", text: `${n(amends.length, "amendment")} mid-run, now v${Math.max(...amends.map((e) => Number(e.version) || 1))} · ${[...new Set(amends.map((e) => e.agent))].join(", ")}` });
1440
+ const checks = of("checkpoint");
1441
+ if (checks.length) {
1442
+ const sent = checks.filter((e) => !/^LOOKS GOOD/i.test(String(e.notes ?? "").trim()));
1443
+ const ups = of("followup").filter((e) => e.kind !== "radar");
1444
+ const k = (h) => ups.filter((e) => e.how === h).length;
1445
+ out.push({
1446
+ label: "buddy",
1447
+ text: sent.length
1448
+ ? `${n(checks.length, "mid-task check")} · ${n(sent.length, "note")} sent` + (ups.length ? ` → ${k("fixed")} fixed, ${k("replied")} answered, ${k("ignored")} ignored` : "")
1449
+ : `${n(checks.length, "mid-task check")} · all looked good`,
1450
+ });
1451
+ }
1452
+ const radar = of("radar");
1453
+ if (radar.length)
1454
+ out.push({ label: "radar", text: `${n(radar.length, "catch", "catches")} while agents worked: ${radar.slice(0, 3).map((e) => `${e.agent} ↔ ${e.with} (${e.kind})`).join(", ")}` });
1455
+ for (const p of of("pair").slice(0, 3))
1456
+ out.push({ label: "pair", text: `${p.agent} helped ${p.with} on “${p.title}”: ${p.part}` });
1457
+ const takes = of("takeover");
1458
+ if (takes.length)
1459
+ out.push({ label: "takeover", text: takes.slice(0, 3).map((e) => `${e.agent} ${e.kind === "rescue" ? "took over" : "took part of"} ${e.from}'s “${e.title}”`).join(" · ") });
1460
+ const reviews = of("reviewed").filter((e) => !e.skipped);
1461
+ if (reviews.length) {
1462
+ const asked = reviews.filter((e) => !e.approve).length;
1463
+ out.push({ label: "reviews", text: `${n(reviews.length, "cross-review")} · ${reviews.length - asked} approved${asked ? `, ${asked} sent back for changes` : ""}` });
1464
+ }
1465
+ const bugs = events.filter((e) => e.type === "bug" && e.round !== undefined);
1466
+ if (bugs.length) {
1467
+ const fixers = [...new Set(bugs.map((e) => e.assignedTo).filter(Boolean))];
1468
+ out.push({ label: "QA", text: `${n(bugs.length, "bug")} found by ${[...new Set(bugs.map((e) => e.agent))].join(", ")} → fixed by ${fixers.join(", ") || "any free agent"}` });
1469
+ }
1470
+ if (messages)
1471
+ out.push({ label: "talk", text: `${n(messages, "message")} between agents` });
1472
+ return out;
787
1473
  }
788
1474
  /** QA's readable lines: "cursor (QA) found: …", "→ fix assigned to codex". */
789
1475
  function qaLine(e, tag, print, act, stats) {
@@ -792,26 +1478,32 @@ function qaLine(e, tag, print, act, stats) {
792
1478
  print(` ${C.violet("◎")} ${C.bold(`QA round ${e.round}`)} ${C.faint("· checked by " + e.agents.join(", "))}`);
793
1479
  if (e.type === "start") {
794
1480
  if (a)
795
- Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
796
- print(` ${tag(e.agent)} ${C.violet("◎")} QA: tests, using it, reading the diff${e.author ? C.faint(" · (everyone wrote some — checking anyway)") : ""}`);
1481
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `QA check, round ${e.round}`, pct: undefined, noteAt: undefined });
1482
+ print(` ${tag(e.agent)} ${C.violet("◎ QA check:")} runs the tests, uses it, reads the diff${e.author ? C.faint(" · (wrote some of it too)") : ""}`);
797
1483
  act(e.agent, `${C.violet("◎")} QA round ${e.round}`);
798
1484
  }
799
1485
  if (e.type === "bug") {
800
- print(` ${tag(e.agent)} ${C.yellow("✗ (QA) found:")} ${oneLine(e.title, 90)}${e.files.length ? C.faint(" · " + e.files.join(", ")) : ""}${e.test ? C.faint(" · + regression test") : ""}`);
801
- print(` ${pad("", 14)} ${C.faint("→ fix assigned to")} ${e.assignedTo ? `${dot(e.assignedTo)} ${e.assignedTo}` : C.faint("any free agent")}`);
1486
+ const head = ` ${tag(e.agent)} ${C.yellow("✗ QA bug:")} `;
1487
+ // The bug's title first; where it is only if there's room.
1488
+ const full = (e.files.length ? C.faint(" · " + e.files.slice(0, 2).join(", ")) : "") + (e.test ? C.faint(" · + test") : "");
1489
+ const where = space(head, full) >= 40 ? full : "";
1490
+ print(`${head}${oneLine(e.title, space(head, where))}${where}`);
1491
+ print(` ${pad("", 14)} ${C.faint("→ fix goes to")} ${e.assignedTo ? `${dot(e.assignedTo)} ${e.assignedTo}` : C.faint("any free agent")}`);
802
1492
  act(e.agent, `${C.yellow("✗ found:")} ${e.title} ${C.faint("→ " + (e.assignedTo ?? "anyone"))}`);
803
1493
  }
804
1494
  if (e.type === "duplicate")
805
- print(` ${tag(e.agent)} ${C.faint(`= duplicate of “${oneLine(e.of, 70)}” · not filed again`)}`);
1495
+ print(` ${tag(e.agent)} ${C.faint(`= duplicate of “${oneLine(e.of, space(" ", tag(e.agent), " = duplicate of “” · not filed again"))}” · not filed again`)}`);
806
1496
  if (e.type === "checked") {
807
1497
  if (a)
808
- Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1 });
1498
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1, doing: undefined });
809
1499
  print(` ${tag(e.agent)} ${e.ok ? C.green("✓ QA done") : C.yellow("! QA didn't finish")} ${C.faint(`· ${e.bugs} bug(s) filed · ${fmtDuration(e.ms)}${e.reason ? " · " + e.reason : ""}`)}`);
810
1500
  }
811
1501
  if (e.type === "fixes")
812
1502
  print(` ${C.violet("◎")} ${C.faint(`QA round ${e.round} fixes: ${e.merged} merged, ${e.failed} failed`)}`);
813
1503
  if (e.type === "clean")
814
1504
  print(` ${C.green("✓")} QA round ${e.round} clean ${C.faint("· nothing found")}`);
1505
+ if (e.type === "suspect")
1506
+ print(C.yellow(` ? QA round ${e.round} clean, but quick`) + C.faint(` · ${e.reason}`));
815
1507
  if (e.type === "unchecked")
816
1508
  print(C.yellow(` ! QA round ${e.round}: the build was NOT checked`) + C.faint(` · ${e.reason}`));
817
1509
  if (e.type === "capped")
@@ -829,7 +1521,7 @@ function qaLine(e, tag, print, act, stats) {
829
1521
  * that's Huddle's choice, not the run's.
830
1522
  */
831
1523
  export function reportFacts(root, startHead, o) {
832
- const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split("\n").filter(Boolean);
1524
+ const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split(/\r?\n/).filter(Boolean);
833
1525
  const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root).out.trim() || "the current branch";
834
1526
  const facts = [
835
1527
  `${o.merged} task(s) merged and ${o.failed} failed. The merged work is already committed: ${subjects.length} commit(s) on ${branch} since the run started (git log ${startHead.slice(0, 7)}..HEAD).`,
@@ -860,6 +1552,7 @@ You run read-only because Huddle only asks you for this write-up; the run itself
860
1552
  * built, how to run it, what's left. Written read-only by one agent from the
861
1553
  * merged diff and the agents' done notes; falls back to the raw notes.
862
1554
  */
1555
+ /** WHAT YOU GOT: the brain's write-up of the run (or the agents' notes). Returns the text, for the dashboard. */
863
1556
  async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
864
1557
  const stat = git(["diff", "--stat", `${startHead}..HEAD`], root).out;
865
1558
  let summary = "";
@@ -869,15 +1562,25 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
869
1562
  if (r.ok)
870
1563
  summary = r.text;
871
1564
  }
872
- print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("· " + shortGoal(goal))}`);
1565
+ print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("· " + shortGoal(goal, space("WHAT YOU GOT · ")))}`);
1566
+ // Wrapped, not cut: this is the part people read. (Command lines keep their indent.)
1567
+ const w = lineWidth();
1568
+ const say = (l) => {
1569
+ if (!w || width(l) <= w - 2)
1570
+ return print(" " + l);
1571
+ const indent = (/^\s*(?:[-*•]\s+|\d+[.)]\s+)?/.exec(l)?.[0] ?? "").replace(/\S/g, " ");
1572
+ for (const part of wrap(l, w - 2, indent))
1573
+ print(" " + part);
1574
+ };
873
1575
  if (summary)
874
1576
  for (const l of summary.split("\n"))
875
- print(" " + l);
1577
+ say(l);
876
1578
  else
877
1579
  for (const n of notes)
878
- print(" " + oneLine(n, 160));
1580
+ say(n);
879
1581
  if (stat)
880
1582
  print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" · see it all: git log")));
1583
+ return summary || notes.join("\n");
881
1584
  }
882
1585
  /** Answer a question about the repo, read-only. */
883
1586
  async function answer(print, question, results, signal) {
@@ -886,7 +1589,7 @@ async function answer(print, question, results, signal) {
886
1589
  let brain = resolveBrain(results);
887
1590
  if (brain && !brain.readsRepo) {
888
1591
  const asker = pickAsker(workersFrom(results).map((w) => w.adapter));
889
- brain = asker ? brainFrom(asker.id === "claude" ? "claude:sonnet" : asker.id) : null;
1592
+ brain = asker ? brainFrom(asker.id === "claude" ? "claude:opus" : asker.id) : null;
890
1593
  }
891
1594
  if (!brain) {
892
1595
  print(C.red(" ✗ no agent ready to answer.") + " Type `agents`.");
@@ -909,11 +1612,8 @@ export function printBoard(print = (l = "") => console.log(l)) {
909
1612
  const s = db.currentSession();
910
1613
  print(`\n${C.violet("BOARD")} ${C.faint("· " + (s?.goal ?? "(no goal yet)"))}`);
911
1614
  for (const t of db.listTasks()) {
912
- const w = 16;
913
- const filled = Math.round((t.progress_pct / 100) * w);
914
- const bar = C.violet("█".repeat(filled)) + C.faint("░".repeat(w - filled));
915
1615
  const who = t.claimed_by ? `${dot(t.claimed_by)} ${t.claimed_by}` : C.faint("unclaimed");
916
- 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}`);
917
1617
  }
918
1618
  const mem = db.recall();
919
1619
  if (mem.length) {
@@ -962,12 +1662,16 @@ const HELP = `
962
1662
  ${C.bold("funnel")} ${C.faint("[on|off]")} public https invites through Tailscale Funnel
963
1663
  ${C.bold("ctrl-t")} ${C.faint("or")} ${C.bold("view")} the live view — 1 agents · 2 plan · 3 board · q to come back
964
1664
  ${C.bold("board")} the task board and shared memory
1665
+ ${C.faint("paste")} ${C.faint("/")} ${C.faint("drag a file in")} long pastes and files go on the shared screen: one copy every agent reads
1666
+ ${C.bold("add")} ${C.faint("<file|dir|glob>")} put files on the screen ${C.faint("·")} ${C.bold("ctx")} ${C.faint("[show|rm|note|clear]")} what's on it
965
1667
  ${C.bold("agents")} reconnect agents; install or sign in to missing ones
1668
+ ${C.bold("agents use|skip")} ${C.faint("<id>")} pick who works with you: agents skip cursor · agents use cursor
966
1669
  ${C.bold("cd <dir>")} move to another project ${C.faint("·")} ${C.bold("!<cmd>")} run a shell command
967
1670
  ${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
968
1671
  ${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
969
1672
 
970
- ${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.")}
971
1675
  `;
972
1676
  // Things people type out of habit that are shell commands, not goals.
973
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"]);
@@ -999,34 +1703,48 @@ export async function runApp() {
999
1703
  const queue = [];
1000
1704
  let stats = null;
1001
1705
  let ticker = null;
1002
- /** "⏱ 1m12s · ● claude 40s 31k · ● codex 22s 15k · ○ cursor" */
1003
- 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`) : "";
1004
1715
  if (!stats)
1005
- return C.violet("● planning…");
1006
- const now = Date.now();
1007
- const parts = [...stats.agents].map(([id, a]) => a.busySince
1008
- ? `${dot(id)} ${id} ${fmtDuration(now - a.busySince)} ${C.faint(a.liveTokens < 0 ? "…" : fmtTokens(a.liveTokens) + " tok")}`
1009
- : C.faint(`○ ${id}`));
1010
- return `${C.faint("⏱")} ${fmtDuration(now - stats.startedAt)} ${parts.join(" ")}`;
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)];
1011
1719
  };
1012
- // Footer under the prompt, like Claude Code's mode line.
1013
- const footer = () => run
1014
- ? ` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` · ${queue.length} queued`) : ""}`
1015
- : ` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` +
1016
- `${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")}`];
1721
+ let reserved = 1; // footer lines reserved under the prompt
1017
1722
  const drawFooter = () => {
1018
1723
  if (!promptShown || asking || !out.isTTY)
1019
1724
  return;
1020
- // Never wrap: a wrapped footer would break the cursor save/restore.
1021
- out.write("\x1b7\x1b[1B\r\x1b[2K" + truncate(footer(), (out.columns || 80) - 1) + "\x1b8");
1725
+ // Never wrap: a wrapped footer would break the cursor save/restore. Never more lines than were reserved.
1726
+ let ls = footer();
1727
+ if (ls.length > reserved)
1728
+ ls = [...ls.slice(0, reserved - 1), C.faint(` … ${ls.length - reserved + 1} more · ctrl-t for the live view`)];
1729
+ const w = (out.columns || 80) - 1;
1730
+ out.write("\x1b7" + ls.map((l) => "\x1b[1B\r\x1b[2K" + truncate(l, w)).join("") + "\x1b[J\x1b8");
1022
1731
  };
1023
1732
  const showPrompt = () => {
1024
- out.write("\n\x1b[1A"); // reserve the footer line below
1733
+ // Reserve the footer lines below the prompt (up to a third of the screen).
1734
+ reserved = Math.max(1, Math.min(footer().length, Math.floor((out.rows || 24) / 3)));
1735
+ out.write("\n".repeat(reserved) + `\x1b[${reserved}A`);
1025
1736
  rl.setPrompt(prompt);
1026
1737
  rl.prompt(true);
1027
1738
  promptShown = true;
1028
1739
  drawFooter();
1029
1740
  };
1741
+ /** The footer grew (a run started, its roster is known): reserve the new lines. */
1742
+ const refitFooter = () => {
1743
+ if (!promptShown || asking || !out.isTTY || footer().length === reserved)
1744
+ return;
1745
+ out.write("\r\x1b[J");
1746
+ showPrompt();
1747
+ };
1030
1748
  // The live view (ctrl-t / `view`). While it's up, prompt output waits.
1031
1749
  const dash = new Dashboard({
1032
1750
  db: () => live?.db ?? null,
@@ -1101,6 +1819,7 @@ export async function runApp() {
1101
1819
  print(C.faint(" (type exit or press ctrl-d to leave)"));
1102
1820
  });
1103
1821
  rl.on("close", () => {
1822
+ bracketedPaste(false);
1104
1823
  out.write("\r\x1b[J");
1105
1824
  console.log(C.faint("bye 👋"));
1106
1825
  process.exit(0); // the scheduler's exit hook stops any running agents
@@ -1159,6 +1878,11 @@ export async function runApp() {
1159
1878
  };
1160
1879
  const unsub = db.onEvent((e) => {
1161
1880
  const p = JSON.parse(e.payload || "{}");
1881
+ const se = screenEvent(e); // notes, reads and teammates' items on the shared screen
1882
+ if (se?.print)
1883
+ print(se.print);
1884
+ if (se?.dash)
1885
+ dash.push(se.dash);
1162
1886
  if (e.type === "member.joined")
1163
1887
  print(` ${C.green("●")} ${C.bold(p.name)} joined the room ${C.faint("· " + p.role)}`);
1164
1888
  if (e.type === "member.left")
@@ -1219,7 +1943,7 @@ export async function runApp() {
1219
1943
  const add = spawnSync("gh", ["api", "-X", "PUT", `repos/${repo}/collaborators/${handle}`, "-f", "permission=push"], {
1220
1944
  encoding: "utf8",
1221
1945
  });
1222
- print(add.status === 0 ? ` ${C.green("✓")} GitHub invite sent to @${handle}` : C.red(` ✗ ${add.stderr.trim().split("\n")[0]}`));
1946
+ print(add.status === 0 ? ` ${C.green("✓")} GitHub invite sent to @${handle}` : C.red(` ✗ ${add.stderr.trim().split(/\r?\n/)[0]}`));
1223
1947
  }
1224
1948
  }
1225
1949
  else if (collab)
@@ -1339,9 +2063,58 @@ export async function runApp() {
1339
2063
  promptShown = false;
1340
2064
  out.write("\r\x1b[J");
1341
2065
  rl.pause();
1342
- spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
2066
+ bracketedPaste(false);
2067
+ runShell(cmd);
2068
+ bracketedPaste(true);
1343
2069
  rl.resume();
1344
2070
  };
2071
+ // ---- the shared screen: pastes, dropped files, `add`, `ctx` (src/ui/paste.ts) ----
2072
+ const screenUI = {
2073
+ print,
2074
+ // A question mid-line would swallow what's typed: park it, ask, put it back.
2075
+ yes: async (q, def) => {
2076
+ const keep = rl.line;
2077
+ if (keep)
2078
+ rl.write(null, { ctrl: true, name: "u" });
2079
+ try {
2080
+ return await yes(rl, q, def);
2081
+ }
2082
+ finally {
2083
+ if (keep)
2084
+ rl.write(keep);
2085
+ }
2086
+ },
2087
+ db: async () => (await openRoom())?.db ?? null,
2088
+ audience: () => {
2089
+ const ready = results.filter((r) => r.state === "ready");
2090
+ return { agents: ready.map((r) => r.adapter.id), companies: new Set(ready.map((r) => r.adapter.vendor)).size };
2091
+ },
2092
+ brain: () => resolveBrain(results),
2093
+ root: repoRoot,
2094
+ interactive: true,
2095
+ };
2096
+ if (out.isTTY && process.stdin.isTTY) {
2097
+ bracketedPaste(true);
2098
+ process.once("exit", () => bracketedPaste(false));
2099
+ interceptStdin(process.stdin, (pass) => new PasteCatcher({
2100
+ pass,
2101
+ enabled: () => !asking && !dash.isOpen(),
2102
+ onPaste: (text) => {
2103
+ const drop = asDrop(text);
2104
+ // Short pastes stay on the line, as one line.
2105
+ if (!drop && !isLongPaste(text))
2106
+ return pass(flatten(text));
2107
+ void (async () => {
2108
+ if (drop)
2109
+ await addPaths(drop, screenUI);
2110
+ else
2111
+ await pasteToScreen(text, screenUI);
2112
+ if (!promptShown)
2113
+ showPrompt();
2114
+ })().catch((e) => print(C.red(" error: ") + (e?.message ?? e)));
2115
+ },
2116
+ }));
2117
+ }
1345
2118
  const startGoal = async (typed) => {
1346
2119
  // "--qa" / "--no-qa" anywhere in a typed goal, like on `huddle join`.
1347
2120
  const qa = /(^|\s)--no-qa(\s|$)/.test(typed) ? false : /(^|\s)--qa(\s|$)/.test(typed) ? true : undefined;
@@ -1359,10 +2132,14 @@ export async function runApp() {
1359
2132
  rl,
1360
2133
  signal: run.signal,
1361
2134
  print,
1362
- onStats: (s) => (stats = s),
2135
+ onStats: (s) => {
2136
+ stats = s;
2137
+ refitFooter();
2138
+ },
1363
2139
  room: r ?? undefined,
1364
2140
  onActivity: (a) => dash.push(a),
1365
2141
  qa,
2142
+ talk,
1366
2143
  });
1367
2144
  }
1368
2145
  catch (e) {
@@ -1389,6 +2166,11 @@ export async function runApp() {
1389
2166
  const line = raw.trim();
1390
2167
  promptShown = false;
1391
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
+ }
1392
2174
  if (!line)
1393
2175
  return showPrompt();
1394
2176
  const [cmd, ...rest] = line.split(/\s+/);
@@ -1397,7 +2179,14 @@ export async function runApp() {
1397
2179
  try {
1398
2180
  if (cmd === "exit" || cmd === "quit")
1399
2181
  return rl.close();
1400
- if (cmd === "help" || cmd === "?")
2182
+ const drop = asDrop(line); // a file dragged in by a terminal that doesn't mark pastes
2183
+ if (drop)
2184
+ await addPaths(drop, screenUI);
2185
+ else if (cmd === "add" && looksLikePaths(line.slice(3)))
2186
+ await addPaths(splitPathTokens(line.slice(3)), screenUI);
2187
+ else if (cmd === "ctx" || cmd === "context" || cmd === "screen")
2188
+ await ctxCommand(line.slice(cmd.length), screenUI);
2189
+ else if (cmd === "help" || cmd === "?")
1401
2190
  print(HELP);
1402
2191
  else if (cmd === "clear" || cmd === "cls") {
1403
2192
  out.write("\x1b[2J\x1b[3J\x1b[H");
@@ -1467,8 +2256,14 @@ export async function runApp() {
1467
2256
  print(C.faint(" nothing running"));
1468
2257
  }
1469
2258
  else if (cmd === "agents") {
2259
+ const [verb, ...ids] = arg.split(/[\s,]+/).filter(Boolean);
1470
2260
  if (run)
1471
2261
  print(C.faint(" finish or stop the current run first"));
2262
+ else if ((verb === "use" || verb === "skip") && ids.length) {
2263
+ setAgentChoice(ids, verb === "use");
2264
+ print(C.faint(` ${verb === "use" ? "using" : "skipping"} ${ids.join(", ")} from now on`));
2265
+ results = await connectAgents();
2266
+ }
1472
2267
  else {
1473
2268
  results = await connectAgents();
1474
2269
  if (await offerSetup(rl, results, true))
@@ -1504,11 +2299,20 @@ export async function runApp() {
1504
2299
  shell(line);
1505
2300
  print(C.faint(` (ran as a shell command — say "build ${line}" if you meant a goal)`));
1506
2301
  }
1507
- 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))) {
1508
2305
  await answer(print, cmd === "ask" ? arg : line, results);
1509
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
+ }
1510
2314
  else {
1511
- const goal = cmd === "build" || cmd === "join" ? arg : line;
2315
+ const goal = cmd === "build" || cmd === "join" || cmd === "next" ? arg : line;
1512
2316
  if (!goal)
1513
2317
  print(C.faint(" usage: build <goal>"));
1514
2318
  else if (run) {