huddle-cli 0.1.0 โ†’ 0.1.1

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 (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. 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,21 @@ 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
33
  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";
34
+ import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
35
+ import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
34
36
  import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
35
37
  import { logoLines } from "./logo.js";
36
38
  import { Dashboard } from "./dashboard.js";
39
+ import { learnFromRun } from "../orchestrator/learn.js";
40
+ import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
41
+ import { splitPathTokens } from "../room/context.js";
37
42
  // 0 = pick a free port per run, so sessions never collide.
38
43
  export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
39
44
  // ---- paths & repo ----------------------------------------------------------
@@ -80,8 +85,8 @@ export function detectTestCmd(root) {
80
85
  return null;
81
86
  }
82
87
  /**
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.
88
+ * The brain to use: your pick if it's usable, else Claude Opus (the strongest
89
+ * plans โ€” the brain writes every task spec), else whichever agent can answer read-only.
85
90
  */
86
91
  export function resolveBrain(results, probed = results) {
87
92
  const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
@@ -95,23 +100,46 @@ export function resolveBrain(results, probed = results) {
95
100
  return b;
96
101
  }
97
102
  if (ready.includes("claude"))
98
- return brainFrom("claude:sonnet");
103
+ return brainFrom("claude:opus");
99
104
  const asker = pickAsker(results.filter((r) => r.state === "ready").map((r) => r.adapter));
100
105
  return asker ? brainFrom(asker.id) : null;
101
106
  }
102
107
  // 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");
108
+ // (Read per call, so a test can point HUDDLE_HOME somewhere else.)
109
+ const prefsFile = () => path.join(process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "prefs.json");
104
110
  export function readPrefs() {
105
111
  try {
106
- return JSON.parse(fs.readFileSync(PREFS, "utf8"));
112
+ return JSON.parse(fs.readFileSync(prefsFile(), "utf8"));
107
113
  }
108
114
  catch {
109
115
  return {};
110
116
  }
111
117
  }
112
118
  function writePrefs(p) {
113
- fs.mkdirSync(path.dirname(PREFS), { recursive: true });
114
- fs.writeFileSync(PREFS, JSON.stringify(p, null, 2));
119
+ fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
120
+ fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
121
+ }
122
+ /** The agents you skipped (`huddle agents skip <id>`). */
123
+ export function skippedAgents() {
124
+ return new Set(readPrefs().skip ?? []);
125
+ }
126
+ /**
127
+ * Use or skip agents, remembered in prefs. Using one also forgets an old
128
+ * "don't install" answer, so Huddle offers it again.
129
+ */
130
+ export function setAgentChoice(ids, use) {
131
+ const p = readPrefs();
132
+ const skip = new Set(p.skip ?? []);
133
+ const declined = new Set(p.declinedInstall ?? []);
134
+ for (const id of ids) {
135
+ if (use) {
136
+ skip.delete(id);
137
+ declined.delete(id);
138
+ }
139
+ else
140
+ skip.add(id);
141
+ }
142
+ writePrefs({ ...p, skip: [...skip], declinedInstall: [...declined] });
115
143
  }
116
144
  // ---- welcome ---------------------------------------------------------------
117
145
  function tildify(p) {
@@ -167,16 +195,24 @@ export async function connectAgents() {
167
195
  return ` ${C.green("โœ“")} ${head} ready`;
168
196
  if (r.state === "not_installed")
169
197
  return ` ${C.faint("โ—‹")} ${head} ${C.faint("not installed")}`;
198
+ if (r.state === "skipped")
199
+ return ` ${C.faint("โ€“")} ${head} ${C.faint(r.detail)}`;
170
200
  return ` ${C.yellow("!")} ${head} ${C.yellow("not signed in")} ${C.faint("โ€” " + r.detail)}`;
171
201
  };
202
+ const skip = skippedAgents();
203
+ const probeOne = async (a) => applySkips([await probeAsync(a)], skip)[0];
172
204
  // Probe legacy CLIs first (silently) so they only appear when installed.
173
205
  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);
206
+ results.set(a.id, await probeOne(a));
207
+ const shownLegacy = (a) => {
208
+ const r = results.get(a.id);
209
+ return r?.state === "ready" || (r?.state === "skipped" && r.was === "ready");
210
+ };
211
+ const visible = ADAPTERS.filter((a) => shown(a) || (LEGACY.has(a.id) && shownLegacy(a)));
176
212
  const live = new LiveLines((spin) => visible.map((a) => row(a, spin))).start();
177
213
  await Promise.all(visible.map(async (a) => {
178
214
  if (!results.has(a.id))
179
- results.set(a.id, await probeAsync(a));
215
+ results.set(a.id, await probeOne(a));
180
216
  live.draw();
181
217
  }));
182
218
  live.stop();
@@ -199,21 +235,52 @@ async function yes(rl, q, def = true) {
199
235
  questionHooks.after();
200
236
  }
201
237
  }
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();
238
+ /**
239
+ * Run a command with the terminal handed over (installers, logins). A string
240
+ * is a shell line โ€” sh, or PowerShell on Windows (`irm โ€ฆ | iex`); an array is
241
+ * a CLI, spawned without a shell.
242
+ */
243
+ export function runInteractive(rl, cmd) {
244
+ rl?.pause();
245
+ bracketedPaste(false); // the other program gets a plain terminal
246
+ const r = typeof cmd === "string" ? runShell(cmd) : spawnToolSync(cmd, { stdio: "inherit" });
247
+ bracketedPaste(true);
248
+ rl?.resume();
209
249
  return r.status === 0;
210
250
  }
251
+ function rlIO(rl) {
252
+ return { ask: (q, def) => yes(rl, q, def), run: (cmd) => runInteractive(rl, cmd), print: (l = "") => console.log(l) };
253
+ }
254
+ /** Install one agent's CLI with this platform's command. False if it failed or there's none. */
255
+ export function installAgent(io, a) {
256
+ const cmd = installCommand(a);
257
+ if (!cmd) {
258
+ io.print(C.yellow(` ${a.id}: `) + C.faint(noInstallHint(a)));
259
+ return false;
260
+ }
261
+ io.print(C.violet(`\nโ†“ installing ${a.id}โ€ฆ`) + C.faint(` $ ${cmd}`));
262
+ const ok = io.run(cmd);
263
+ io.print(ok ? C.green(`โœ“ ${a.id} installed`) : C.red(`โœ— ${a.id} install failed โ€” see output above`));
264
+ return ok;
265
+ }
266
+ /** Hand the terminal to an agent's own sign-in. False if it has no login command. */
267
+ export function signIn(io, a) {
268
+ if (!a.install.login)
269
+ return false;
270
+ if (a.install.loginNote)
271
+ io.print(C.faint(` ${a.install.loginNote}`));
272
+ io.run(a.install.login);
273
+ return true;
274
+ }
275
+ /** The install line to show for an agent on this platform. */
276
+ export const installLine = (a) => installCommand(a) ?? noInstallHint(a);
211
277
  /**
212
278
  * Offer to install missing agents and sign in to logged-out ones โ€” always with
213
279
  * the user's OK. A "no" is remembered so we don't nag on every launch; the
214
280
  * `agents` command asks again.
215
281
  */
216
282
  export async function offerSetup(rl, results, force = false) {
283
+ const io = rlIO(rl);
217
284
  const prefs = readPrefs();
218
285
  const declined = new Set(force ? [] : (prefs.declinedInstall ?? []));
219
286
  const missing = results.filter((r) => r.state === "not_installed" && !declined.has(r.adapter.id) && !LEGACY.has(r.adapter.id));
@@ -222,19 +289,14 @@ export async function offerSetup(rl, results, force = false) {
222
289
  if (missing.length) {
223
290
  console.log();
224
291
  for (const r of missing)
225
- console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint("$ " + r.adapter.install.cmd)}`);
292
+ console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint(installCommand(r.adapter) ? "$ " + installLine(r.adapter) : installLine(r.adapter))}`);
226
293
  const names = missing.map((r) => r.adapter.id).join(", ");
227
294
  if (await yes(rl, `\nInstall ${names}?`)) {
228
295
  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`));
296
+ const ok = installAgent(io, r.adapter);
232
297
  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
- }
298
+ if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`)))
299
+ signIn(io, r.adapter);
238
300
  }
239
301
  }
240
302
  else {
@@ -247,9 +309,7 @@ export async function offerSetup(rl, results, force = false) {
247
309
  if (!login)
248
310
  continue;
249
311
  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);
312
+ signIn(io, r.adapter);
253
313
  changed = true;
254
314
  }
255
315
  else {
@@ -259,6 +319,29 @@ export async function offerSetup(rl, results, force = false) {
259
319
  }
260
320
  return changed;
261
321
  }
322
+ /**
323
+ * The live status block under the run's output, one line per agent:
324
+ * โ— claude 60% Build the CLI adapter ยท note 20s ago ยท 1m12s
325
+ * โ—‹ cursor idle ยท 2 done
326
+ * Every line fits `W` cells: the title gives way, never the numbers.
327
+ */
328
+ export function statusLines(stats, W, now = Date.now()) {
329
+ const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
330
+ return [...stats.agents].map(([id, a]) => {
331
+ const head = ` ${a.busySince ? dot(id) : C.faint("โ—‹")} ${pad(id, idW)} `;
332
+ if (!a.busySince) {
333
+ const done = a.tasks + a.reviews + (a.qa ?? 0);
334
+ return head + C.faint(done ? `idle ยท ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
335
+ }
336
+ const pct = a.pct === undefined ? C.faint(" ยท ") : C.violet(`${String(a.pct).padStart(3)}%`);
337
+ const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
338
+ const said = a.noteAt === undefined ? "no note yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
339
+ const tail = (quiet > 120_000 ? C.yellow(` ยท ${said}`) : C.faint(` ยท ${said}`)) +
340
+ C.faint(` ยท ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` ยท ${fmtTokens(a.liveTokens)} tok` : ""}`);
341
+ const room = Math.max(8, W - width(head) - 5 - width(tail));
342
+ return `${head}${pct} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`;
343
+ });
344
+ }
262
345
  export function newStats(workers) {
263
346
  return {
264
347
  startedAt: Date.now(),
@@ -340,7 +423,7 @@ async function prepareRepo(print, rl, goal = "") {
340
423
  if (!(await yes(rl, " Set it up? (git init + commit your current files)")))
341
424
  return false;
342
425
  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"));
426
+ print(C.red(" โœ— git isn't installed.") + C.faint(" " + gitFix()));
344
427
  return false;
345
428
  }
346
429
  const gi = path.join(process.cwd(), ".gitignore");
@@ -358,7 +441,7 @@ async function prepareRepo(print, rl, goal = "") {
358
441
  print(` ${C.green("โœ“")} git repo ready`);
359
442
  }
360
443
  ensureInit();
361
- const dirty = git(["status", "--porcelain"]).out.split("\n").filter((l) => l && !l.includes(".huddle/"));
444
+ const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
362
445
  if (dirty.length) {
363
446
  if (!rl) {
364
447
  print(C.yellow(`! ${dirty.length} uncommitted change(s) โ€” agents only see committed files.`));
@@ -423,23 +506,41 @@ export function likelyAgents(tasks, workers) {
423
506
  }
424
507
  return out;
425
508
  }
509
+ /** Room left on a line once its fixed parts are drawn: the terminal's width, or 120 in a log. */
510
+ const rawSpace = (...used) => (lineWidth() ?? 120) - used.reduce((n, u) => n + width(u), 0);
511
+ const space = (...used) => Math.max(24, rawSpace(...used));
512
+ /**
513
+ * A phase header. Every run reads the same way, top to bottom:
514
+ * โ”โ” COUNCIL โ”โ” โ†’ โ”โ” PLAN โ”โ” โ†’ โ”โ” BUILD โ”โ” โ†’ โ”โ” QA โ”โ” โ†’ โ”โ” RESULT โ”โ”
515
+ */
516
+ function phase(print, name, sub = "") {
517
+ const head = `${C.violet("โ”โ”")} ${C.bold(C.violet(name))} ${C.violet("โ”โ”")}`;
518
+ // Cut to the terminal; a log keeps the whole line.
519
+ const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
520
+ print(`\n${head}${rest}`);
521
+ }
426
522
  function printPlan(print, goal, plan, workers) {
427
523
  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 : ""))}`);
524
+ phase(print, "PLAN", `${plan.tasks.length} task(s) ยท by ${plan.by}${took}${plan.note ? " โ€” " + plan.note : ""}`);
525
+ print(` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`);
429
526
  const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
430
527
  const likely = likelyAgents(plan.tasks, workers);
431
528
  plan.tasks.forEach((t, i) => {
432
529
  const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
433
530
  const fit = likely[i];
434
531
  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}`);
532
+ const who = fit ? ` ${C.faint("โ†’ likely")} ${dot(fit)} ${fit}` : "";
533
+ const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
534
+ print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
436
535
  });
437
536
  print(C.faint(` ${plan.tasks.length} task(s), ${Math.min(startable, workers.length)} of ${workers.length} agents can start right away`));
438
537
  // The council's agreements, which every agent can huddle_recall mid-task.
439
538
  for (const d of plan.council?.decisions ?? [])
440
- print(` ${C.violet("โ—†")} ${C.faint("decided:")} ${oneLine(d, 110)}`);
441
- for (const r of plan.council?.risks ?? [])
442
- print(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${oneLine(r.body, 105)}`);
539
+ print(` ${C.violet("โ—†")} ${C.faint("decided:")} ${oneLine(d, space(" โ—† decided: "))}`);
540
+ for (const r of plan.council?.risks ?? []) {
541
+ const label = ` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} `;
542
+ print(`${label}${oneLine(r.body, space(label))}`);
543
+ }
443
544
  }
444
545
  /** Show a spinner line while a promise settles (TTY only). */
445
546
  async function withSpinner(label, p) {
@@ -456,7 +557,11 @@ async function withSpinner(label, p) {
456
557
  }
457
558
  }
458
559
  export async function runGoal(goal, opts) {
459
- const print = opts.print ?? ((l = "") => console.log(l));
560
+ // Every line fits the terminal (logs keep everything). In a one-shot run on a
561
+ // terminal, the per-agent status lines sit under the output (the Dock).
562
+ let dock = null;
563
+ const sink = opts.print ?? ((l = "") => (dock ? dock.log(l) : console.log(l)));
564
+ const print = (l = "") => sink(fit(l));
460
565
  // Typed at the prompt too: `build a todo app from scratch --no-council`.
461
566
  const flagIn = goal.match(/(^|\s)--(no-)?council(?=\s|$)/);
462
567
  if (flagIn)
@@ -485,37 +590,77 @@ export async function runGoal(goal, opts) {
485
590
  const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
486
591
  const members = councilMembers(workers);
487
592
  // 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;
593
+ const repoFiles = git(["ls-files"], root).out.split(/\r?\n/).filter(Boolean).length;
489
594
  const wantCouncil = councilWanted(goal, { flag: councilFlag, members: members.length, repoFiles });
490
595
  if (councilFlag === true && !wantCouncil.yes)
491
596
  print(C.yellow(` ! no design council: ${wantCouncil.why}`));
492
597
  let plan;
493
598
  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) => {
599
+ phase(print, "COUNCIL", `${members.map((m) => m.id).join(", ")} brainstorm before anyone codes ยท ${wantCouncil.why} (--no-council skips)`);
600
+ /** " โ— codex adopts claude's: <body, cut to fit> ยท 14s" */
601
+ const say = (agent, label, body = "", took = "") => {
602
+ const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
603
+ const text = `${label}${body ? " " + oneLine(body, space(head, took)) : ""}${took}`;
496
604
  print(` ${dot(agent)} ${pad(agent, 12)} ${text}`);
497
605
  opts.onActivity?.({ agent, text });
498
606
  };
499
607
  const onCouncil = (e) => {
500
608
  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โ€ฆ`));
609
+ print(C.faint(` ${e.round === "proposal" ? "1/4 each agent proposes a designโ€ฆ" : "2/4 each agent reviews the others: what to adopt, what's flawedโ€ฆ"}`));
610
+ if (e.type === "synthesizing") {
611
+ const head = ` 3/4 ${e.brain} writes the plan and its contract`;
612
+ print(C.faint(`${head}${e.why ? ` ยท ${oneLine(e.why, space(head, " ยท "))}` : "โ€ฆ"}`));
613
+ }
614
+ if (e.type === "ratify")
615
+ print(C.faint(` 4/4 ${e.agents.join(", ")} check ${e.by}'s contractโ€ฆ`));
616
+ if (e.type === "ratified") {
617
+ const r = e.entry;
618
+ const took = C.faint(` ยท ${fmtDuration(r.ms)}`);
619
+ if (r.verdict === "skipped")
620
+ return say(r.agent, C.faint(`ratify skipped (${r.skipped})`), "", took);
621
+ if (r.verdict === "object")
622
+ return say(r.agent, C.yellow(`objects (${r.objections.length}):`), r.objections.join(" ยท "), took);
623
+ return say(r.agent, r.verdict === "ok" ? C.green("โœ“ ok") : C.faint("no clear verdict ยท taken as ok"), "", took);
624
+ }
625
+ if (e.type === "revised")
626
+ 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
627
  if (e.type !== "entry")
505
628
  return;
506
629
  const x = e.entry;
507
630
  const took = C.faint(` ยท ${fmtDuration(x.ms)}`);
508
631
  if (!x.ok)
509
- return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`) + took);
632
+ return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`), "", took);
510
633
  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)}`);
634
+ return say(x.agent, C.faint("proposes:"), headline(x.text, "ARCHITECTURE"), took);
635
+ // A cross-review: what it took from whom, and what it found wrong in whose.
636
+ const adopt = x.adopts?.[0];
637
+ const flawPt = x.flaws?.[0];
638
+ if (!adopt && !flawPt)
639
+ return say(x.agent, C.faint("flags:"), headline(x.text, "FLAWS"), took);
640
+ if (adopt)
641
+ say(x.agent, `${C.green("+ adopts")} ${adopt.of}'s idea:`, adopt.text, took);
642
+ else
643
+ say(x.agent, `${C.yellow("! flaw in")} ${flawPt.of}'s idea:`, flawPt.text, took);
644
+ if (adopt && flawPt) {
645
+ const head = ` ${" ".repeat(15)}${C.yellow("! flaw in")} ${flawPt.of}'s idea: `;
646
+ print(`${head}${oneLine(flawPt.text, space(head))}`);
647
+ }
517
648
  };
518
- plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil });
649
+ // Who synthesized before in this repo's Room: ties rotate the job.
650
+ let past = {};
651
+ try {
652
+ if (opts.room?.db)
653
+ past = pastSyntheses(opts.room.db);
654
+ else if (fs.existsSync(dbPath())) {
655
+ const d = new RoomDB(dbPath());
656
+ past = pastSyntheses(d);
657
+ d.close();
658
+ }
659
+ }
660
+ catch {
661
+ /* no history: roster order */
662
+ }
663
+ plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil, pastSyntheses: past });
519
664
  }
520
665
  else {
521
666
  // A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
@@ -551,6 +696,9 @@ export async function runGoal(goal, opts) {
551
696
  const testCmd = detectTestCmd(root);
552
697
  const ownRoom = !opts.room;
553
698
  const db = opts.room?.db ?? new RoomDB(dbPath());
699
+ // What this run teaches the next one is learned from its own records (orchestrator/learn.ts).
700
+ const runStart = Date.now();
701
+ const seen = [];
554
702
  db.clearBoard();
555
703
  const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
556
704
  const taskIds = seedBoard(db, goal, plan.tasks);
@@ -559,37 +707,86 @@ export async function runGoal(goal, opts) {
559
707
  // Before the live listener below, so the council's memories aren't printed twice.
560
708
  if (plan.council)
561
709
  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");
710
+ // The ratified contract goes on main before any task branches off it, so every worktree has it.
711
+ if (plan.council?.contract) {
712
+ const by = plan.council.synthesizer?.id ?? plan.council.synthesizedBy;
713
+ const ok = commitFile(root, "HUDDLE_CONTRACT.md", contractFile(plan.council.contract, by, plan.council.members), "huddle: the council's contract");
714
+ const objected = plan.council.objections?.length ?? 0;
715
+ print(` ${C.violet("โ—†")} ${C.faint("contract:")} ${ok ? "HUDDLE_CONTRACT.md on main" : "in the Room (couldn't commit HUDDLE_CONTRACT.md)"} ${C.faint(`ยท by ${by}` + (objected ? ` ยท ${plan.council.objections.filter((o) => o.taken).length}/${objected} objection(s) taken` : ""))}`);
716
+ }
717
+ const t0 = Date.now();
718
+ const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
719
+ opts.onStats?.(stats);
720
+ phase(print, "BUILD", `${plan.tasks.length} task(s) ยท ${workers.length + remoteNow().length} agent(s) ยท each in its own worktree, merged when tests pass`);
721
+ print(C.faint(` room ${room.url} ยท tests: ${testCmd ?? "none yet"} ยท approvals: ${opts.approvals}`) +
722
+ (rl ? C.faint(" ยท ctrl-t live view ยท ctrl-c stops") : ""));
723
+ print();
724
+ // A one-shot run on a terminal (huddle join): who's on what, pinned under the output.
725
+ // (The prompt app draws the same lines in its footer.)
726
+ if (!opts.print && process.stdout.isTTY)
727
+ dock = new Dock(() => [
728
+ C.faint(` โฑ ${fmtDuration(Date.now() - t0)} ยท ${[...stats.agents.values()].reduce((n, a) => n + a.tasks, 0)} merged so far`),
729
+ ...statusLines(stats, (process.stdout.columns || 80) - 1),
730
+ ]).start();
566
731
  const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
732
+ /** " โ— codex <label> <body, cut to fit><suffix>" */
733
+ const line = (agent, label, body = "", suffix = "") => {
734
+ const head = ` ${tag(agent)} ${label}${body ? " " : ""}`;
735
+ // A quoted title keeps its closing quote when it's cut.
736
+ const q = /^โ€œ([\s\S]*)โ€$/.exec(body);
737
+ // On a narrow screen a quoted title gives way entirely before the verdict after it does.
738
+ if (q && rawSpace(head, suffix) < 14)
739
+ return ` ${tag(agent)} ${label}${suffix}`;
740
+ const text = q ? `โ€œ${oneLine(q[1], rawSpace(head, suffix) - 2)}โ€` : body ? oneLine(body, space(head, suffix)) : "";
741
+ return `${head}${text}${suffix}`;
742
+ };
743
+ /** The parts after a title, longest first: the first that leaves the whole title (or 32 cells of it) wins. */
744
+ const fitting = (agent, label, title, ...suffixes) => suffixes.find((x) => rawSpace(` ${tag(agent)} ${label} `, x) >= Math.min(32, width(title))) ?? suffixes[suffixes.length - 1];
745
+ // Handoffs land just before a task starts: one "ยท read 3 handoffs" on the โ–ธ line, not a line each.
746
+ const handoffs = new Map();
747
+ const sayOne = (agent, label, body = "", suffix = "") => print(line(agent, label, body, suffix));
748
+ /** Up to `n` lines of a teammate's notes, indented under the line that sent them. */
749
+ const notesUnder = (notes, n = 3) => {
750
+ for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*โ€ข]\s*/, "").trim()).filter(Boolean).slice(0, n))
751
+ print(C.faint(` ${oneLine(l, space(" "))}`));
752
+ };
753
+ let talk = 0; // agent-to-agent messages this run, for the teamwork summary
567
754
  const act = (agent, text) => opts.onActivity?.({ agent, text });
568
755
  const unsub = db.onEvent((e) => {
569
756
  const p = JSON.parse(e.payload || "{}");
570
757
  if (e.type === "task.progress" && p.note) {
571
758
  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)}`);
759
+ const st = stats.agents.get(agent);
760
+ if (st)
761
+ Object.assign(st, { pct: Number(p.pct) || 0, noteAt: Date.now() });
762
+ sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
573
763
  act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
574
764
  }
575
765
  else if (e.type === "memory.added" && p.author !== "huddle") {
576
- print(` ${C.violet("โ—†")} ${pad(C.faint("memory"), 12)} ${oneLine(p.body, 100)}`);
577
766
  act(undefined, `${C.violet("โ—†")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
767
+ // Already on screen as its own line: a task's done note (โœ“ merged), a QA bug (โœ— found), a message (a โ†’ b).
768
+ if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ โ†’ \S+:/.test(String(p.body)))
769
+ return;
770
+ const head = ` ${C.violet("โ—†")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
771
+ print(`${head}${oneLine(p.body, space(head))}`);
578
772
  }
579
773
  else if (e.type === "plan.announced" && e.agent_id) {
580
- print(` ${tag(e.agent_id)} ${C.faint("plans:")} ${oneLine(p.intent ?? "", 100)}`);
774
+ sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
581
775
  act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
582
776
  }
583
777
  else if (e.type === "agent.message" && p.kind !== "question") {
584
778
  // 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)}`);
779
+ // Huddle's own relay of a buddy check is the โ—Ž buddy check line itself.
780
+ if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
781
+ return;
782
+ if (p.from !== "huddle")
783
+ talk++;
784
+ const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("โ†’")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
785
+ const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
786
+ print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
787
+ act(undefined, `${arrow}${oneLine(p.body, 200)}`);
588
788
  }
589
789
  });
590
- const t0 = Date.now();
591
- const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
592
- opts.onStats?.(stats);
593
790
  const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
594
791
  const cost = (ms, tokens, cached) => C.faint(` ยท ${fmtDuration(ms)} ยท ${tok(tokens, cached)}`);
595
792
  if (remoteNow().length && !(remote && sync))
@@ -621,6 +818,7 @@ export async function runGoal(goal, opts) {
621
818
  }
622
819
  : undefined,
623
820
  onEvent: (e) => {
821
+ seen.push(e);
624
822
  // HUDDLE_EVENTS_FILE=<path>: every scheduler event as a JSON line (evals, post-mortems).
625
823
  if (process.env.HUDDLE_EVENTS_FILE)
626
824
  fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
@@ -628,11 +826,9 @@ export async function runGoal(goal, opts) {
628
826
  stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
629
827
  const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
630
828
  if (e.type === "start" && a)
631
- Object.assign(a, { busySince: Date.now(), liveTokens: 0 });
829
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined }); // -1 = not reported yet
632
830
  if (e.type === "tokens" && a)
633
831
  a.liveTokens = e.tokens;
634
- if (e.type === "start" && a)
635
- a.liveTokens = -1; // -1 = not reported yet
636
832
  if ((e.type === "merged" || e.type === "failed") && a)
637
833
  Object.assign(a, {
638
834
  busySince: null,
@@ -641,19 +837,20 @@ export async function runGoal(goal, opts) {
641
837
  cached: a.cached + e.cached,
642
838
  ms: a.ms + e.ms,
643
839
  tasks: a.tasks + 1,
840
+ doing: undefined,
644
841
  });
645
842
  if (e.type === "review" && a)
646
- Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
843
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `review ${e.author}'s โ€œ${e.title}โ€`, pct: undefined, noteAt: undefined });
647
844
  if (e.type === "reviewed" && a)
648
- Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1 });
845
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1, doing: undefined });
649
846
  if (e.type === "takeover") {
650
847
  // The author's rounds end here (no merged/failed event for it): count them.
651
848
  const f = e.kind === "rescue" ? stats.agents.get(e.from) : undefined;
652
849
  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 });
850
+ 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
851
  const what = e.kind === "rescue" ? `took over ${e.from}'s` : `took part of ${e.from}'s task:`;
655
852
  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))}`);
853
+ sayOne(e.agent, C.yellow("โคณ " + what), `โ€œ${e.title}โ€`, C.faint(" ยท " + oneLine(e.reason, 60)));
657
854
  }
658
855
  if (e.type === "start")
659
856
  act(e.agent, `${C.violet("โ–ธ")} ${e.takeover ? `continuing ${e.takeover}'s work: ` : e.fixing ? "fixing after review: " : ""}${e.title}`);
@@ -665,41 +862,75 @@ export async function runGoal(goal, opts) {
665
862
  act(e.agent, `${C.violet("โ—Ž")} reviewing ${e.author}'s work`);
666
863
  if (e.type === "reviewed")
667
864
  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}`) : ""));
865
+ if (e.type === "start") {
866
+ const from = handoffs.get(e.agent) ?? [];
867
+ handoffs.delete(e.agent);
868
+ const label = `${C.violet("โ–ธ")}${e.takeover ? C.yellow(` continuing ${e.takeover}'s work:`) : e.fixing ? C.yellow(" fixing after review:") : ""}`;
869
+ const read = from.length ? ` ยท read ${from.length} handoff${from.length === 1 ? "" : "s"} (${[...new Set(from)].join(", ")})` : "";
870
+ const remote = e.remote ? ` ยท offered to ${e.remote}` : "";
871
+ 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, "")));
872
+ }
873
+ if (e.type === "contract") {
874
+ act(e.agent, `${C.violet("โ—†")} amended the contract (v${e.version}): ${e.section}: ${e.change}`);
875
+ sayOne(e.agent, `${C.violet("โ—† contract v" + e.version)} ${C.faint(e.section + ":")}`, e.change);
876
+ }
671
877
  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}โ€`)}`);
878
+ handoffs.set(e.agent, [...(handoffs.get(e.agent) ?? []), e.from]);
673
879
  if (e.type === "declined")
674
- print(` ${tag(e.agent)} ${C.faint("โœ— declined ยท back on the board")} ${C.faint(e.title)}`);
880
+ sayOne(e.agent, C.faint("โœ— declined ยท back on the board"), e.title);
675
881
  if (e.type === "review")
676
- print(` ${tag(e.agent)} ${C.violet("โ—Ž")} reviewing ${e.author}'s ${C.faint("โ€œ" + e.title + "โ€")}`);
882
+ sayOne(e.agent, `${C.violet("โ—Ž reviews")} ${e.author}'s`, `โ€œ${e.title}โ€`);
677
883
  if (e.type === "reviewed") {
678
884
  if (e.skipped)
679
- print(` ${tag(e.agent)} ${C.faint("โ—‹ review skipped")} ${C.faint("ยท " + e.notes + " ยท " + fmtDuration(e.ms) + " ยท merging on tests alone")}`);
885
+ sayOne(e.agent, C.faint("โ—‹ review skipped ยท"), e.notes, C.faint(` ยท ${fmtDuration(e.ms)} ยท merging on tests alone`));
680
886
  else if (e.approve)
681
887
  print(` ${tag(e.agent)} ${C.green("โœ“ approved")} ${e.author}'s work ${C.faint("ยท " + fmtDuration(e.ms))}`);
682
888
  else {
683
889
  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)}`));
890
+ notesUnder(e.notes, 4);
686
891
  }
687
892
  }
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)}`);
893
+ if (e.type === "merged") {
894
+ const short = e.reason.replace(/\s*\([^)]*\)/g, "");
895
+ const c = cost(e.ms, e.tokens, e.cached);
896
+ 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));
897
+ }
690
898
  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))}`);
899
+ 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
900
  if (e.type === "conflict" && a)
693
- Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
901
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `fix a merge conflict in โ€œ${e.title}โ€`, pct: undefined, noteAt: undefined });
694
902
  if (e.type === "resolved" && a)
695
- Object.assign(a, { busySince: null, ms: a.ms + e.ms });
903
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, doing: undefined });
696
904
  if (e.type === "conflict") {
697
905
  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}`));
906
+ 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
907
  }
701
908
  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))}`);
909
+ sayOne(e.agent, e.ok ? C.green("โœ“ resolved") : C.yellow("โœ— resolution rejected"), "", C.faint(" ยท " + oneLine(e.reason, 60) + " ยท " + fmtDuration(e.ms)));
910
+ // The integration radar told both agents mid-task (their inbox has the details).
911
+ if (e.type === "radar") {
912
+ act(e.agent, `${C.yellow("โš  radar")} ${e.kind === "overlap" ? "overlap" : "name mismatch"} with ${e.with}: ${e.detail}`);
913
+ sayOne(e.agent, `${C.yellow(e.kind === "overlap" ? "โš  radar: overlap" : "โš  radar: name mismatch")} with ${e.with}:`, e.detail);
914
+ }
915
+ // A buddy check: another agent read the author's work in progress; notes went to its inbox.
916
+ if (e.type === "checkpoint") {
917
+ const ok = /^LOOKS GOOD/i.test(e.notes.trim());
918
+ 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)}`);
919
+ const verdict = ok ? C.green(" ยท looks good") : e.late ? C.yellow(" ยท came back after it landed (kept in shared memory)") : C.yellow(" ยท notes sent");
920
+ sayOne(e.agent, `${C.violet("โ—Ž buddy check")} ${e.author}'s`, `โ€œ${e.title}โ€`, ` ${e.pct ? `at ${e.pct}%` : "mid-task"}${verdict}`);
921
+ if (!ok)
922
+ notesUnder(e.notes);
923
+ }
924
+ // A note that needed an answer, settled: addressed (fixed or replied) or ignored.
925
+ if (e.type === "followup") {
926
+ const what = e.kind === "radar" ? "radar finding" : `${e.by}'s buddy note`;
927
+ const how = e.how === "ignored" ? C.yellow("ignored") : C.green(e.how === "replied" ? "answered" : "addressed in code");
928
+ sayOne(e.agent, `${C.violet("โ—Ž")} ${what} ${how} ${C.faint("ยท")}`, e.note.replace(/^buddy check from [^:]*:\s*/i, ""), C.faint(` ยท โ€œ${oneLine(e.title, 40)}โ€`));
929
+ }
930
+ if (e.type === "pair") {
931
+ act(e.agent, `${C.violet("โ‡‰")} pairs with ${e.with}: ${e.part}`);
932
+ sayOne(e.agent, `${C.violet("โ‡‰ pairs with " + e.with)} on โ€œ${oneLine(e.title, 40)}โ€: takes`, e.part, C.faint(" ยท " + oneLine(e.reason, 50)));
933
+ }
703
934
  if (e.type === "stuck")
704
935
  print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" ยท ")));
705
936
  },
@@ -708,11 +939,12 @@ export async function runGoal(goal, opts) {
708
939
  // QA: agents that didn't write the code re-check the integrated main; bugs
709
940
  // they file become routed fix tasks, run through this same board.
710
941
  let qaNote = "";
942
+ let qaSum = ""; // the RESULT's checks line
711
943
  if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
712
944
  if (res.failed)
713
945
  print(C.faint(`\n QA skipped โ€” ${res.failed} task(s) didn't merge; it checks finished builds only`));
714
946
  else {
715
- print(`\n${C.violet("QA")} ${C.faint("ยท agents that didn't write it check the build ยท --no-qa to skip")}`);
947
+ phase(print, "QA", "agents that didn't write it check the build ยท --no-qa skips");
716
948
  const qa = await runQa({
717
949
  db,
718
950
  room,
@@ -725,6 +957,7 @@ export async function runGoal(goal, opts) {
725
957
  signal: opts.signal,
726
958
  runFixes: () => runBoard(boardOpts),
727
959
  onEvent: (e) => {
960
+ seen.push(e);
728
961
  if (process.env.HUDDLE_EVENTS_FILE)
729
962
  fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
730
963
  qaLine(e, tag, print, act, stats);
@@ -733,6 +966,7 @@ export async function runGoal(goal, opts) {
733
966
  res.merged += qa.fixed;
734
967
  res.failed += qa.failed;
735
968
  res.stopped ||= qa.stopped;
969
+ 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
970
  qaNote = qa.clean
737
971
  ? `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
972
  : `QA: ${qa.rounds} round(s); ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, but the last round did NOT come back clean.`;
@@ -747,15 +981,43 @@ export async function runGoal(goal, opts) {
747
981
  if (ev.task_id && p.kind === "rescue" && p.branch)
748
982
  takeovers.set(ev.task_id, String(p.branch));
749
983
  }
984
+ const learned = learnFromRun({ db, root, since: runStart, events: seen });
750
985
  if (ownRoom) {
751
986
  room.close();
752
987
  db.close();
753
988
  }
754
989
  opts.onStats?.(stats);
990
+ dock?.stop();
991
+ dock = null;
992
+ // RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
993
+ phase(print, "RESULT", "what got built, how it was checked, who did what");
755
994
  const head = res.stopped ? C.yellow("โ–  stopped") : res.failed ? C.yellow("โ—Ž") : C.green("โœ“");
756
995
  const total = [...stats.agents.values()].reduce((n, a) => n + a.tokens, 0);
757
996
  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)}`)}`);
997
+ print(`${head} ${res.merged} merged, ${res.failed} failed ${C.faint(`ยท ${fmtDuration(Date.now() - t0)} ยท ${tok(total, totalCached)}`)}`);
998
+ const testsNow = detectTestCmd(root);
999
+ 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")}`);
1000
+ if (qaSum)
1001
+ print(` ${C.faint(pad("QA", 9))} ${qaSum}`);
1002
+ for (const t of parked) {
1003
+ // Never resolved by force: the branch holds the agent's work exactly as it left it.
1004
+ // A taken-over task still lives on its first agent's branch.
1005
+ const tk = takeovers.get(t.id);
1006
+ const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
1007
+ print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` โ€” ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
1008
+ print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
1009
+ }
1010
+ if (res.merged)
1011
+ await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
1012
+ const team = teamworkLines(plan.council, seen, talk);
1013
+ if (team.length) {
1014
+ print(`\n${C.violet("TEAMWORK")} ${C.faint("ยท how the agents worked together ยท huddle story for all of it")}`);
1015
+ for (const t of team) {
1016
+ const label = ` ${C.faint(pad(t.label, 9))} `;
1017
+ print(`${label}${oneLine(t.text, space(label))}`);
1018
+ }
1019
+ }
1020
+ print(`\n${C.violet("COST")} ${C.faint(`ยท ${fmtDuration(Date.now() - t0)} ยท ${tok(total, totalCached)}`)}`);
759
1021
  for (const [id, a] of stats.agents) {
760
1022
  if (!a.tasks && !a.reviews && !a.qa) {
761
1023
  print(` ${tag(id)} ${C.faint("idle this run")}`);
@@ -768,22 +1030,79 @@ export async function runGoal(goal, opts) {
768
1030
  ]
769
1031
  .filter(Boolean)
770
1032
  .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`));
1033
+ print(` ${tag(id)} ${pad(did, 27)} ${pad(fmtDuration(a.ms), 7)} ${a.tasks ? tok(a.tokens, a.cached) : ""}`);
780
1034
  }
1035
+ if (learned.length)
1036
+ print(C.faint(` โ—† learned ${learned.length} lesson(s) for next time ยท huddle lessons to see them`));
781
1037
  if (res.merged && remote && sync) {
782
1038
  const p = pushMerged(root);
783
1039
  print(p.ok ? ` ${C.green("โ†‘")} git push ยท all in one go ${C.faint("โ†’ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
784
1040
  }
785
- if (res.merged)
786
- await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: detectTestCmd(root), qaNote }));
1041
+ }
1042
+ /**
1043
+ * The run's teamwork, one line each, from what the council and the scheduler
1044
+ * recorded โ€” counts, not judgments. [] for a run where nobody worked with anybody.
1045
+ */
1046
+ export function teamworkLines(council, events, messages = 0) {
1047
+ const out = [];
1048
+ const n = (k, one, many = one + "s") => `${k} ${k === 1 ? one : many}`;
1049
+ const of = (t) => events.filter((e) => e.type === t);
1050
+ if (council) {
1051
+ const adopts = council.critiques.flatMap((c) => c.adopts ?? []);
1052
+ const flaws = council.critiques.flatMap((c) => c.flaws ?? []);
1053
+ if (adopts.length || flaws.length) {
1054
+ // "claude's taken by codex, cursor": whose ideas the others built on.
1055
+ const takers = new Map();
1056
+ for (const a of adopts)
1057
+ takers.set(a.of, (takers.get(a.of) ?? new Set()).add(a.by));
1058
+ const whose = [...takers].sort((a, b) => b[1].size - a[1].size).map(([of, by]) => `${of}'s taken by ${[...by].join(", ")}`);
1059
+ out.push({ label: "ideas", text: `${n(adopts.length, "idea")} adopted, ${n(flaws.length, "flaw")} flagged${whose.length ? ` ยท ${whose.join(" ยท ")}` : ""}` });
1060
+ }
1061
+ const by = council.synthesizer?.id ?? council.synthesizedBy;
1062
+ const obj = council.objections ?? [];
1063
+ const okd = (council.ratify ?? []).filter((r) => r.verdict === "ok").length;
1064
+ if (council.contract)
1065
+ out.push({
1066
+ label: "contract",
1067
+ text: `written by ${by}${obj.length ? ` ยท ${obj.filter((o) => o.taken).length} of ${n(obj.length, "objection")} taken` : okd ? ` ยท ${okd} ratified it as is` : ""}`,
1068
+ });
1069
+ }
1070
+ const amends = of("contract");
1071
+ if (amends.length)
1072
+ 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(", ")}` });
1073
+ const checks = of("checkpoint");
1074
+ if (checks.length) {
1075
+ const sent = checks.filter((e) => !/^LOOKS GOOD/i.test(String(e.notes ?? "").trim()));
1076
+ const ups = of("followup").filter((e) => e.kind !== "radar");
1077
+ const k = (h) => ups.filter((e) => e.how === h).length;
1078
+ out.push({
1079
+ label: "buddy",
1080
+ text: sent.length
1081
+ ? `${n(checks.length, "mid-task check")} ยท ${n(sent.length, "note")} sent` + (ups.length ? ` โ†’ ${k("fixed")} fixed, ${k("replied")} answered, ${k("ignored")} ignored` : "")
1082
+ : `${n(checks.length, "mid-task check")} ยท all looked good`,
1083
+ });
1084
+ }
1085
+ const radar = of("radar");
1086
+ if (radar.length)
1087
+ 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(", ")}` });
1088
+ for (const p of of("pair").slice(0, 3))
1089
+ out.push({ label: "pair", text: `${p.agent} helped ${p.with} on โ€œ${p.title}โ€: ${p.part}` });
1090
+ const takes = of("takeover");
1091
+ if (takes.length)
1092
+ 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(" ยท ") });
1093
+ const reviews = of("reviewed").filter((e) => !e.skipped);
1094
+ if (reviews.length) {
1095
+ const asked = reviews.filter((e) => !e.approve).length;
1096
+ out.push({ label: "reviews", text: `${n(reviews.length, "cross-review")} ยท ${reviews.length - asked} approved${asked ? `, ${asked} sent back for changes` : ""}` });
1097
+ }
1098
+ const bugs = events.filter((e) => e.type === "bug" && e.round !== undefined);
1099
+ if (bugs.length) {
1100
+ const fixers = [...new Set(bugs.map((e) => e.assignedTo).filter(Boolean))];
1101
+ 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"}` });
1102
+ }
1103
+ if (messages)
1104
+ out.push({ label: "talk", text: `${n(messages, "message")} between agents` });
1105
+ return out;
787
1106
  }
788
1107
  /** QA's readable lines: "cursor (QA) found: โ€ฆ", "โ†’ fix assigned to codex". */
789
1108
  function qaLine(e, tag, print, act, stats) {
@@ -792,26 +1111,32 @@ function qaLine(e, tag, print, act, stats) {
792
1111
  print(` ${C.violet("โ—Ž")} ${C.bold(`QA round ${e.round}`)} ${C.faint("ยท checked by " + e.agents.join(", "))}`);
793
1112
  if (e.type === "start") {
794
1113
  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)") : ""}`);
1114
+ Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: `QA check, round ${e.round}`, pct: undefined, noteAt: undefined });
1115
+ 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
1116
  act(e.agent, `${C.violet("โ—Ž")} QA round ${e.round}`);
798
1117
  }
799
1118
  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")}`);
1119
+ const head = ` ${tag(e.agent)} ${C.yellow("โœ— QA bug:")} `;
1120
+ // The bug's title first; where it is only if there's room.
1121
+ const full = (e.files.length ? C.faint(" ยท " + e.files.slice(0, 2).join(", ")) : "") + (e.test ? C.faint(" ยท + test") : "");
1122
+ const where = space(head, full) >= 40 ? full : "";
1123
+ print(`${head}${oneLine(e.title, space(head, where))}${where}`);
1124
+ print(` ${pad("", 14)} ${C.faint("โ†’ fix goes to")} ${e.assignedTo ? `${dot(e.assignedTo)} ${e.assignedTo}` : C.faint("any free agent")}`);
802
1125
  act(e.agent, `${C.yellow("โœ— found:")} ${e.title} ${C.faint("โ†’ " + (e.assignedTo ?? "anyone"))}`);
803
1126
  }
804
1127
  if (e.type === "duplicate")
805
- print(` ${tag(e.agent)} ${C.faint(`= duplicate of โ€œ${oneLine(e.of, 70)}โ€ ยท not filed again`)}`);
1128
+ print(` ${tag(e.agent)} ${C.faint(`= duplicate of โ€œ${oneLine(e.of, space(" ", tag(e.agent), " = duplicate of โ€œโ€ ยท not filed again"))}โ€ ยท not filed again`)}`);
806
1129
  if (e.type === "checked") {
807
1130
  if (a)
808
- Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1 });
1131
+ Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1, doing: undefined });
809
1132
  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
1133
  }
811
1134
  if (e.type === "fixes")
812
1135
  print(` ${C.violet("โ—Ž")} ${C.faint(`QA round ${e.round} fixes: ${e.merged} merged, ${e.failed} failed`)}`);
813
1136
  if (e.type === "clean")
814
1137
  print(` ${C.green("โœ“")} QA round ${e.round} clean ${C.faint("ยท nothing found")}`);
1138
+ if (e.type === "suspect")
1139
+ print(C.yellow(` ? QA round ${e.round} clean, but quick`) + C.faint(` ยท ${e.reason}`));
815
1140
  if (e.type === "unchecked")
816
1141
  print(C.yellow(` ! QA round ${e.round}: the build was NOT checked`) + C.faint(` ยท ${e.reason}`));
817
1142
  if (e.type === "capped")
@@ -829,7 +1154,7 @@ function qaLine(e, tag, print, act, stats) {
829
1154
  * that's Huddle's choice, not the run's.
830
1155
  */
831
1156
  export function reportFacts(root, startHead, o) {
832
- const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split("\n").filter(Boolean);
1157
+ const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split(/\r?\n/).filter(Boolean);
833
1158
  const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root).out.trim() || "the current branch";
834
1159
  const facts = [
835
1160
  `${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).`,
@@ -869,13 +1194,22 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
869
1194
  if (r.ok)
870
1195
  summary = r.text;
871
1196
  }
872
- print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("ยท " + shortGoal(goal))}`);
1197
+ print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("ยท " + shortGoal(goal, space("WHAT YOU GOT ยท ")))}`);
1198
+ // Wrapped, not cut: this is the part people read. (Command lines keep their indent.)
1199
+ const w = lineWidth();
1200
+ const say = (l) => {
1201
+ if (!w || width(l) <= w - 2)
1202
+ return print(" " + l);
1203
+ const indent = (/^\s*(?:[-*โ€ข]\s+|\d+[.)]\s+)?/.exec(l)?.[0] ?? "").replace(/\S/g, " ");
1204
+ for (const part of wrap(l, w - 2, indent))
1205
+ print(" " + part);
1206
+ };
873
1207
  if (summary)
874
1208
  for (const l of summary.split("\n"))
875
- print(" " + l);
1209
+ say(l);
876
1210
  else
877
1211
  for (const n of notes)
878
- print(" " + oneLine(n, 160));
1212
+ say(n);
879
1213
  if (stat)
880
1214
  print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" ยท see it all: git log")));
881
1215
  }
@@ -886,7 +1220,7 @@ async function answer(print, question, results, signal) {
886
1220
  let brain = resolveBrain(results);
887
1221
  if (brain && !brain.readsRepo) {
888
1222
  const asker = pickAsker(workersFrom(results).map((w) => w.adapter));
889
- brain = asker ? brainFrom(asker.id === "claude" ? "claude:sonnet" : asker.id) : null;
1223
+ brain = asker ? brainFrom(asker.id === "claude" ? "claude:opus" : asker.id) : null;
890
1224
  }
891
1225
  if (!brain) {
892
1226
  print(C.red(" โœ— no agent ready to answer.") + " Type `agents`.");
@@ -962,7 +1296,10 @@ const HELP = `
962
1296
  ${C.bold("funnel")} ${C.faint("[on|off]")} public https invites through Tailscale Funnel
963
1297
  ${C.bold("ctrl-t")} ${C.faint("or")} ${C.bold("view")} the live view โ€” 1 agents ยท 2 plan ยท 3 board ยท q to come back
964
1298
  ${C.bold("board")} the task board and shared memory
1299
+ ${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
1300
+ ${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
1301
  ${C.bold("agents")} reconnect agents; install or sign in to missing ones
1302
+ ${C.bold("agents use|skip")} ${C.faint("<id>")} pick who works with you: agents skip cursor ยท agents use cursor
966
1303
  ${C.bold("cd <dir>")} move to another project ${C.faint("ยท")} ${C.bold("!<cmd>")} run a shell command
967
1304
  ${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
968
1305
  ${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
@@ -999,34 +1336,47 @@ export async function runApp() {
999
1336
  const queue = [];
1000
1337
  let stats = null;
1001
1338
  let ticker = null;
1002
- /** "โฑ 1m12s ยท โ— claude 40s 31k ยท โ— codex 22s 15k ยท โ—‹ cursor" */
1339
+ /** "โฑ 1m12s ยท 3 merged so far" โ€” the per-agent lines (statusLines) go under it. */
1003
1340
  const liveStats = () => {
1004
1341
  if (!stats)
1005
1342
  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(" ")}`;
1343
+ const merged = [...stats.agents.values()].reduce((n, a) => n + a.tasks, 0);
1344
+ return `${C.faint("โฑ")} ${fmtDuration(Date.now() - stats.startedAt)} ${C.faint(`ยท ${merged} merged so far`)}`;
1011
1345
  };
1012
- // Footer under the prompt, like Claude Code's mode line.
1346
+ // Footer under the prompt, like Claude Code's mode line; during a run, one more line per agent.
1013
1347
  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")}`;
1348
+ ? [
1349
+ ` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` ยท ${queue.length} queued`) : ""}`,
1350
+ ...(stats ? statusLines(stats, (out.columns || 80) - 1) : []),
1351
+ ]
1352
+ : [` ${MODE_INFO[state.mode].chip} ${C.faint("ยท " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
1353
+ let reserved = 1; // footer lines reserved under the prompt
1017
1354
  const drawFooter = () => {
1018
1355
  if (!promptShown || asking || !out.isTTY)
1019
1356
  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");
1357
+ // Never wrap: a wrapped footer would break the cursor save/restore. Never more lines than were reserved.
1358
+ let ls = footer();
1359
+ if (ls.length > reserved)
1360
+ ls = [...ls.slice(0, reserved - 1), C.faint(` โ€ฆ ${ls.length - reserved + 1} more ยท ctrl-t for the live view`)];
1361
+ const w = (out.columns || 80) - 1;
1362
+ out.write("\x1b7" + ls.map((l) => "\x1b[1B\r\x1b[2K" + truncate(l, w)).join("") + "\x1b[J\x1b8");
1022
1363
  };
1023
1364
  const showPrompt = () => {
1024
- out.write("\n\x1b[1A"); // reserve the footer line below
1365
+ // Reserve the footer lines below the prompt (up to a third of the screen).
1366
+ reserved = Math.max(1, Math.min(footer().length, Math.floor((out.rows || 24) / 3)));
1367
+ out.write("\n".repeat(reserved) + `\x1b[${reserved}A`);
1025
1368
  rl.setPrompt(prompt);
1026
1369
  rl.prompt(true);
1027
1370
  promptShown = true;
1028
1371
  drawFooter();
1029
1372
  };
1373
+ /** The footer grew (a run started, its roster is known): reserve the new lines. */
1374
+ const refitFooter = () => {
1375
+ if (!promptShown || asking || !out.isTTY || footer().length === reserved)
1376
+ return;
1377
+ out.write("\r\x1b[J");
1378
+ showPrompt();
1379
+ };
1030
1380
  // The live view (ctrl-t / `view`). While it's up, prompt output waits.
1031
1381
  const dash = new Dashboard({
1032
1382
  db: () => live?.db ?? null,
@@ -1101,6 +1451,7 @@ export async function runApp() {
1101
1451
  print(C.faint(" (type exit or press ctrl-d to leave)"));
1102
1452
  });
1103
1453
  rl.on("close", () => {
1454
+ bracketedPaste(false);
1104
1455
  out.write("\r\x1b[J");
1105
1456
  console.log(C.faint("bye ๐Ÿ‘‹"));
1106
1457
  process.exit(0); // the scheduler's exit hook stops any running agents
@@ -1159,6 +1510,11 @@ export async function runApp() {
1159
1510
  };
1160
1511
  const unsub = db.onEvent((e) => {
1161
1512
  const p = JSON.parse(e.payload || "{}");
1513
+ const se = screenEvent(e); // notes, reads and teammates' items on the shared screen
1514
+ if (se?.print)
1515
+ print(se.print);
1516
+ if (se?.dash)
1517
+ dash.push(se.dash);
1162
1518
  if (e.type === "member.joined")
1163
1519
  print(` ${C.green("โ—")} ${C.bold(p.name)} joined the room ${C.faint("ยท " + p.role)}`);
1164
1520
  if (e.type === "member.left")
@@ -1219,7 +1575,7 @@ export async function runApp() {
1219
1575
  const add = spawnSync("gh", ["api", "-X", "PUT", `repos/${repo}/collaborators/${handle}`, "-f", "permission=push"], {
1220
1576
  encoding: "utf8",
1221
1577
  });
1222
- print(add.status === 0 ? ` ${C.green("โœ“")} GitHub invite sent to @${handle}` : C.red(` โœ— ${add.stderr.trim().split("\n")[0]}`));
1578
+ print(add.status === 0 ? ` ${C.green("โœ“")} GitHub invite sent to @${handle}` : C.red(` โœ— ${add.stderr.trim().split(/\r?\n/)[0]}`));
1223
1579
  }
1224
1580
  }
1225
1581
  else if (collab)
@@ -1339,9 +1695,58 @@ export async function runApp() {
1339
1695
  promptShown = false;
1340
1696
  out.write("\r\x1b[J");
1341
1697
  rl.pause();
1342
- spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
1698
+ bracketedPaste(false);
1699
+ runShell(cmd);
1700
+ bracketedPaste(true);
1343
1701
  rl.resume();
1344
1702
  };
1703
+ // ---- the shared screen: pastes, dropped files, `add`, `ctx` (src/ui/paste.ts) ----
1704
+ const screenUI = {
1705
+ print,
1706
+ // A question mid-line would swallow what's typed: park it, ask, put it back.
1707
+ yes: async (q, def) => {
1708
+ const keep = rl.line;
1709
+ if (keep)
1710
+ rl.write(null, { ctrl: true, name: "u" });
1711
+ try {
1712
+ return await yes(rl, q, def);
1713
+ }
1714
+ finally {
1715
+ if (keep)
1716
+ rl.write(keep);
1717
+ }
1718
+ },
1719
+ db: async () => (await openRoom())?.db ?? null,
1720
+ audience: () => {
1721
+ const ready = results.filter((r) => r.state === "ready");
1722
+ return { agents: ready.map((r) => r.adapter.id), companies: new Set(ready.map((r) => r.adapter.vendor)).size };
1723
+ },
1724
+ brain: () => resolveBrain(results),
1725
+ root: repoRoot,
1726
+ interactive: true,
1727
+ };
1728
+ if (out.isTTY && process.stdin.isTTY) {
1729
+ bracketedPaste(true);
1730
+ process.once("exit", () => bracketedPaste(false));
1731
+ interceptStdin(process.stdin, (pass) => new PasteCatcher({
1732
+ pass,
1733
+ enabled: () => !asking && !dash.isOpen(),
1734
+ onPaste: (text) => {
1735
+ const drop = asDrop(text);
1736
+ // Short pastes stay on the line, as one line.
1737
+ if (!drop && !isLongPaste(text))
1738
+ return pass(flatten(text));
1739
+ void (async () => {
1740
+ if (drop)
1741
+ await addPaths(drop, screenUI);
1742
+ else
1743
+ await pasteToScreen(text, screenUI);
1744
+ if (!promptShown)
1745
+ showPrompt();
1746
+ })().catch((e) => print(C.red(" error: ") + (e?.message ?? e)));
1747
+ },
1748
+ }));
1749
+ }
1345
1750
  const startGoal = async (typed) => {
1346
1751
  // "--qa" / "--no-qa" anywhere in a typed goal, like on `huddle join`.
1347
1752
  const qa = /(^|\s)--no-qa(\s|$)/.test(typed) ? false : /(^|\s)--qa(\s|$)/.test(typed) ? true : undefined;
@@ -1359,7 +1764,10 @@ export async function runApp() {
1359
1764
  rl,
1360
1765
  signal: run.signal,
1361
1766
  print,
1362
- onStats: (s) => (stats = s),
1767
+ onStats: (s) => {
1768
+ stats = s;
1769
+ refitFooter();
1770
+ },
1363
1771
  room: r ?? undefined,
1364
1772
  onActivity: (a) => dash.push(a),
1365
1773
  qa,
@@ -1397,7 +1805,14 @@ export async function runApp() {
1397
1805
  try {
1398
1806
  if (cmd === "exit" || cmd === "quit")
1399
1807
  return rl.close();
1400
- if (cmd === "help" || cmd === "?")
1808
+ const drop = asDrop(line); // a file dragged in by a terminal that doesn't mark pastes
1809
+ if (drop)
1810
+ await addPaths(drop, screenUI);
1811
+ else if (cmd === "add" && looksLikePaths(line.slice(3)))
1812
+ await addPaths(splitPathTokens(line.slice(3)), screenUI);
1813
+ else if (cmd === "ctx" || cmd === "context" || cmd === "screen")
1814
+ await ctxCommand(line.slice(cmd.length), screenUI);
1815
+ else if (cmd === "help" || cmd === "?")
1401
1816
  print(HELP);
1402
1817
  else if (cmd === "clear" || cmd === "cls") {
1403
1818
  out.write("\x1b[2J\x1b[3J\x1b[H");
@@ -1467,8 +1882,14 @@ export async function runApp() {
1467
1882
  print(C.faint(" nothing running"));
1468
1883
  }
1469
1884
  else if (cmd === "agents") {
1885
+ const [verb, ...ids] = arg.split(/[\s,]+/).filter(Boolean);
1470
1886
  if (run)
1471
1887
  print(C.faint(" finish or stop the current run first"));
1888
+ else if ((verb === "use" || verb === "skip") && ids.length) {
1889
+ setAgentChoice(ids, verb === "use");
1890
+ print(C.faint(` ${verb === "use" ? "using" : "skipping"} ${ids.join(", ")} from now on`));
1891
+ results = await connectAgents();
1892
+ }
1472
1893
  else {
1473
1894
  results = await connectAgents();
1474
1895
  if (await offerSetup(rl, results, true))