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
@@ -4,14 +4,21 @@
4
4
  // (dependencies must be merged first). When its process exits, the merge engine
5
5
  // runs the test gate; in manual mode you approve the merge; landing it can
6
6
  // unblock more tasks, so we fill again. Nobody idles while there's work.
7
+ import { userBrief } from "../room/user.js";
7
8
  import { launchAgent, stopChild } from "./launcher.js";
8
- import { prepareMerge, landMerge, removeWorktree, branchDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
9
+ import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
9
10
  import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
10
11
  import { UsageMeter } from "../adapters/usage.js";
11
12
  import { askAgent } from "./ask.js";
12
13
  import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
13
- import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv } from "./takeover.js";
14
- import { helpOffer, alongsideBrief } from "../room/progress.js";
14
+ import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv, pairWaitMsFromEnv, buddyAfterMsFromEnv, isSpecialTask, buddyPrompt, parseBuddy, pairPrompt, parsePart, testsPart, pairNote, BUDDY_AT, pairEligible, } from "./takeover.js";
15
+ import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/progress.js";
16
+ import { contractBrief, contractNames, getContract } from "../room/contract.js";
17
+ import { notifyAgent } from "../room/mail.js";
18
+ import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
19
+ import { onFinished, processTable } from "../platform.js";
20
+ import { BusyWatch, silenceMaxMsFromEnv } from "./busy.js";
21
+ import { startRadar } from "./radar.js";
15
22
  const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
16
23
  /** No help offers on a task whose reported progress is this far along. */
17
24
  export const OFFER_MAX_PCT = 60;
@@ -46,18 +53,20 @@ HOW:
46
53
  resolution and re-runs the tests before anything lands.
47
54
  4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
48
55
  }
49
- function reviewPrompt(title, spec, diff) {
56
+ function reviewPrompt(title, spec, diff, contract = "") {
50
57
  return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
51
58
 
52
59
  TASK: ${title}
53
60
  ${spec}
54
-
61
+ ${contract ? `\n${cap(contract, 2500)}\n` : ""}
55
62
  DIFF (against main):
56
63
  ${diff || "(empty diff)"}
57
64
 
58
65
  You have NO tools — you can't run commands or open files, and you don't need to: the task and the full diff are above.
59
- Check the diff against the task: bugs, broken contracts, missing requirements, behavior that would fail.
66
+ Check, in order: (1) names and shapes match the ${contract ? "contract and the " : ""}task (functions, fields, routes, messages);
67
+ (2) each acceptance point in the task is actually done; (3) error paths (bad input, missing data) behave; (4) the tests actually exercise this change.
60
68
  Reply with the first line exactly APPROVE or CHANGES.
69
+ If APPROVE, add one line "Verified: <what you checked and found working>".
61
70
  If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
62
71
  Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
63
72
  }
@@ -65,14 +74,19 @@ Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you
65
74
  * The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
66
75
  * honestly, and it never blocks a merge (a reviewer can't block by accident).
67
76
  */
68
- function parseVerdict(text) {
77
+ export function parseVerdict(text) {
69
78
  const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
70
- const i = lines.findIndex((l) => /^(APPROVE|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
79
+ // "APPROVED" is an approve too (it used to read as no verdict at all).
80
+ const i = lines.findIndex((l) => /^(APPROVED?|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
71
81
  if (i < 0)
72
82
  return { approve: true, skipped: true, notes: "no verdict" };
73
83
  if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
74
84
  return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
75
- return { approve: true, notes: lines.slice(i + 1, i + 3).join(" ") };
85
+ // Found live: ~9s bare APPROVEs. An approve that says nothing about what was verified is shown as shallow.
86
+ const said = [lines[i].replace(/[*`#_]/g, "").replace(/^\s*APPROVE[D]?\b[\s:.,!—-]*/i, ""), ...lines.slice(i + 1, i + 3)].join(" ").trim();
87
+ // "Verified: ok" / "LGTM" say nothing either: what's left after the label has to name something.
88
+ const what = said.replace(/^(verified|checked)\s*:?\s*/i, "").replace(/^(lgtm|looks good|ok|fine)\b[\s.!,]*/i, "");
89
+ return what.length >= 10 ? { approve: true, notes: said } : { approve: true, shallow: true, notes: "approve (shallow)" };
76
90
  }
77
91
  export async function runBoard(opts) {
78
92
  const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
@@ -90,6 +104,16 @@ export async function runBoard(opts) {
90
104
  }
91
105
  opts.onEvent(e);
92
106
  };
107
+ // The shared contract, when the Council wrote one ("" when there is none).
108
+ const contractOf = opts.contractBrief ?? contractBrief;
109
+ const contract = () => {
110
+ try {
111
+ return contractOf(db) || "";
112
+ }
113
+ catch {
114
+ return "";
115
+ }
116
+ };
93
117
  const running = new Map(); // agent id -> handle
94
118
  let merged = 0;
95
119
  let failed = 0;
@@ -112,11 +136,17 @@ export async function runBoard(opts) {
112
136
  return new Promise((resolve) => {
113
137
  let resolved = false;
114
138
  let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
139
+ let stopRadar = () => { }; // the integration radar (set up below)
140
+ // Buddy checks and pairing asks end with the run (or with you): no ask outlives it.
141
+ const sideAsks = new AbortController();
142
+ signal?.addEventListener("abort", () => sideAsks.abort());
115
143
  const done = () => {
116
144
  if (resolved)
117
145
  return;
118
146
  resolved = true;
147
+ sideAsks.abort();
119
148
  stopWatch();
149
+ stopRadar();
120
150
  process.off("exit", killAll);
121
151
  resolve({ merged, failed, stopped: !!signal?.aborted });
122
152
  };
@@ -127,6 +157,10 @@ export async function runBoard(opts) {
127
157
  signal?.addEventListener("abort", killAll);
128
158
  // If Huddle itself exits, never leave orphaned agents working for nobody.
129
159
  process.once("exit", killAll);
160
+ // The token budget: past its soft line nothing new starts (tasks, takeovers, buddy checks, pairings);
161
+ // what's running finishes. Its hard line aborts the run's signal (the caller wires that, as Ctrl-C does).
162
+ const overBudget = () => !!opts.budget?.stopping;
163
+ const stoppedWhy = () => (opts.budget?.level === "hard" ? `stopped: the token budget ran over (${Math.round(opts.budget.used * 100)}%)` : "stopped by you");
130
164
  // Agents held out of fill(): an author waiting on review, a reviewer reviewing.
131
165
  const reserved = new Set();
132
166
  const reviewed = new Set(); // task ids that already had their one review round
@@ -233,27 +267,22 @@ export async function runBoard(opts) {
233
267
  agentId: r.id,
234
268
  role: "worker",
235
269
  taskId: task.id,
236
- taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}`,
270
+ // The contract decides a name clash: both sides code to it.
271
+ taskSpec: `Resolve merge conflict: ${task.title}\n\n${resolvePrompt({ title: task.title, spec: task.spec, brief, others, retry: st.last })}${contractBrief(db) ? `\n\n${contractBrief(db)}` : ""}`,
237
272
  roomUrl,
238
273
  roomSecret: opts.roomSecret,
239
274
  approvals: opts.approvals,
240
275
  worktree: wt,
241
276
  onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
242
277
  });
278
+ opts.budget?.track(h2.meter, r.id);
243
279
  db.setTaskStatus(task.id, "in_progress");
244
280
  db.setAgentStatus(r.id, "working", wt.path);
245
281
  running.set(r.id, h2);
246
282
  heard(r.id, h2);
247
283
  onEvent({ type: "conflict", agent: r.id, author: c.author.id, task: task.id, title: task.title, files: cs.files, attempt: st.n, log: h2.logPath });
248
- let exited = false;
249
- const once = (code) => {
250
- if (exited)
251
- return;
252
- exited = true;
253
- void endResolve(r, c, h2, cs, code);
254
- };
255
- h2.child.on("exit", once);
256
- h2.child.on("error", () => once(-1));
284
+ // On 'close' (capped): its last stdout lines (token usage) can still be in the pipe at 'exit'.
285
+ onFinished(h2.child, (code) => void endResolve(r, c, h2, cs, code));
257
286
  };
258
287
  const endResolve = async (r, c, h2, cs, code) => {
259
288
  const ms = Date.now() - h2.startedAt;
@@ -266,7 +295,7 @@ export async function runBoard(opts) {
266
295
  // Both agents' Plug wiring may sit in this worktree; neither may be committed.
267
296
  const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
268
297
  const res = signal?.aborted
269
- ? { ok: false, reason: "stopped by you" }
298
+ ? { ok: false, reason: stoppedWhy() }
270
299
  : code === 0
271
300
  ? finishConflictMerge(wt, cs, h.excludes)
272
301
  : { ok: false, reason: `resolver exited with code ${code}` };
@@ -293,10 +322,17 @@ export async function runBoard(opts) {
293
322
  const taskWt = new Map(); // task id -> its worktree, whoever works it
294
323
  const lastSeen = new Map(); // agent id -> its last output or huddle_* call
295
324
  const silenced = new Map(); // task id -> why we stopped its agent
325
+ // Quiet isn't stuck: a long test run prints nothing (busy.ts). Busy restarts the silence clock, up to a cap.
326
+ const silenceMaxMs = Math.max(silenceMs, opts.silenceMaxMs ?? silenceMaxMsFromEnv(silenceMs));
327
+ const busyWatch = new BusyWatch();
328
+ const lastBusy = new Map(); // agent id -> the last look that found it working
329
+ const busyTold = new Set(); // launches whose "busy, not silent" was reported
296
330
  /** Start listening for signs of life from a freshly launched agent. */
297
331
  const heard = (id, h) => {
298
332
  const bump = () => lastSeen.set(id, Date.now());
299
333
  bump();
334
+ lastBusy.delete(id);
335
+ busyWatch.forget(id);
300
336
  h.child?.stdout?.on("data", bump);
301
337
  h.child?.stderr?.on("data", bump);
302
338
  };
@@ -370,28 +406,383 @@ export async function runBoard(opts) {
370
406
  });
371
407
  start(r, task, brief, rc.from.id);
372
408
  };
373
- /** Stop agents that have gone quiet; they're taken over in finish(). */
409
+ /**
410
+ * Stop agents that have gone quiet; they're taken over in finish(). Quiet =
411
+ * no output and no huddle_* call. Past half the window Huddle starts looking
412
+ * at what the agent is doing (busy.ts); a look that finds it working counts
413
+ * as a sign of life, so only an agent quiet AND idle for the whole window is
414
+ * silent — or one quiet past the cap, working or not.
415
+ */
416
+ const span = (ms) => (ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.round(ms / 1000)}s`);
374
417
  const watchSilence = () => {
418
+ let table; // one process snapshot per tick, only when someone is quiet
419
+ const now = Date.now();
375
420
  for (const [id, h] of running) {
376
421
  if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
377
422
  continue;
378
- const quiet = Date.now() - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
379
- if (quiet < silenceMs)
423
+ const quiet = now - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
424
+ if (quiet < silenceMs / 2) {
425
+ busyWatch.forget(id); // it spoke: the next quiet spell compares from a fresh look
426
+ continue;
427
+ }
428
+ let firstLook = false;
429
+ if (quiet < silenceMaxMs && h.child.pid) {
430
+ if (table === undefined) {
431
+ try {
432
+ table = (opts.processTable ?? processTable)();
433
+ }
434
+ catch {
435
+ table = null;
436
+ }
437
+ }
438
+ const v = busyWatch.look(id, { pid: h.child.pid, worktree: h.worktree, logPath: h.logPath }, table, now);
439
+ firstLook = v === null; // nothing to compare with yet: decide on the next tick
440
+ if (v?.busy) {
441
+ lastBusy.set(id, now);
442
+ if (quiet >= silenceMs && !busyTold.has(h)) {
443
+ busyTold.add(h);
444
+ const t = db.getTask(h.taskId);
445
+ onEvent({ type: "busy", agent: id, task: h.taskId, title: t?.title ?? h.taskId, quietMs: quiet, why: v.why });
446
+ }
447
+ }
448
+ }
449
+ const idle = now - Math.max(lastSeen.get(id) ?? 0, lastBusy.get(id) ?? 0, h.startedAt);
450
+ if (quiet >= silenceMaxMs)
451
+ silenced.set(h.taskId, busyTold.has(h)
452
+ ? `went silent — no output and no huddle_* call for ${span(quiet)}; it looked busy (a long command?), but that is past the ${span(silenceMaxMs)} cap`
453
+ : `went silent — no output and no huddle_* call for ${span(quiet)}`);
454
+ else if (idle >= silenceMs && !firstLook)
455
+ silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span(quiet)}, and nothing running (no child using CPU, no files changing)`);
456
+ else
457
+ continue;
458
+ busyWatch.forget(id);
459
+ stopChild(h.child);
460
+ }
461
+ };
462
+ // --- teamwork mid-task --------------------------------------------------
463
+ // Found live (3 capstone runs): every help offer was declined, no split
464
+ // ever happened, and reviews came only after huddle_done. So Huddle acts:
465
+ // buddy checks put a second agent's eyes on work in progress, and pairing
466
+ // hands a part of the slowest task to a free agent unless its author says no.
467
+ const buddyOn = opts.buddyChecks ?? !/^(off|no|0|false)$/i.test(process.env.HUDDLE_BUDDY ?? "");
468
+ const pairWaitMs = opts.pairWaitMs ?? pairWaitMsFromEnv();
469
+ const buddyAfterMs = opts.buddyAfterMs !== undefined ? opts.buddyAfterMs : buddyAfterMsFromEnv();
470
+ const buddyBy = new Map(); // agent id -> buddy checks it gave
471
+ const buddyDone = new Map(); // task id -> its checks so far (and the first one's pct)
472
+ const buddyBusy = new Set(); // task ids with a check in flight
473
+ const buddyRuns = new Map(); // task id -> its check in flight, for finish() to wait on
474
+ const buddyWaitMs = opts.buddyWaitMs ?? 60_000;
475
+ const followedUp = new Set(); // task ids that had their one round for notes that came too late
476
+ const paired = new Set(); // task ids that had their one pairing (or attempt)
477
+ const pairing = new Set(); // helper ids mid-pairing (asking, or waiting for a no)
478
+ const holdFor = new Map(); // task id -> the partner task that must land first
479
+ const testsOf = new Set(); // tests-part task ids (they test their partner's code)
480
+ const held = []; // finished, waiting for their partner to land
481
+ const settled = (id) => {
482
+ const st = db.getTask(id)?.status;
483
+ return !st || st === "merged" || st === "failed" || st === "needs_you";
484
+ };
485
+ /** The other tasks in flight, with their work so far (capped): where a buddy spots XYZ vs ABC. */
486
+ const othersInFlight = (taskId) => [...running.values()]
487
+ .filter((x) => x.taskId !== taskId && x.child)
488
+ .slice(0, 3)
489
+ .map((x) => {
490
+ const t = db.getTask(x.taskId);
491
+ const wip = wipDiff(repoRoot, x.worktree, x.excludes, 2500);
492
+ return `- ${x.agentId}: “${t?.title ?? x.taskId}” (${x.taskId}) · files so far: ${wip.files.slice(0, 10).join(", ") || "(none yet)"}${wip.diff ? `\n${wip.diff}` : ""}`;
493
+ })
494
+ .join("\n");
495
+ /** Never the author; a free agent first, then the one that gave the fewest buddy checks. */
496
+ const pickBuddy = (authorId) => workers
497
+ .filter((x) => x.id !== authorId && x.adapter?.ask)
498
+ .map((w, i) => ({ w, i, busy: running.has(w.id) || reserved.has(w.id) ? 1 : 0 }))
499
+ .sort((a, b) => a.busy - b.busy || (buddyBy.get(a.w.id) ?? 0) - (buddyBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
500
+ /** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
501
+ const buddyCheck = async (taskId, pct, silentMs = 0) => {
502
+ if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId) || overBudget())
503
+ return;
504
+ const t = db.getTask(taskId);
505
+ if (!t?.claimed_by || isSpecialTask(t))
506
+ return;
507
+ const author = t.claimed_by;
508
+ const h = running.get(author);
509
+ if (!h || h.taskId !== taskId || !h.child)
510
+ return; // local, and mid-task
511
+ const st = buddyDone.get(taskId) ?? { n: 0, notes: false, at: 0 };
512
+ // The second look is a follow-up on the first one's notes; a first look at 80% already was the late one.
513
+ // silentMs: the author never reported progress (found live: Claude called
514
+ // huddle_progress twice in 4 tasks), so the first look goes by time instead.
515
+ const due = st.n === 0 ? pct >= BUDDY_AT[0] || silentMs > 0 : st.n === 1 && st.notes && st.at < BUDDY_AT[1] && pct >= BUDDY_AT[1];
516
+ if (!due)
517
+ return;
518
+ const buddy = pickBuddy(author);
519
+ if (!buddy)
520
+ return;
521
+ // Nothing written yet: nothing to look at, and not worth a teammate's tokens. The next report tries again.
522
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 16_000);
523
+ if (!wip.diff)
524
+ return;
525
+ st.n++;
526
+ if (st.n === 1)
527
+ st.at = pct;
528
+ buddyDone.set(taskId, st);
529
+ buddyBusy.add(taskId);
530
+ buddyBy.set(buddy.id, (buddyBy.get(buddy.id) ?? 0) + 1);
531
+ const t0 = Date.now();
532
+ let landed = () => { };
533
+ buddyRuns.set(taskId, { landed: new Promise((r) => (landed = r)), at: t0 });
534
+ try {
535
+ // The Room's huddle_done waits on this pair (server.ts), so the notes reach the author before it exits.
536
+ db.publish("buddy.start", { task_id: taskId, agent_id: buddy.id, payload: { author, pct } });
537
+ const when = silentMs ? `${Math.round(silentMs / 60_000) || 1} min in` : `${pct}%`;
538
+ const prompt = buddyPrompt({ author, when, title: t.title, spec: t.spec, contract: contract(), others: othersInFlight(taskId), diff: wip.diff });
539
+ // Its own signal, tied to the run's: askAgent never unhooks, and a long run would pile listeners on one signal.
540
+ const stop = new AbortController();
541
+ const off = () => stop.abort();
542
+ sideAsks.signal.addEventListener("abort", off);
543
+ const res = await askAgent(buddy.adapter, prompt, repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => sideAsks.signal.removeEventListener("abort", off));
544
+ if (resolved || !res.ok)
545
+ return; // no answer: the author never knew it was asked
546
+ const notes = parseBuddy(res.text);
547
+ st.notes = !!notes;
548
+ // Its author is past it (finish() waited buddyWaitMs; it's merging or landed): the notes would only reach the
549
+ // author's NEXT task, where they mean nothing. Shared memory keeps them for QA and later tasks.
550
+ const st2 = db.getTask(taskId)?.status;
551
+ const late = !!notes && !!st2 && !["claimed", "in_progress"].includes(st2);
552
+ if (notes && late)
553
+ db.remember({ kind: "gotcha", body: `Buddy note on "${t.title}" from ${buddy.id} (came back after it landed; nobody acted on it): ${notes}`.slice(0, 1500), author: buddy.id });
554
+ else if (notes) {
555
+ const m = notifyAgent(db, author, `buddy check from ${buddy.id} ${silentMs ? when : `at ${when}`}:\n${notes}`, "huddle", taskId);
556
+ trackNote(db, m, { by: buddy.id, kind: "buddy", worktree: h.worktree });
557
+ }
558
+ onEvent({ type: "checkpoint", agent: buddy.id, author, task: taskId, title: t.title, pct, notes: notes ?? "LOOKS GOOD", ms: Date.now() - t0, ...(late ? { late } : {}) });
559
+ }
560
+ catch {
561
+ /* a buddy check never breaks a run */
562
+ }
563
+ finally {
564
+ buddyBusy.delete(taskId);
565
+ buddyRuns.delete(taskId);
566
+ try {
567
+ db.publish("buddy.end", { task_id: taskId, agent_id: buddy.id });
568
+ }
569
+ catch {
570
+ /* closed db */
571
+ }
572
+ landed();
573
+ }
574
+ };
575
+ /** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
576
+ const buddyBySilence = () => {
577
+ if (buddyAfterMs === null || !buddyOn || overBudget())
578
+ return;
579
+ const now = Date.now();
580
+ for (const h of running.values()) {
581
+ if (!h.child || buddyDone.has(h.taskId) || buddyBusy.has(h.taskId) || now - h.startedAt < buddyAfterMs)
582
+ continue;
583
+ // Reached 40%: that report triggers it. One early "10%" and then quiet still counts as quiet.
584
+ const pct = db.getTask(h.taskId)?.progress_pct ?? 0;
585
+ if (pct >= BUDDY_AT[0])
380
586
  continue;
381
- const span = quiet >= 60_000 ? `${Math.round(quiet / 60_000)} min` : `${Math.round(quiet / 1000)}s`;
382
- silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
383
- h.child.kill("SIGTERM");
384
- const child = h.child;
385
- setTimeout(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"), 5000).unref();
587
+ void buddyCheck(h.taskId, pct, now - h.startedAt).catch(() => { });
588
+ }
589
+ };
590
+ /** Resolves with the author's "no: …" reason, or null when the window closes (or it says yes, or stops mattering). */
591
+ const waitForNo = (author, helper, since, ms, still) => new Promise((resolve) => {
592
+ let over = false;
593
+ const end = (v) => {
594
+ if (over)
595
+ return;
596
+ over = true;
597
+ clearTimeout(timer);
598
+ clearInterval(poll);
599
+ unsub();
600
+ resolve(v);
601
+ };
602
+ const look = () => {
603
+ if (over)
604
+ return;
605
+ for (const m of db.inbox("huddle", { peek: true })) {
606
+ if (m.from_agent !== author || m.created_at < since)
607
+ continue;
608
+ db.markRead(m.id);
609
+ const body = m.body.replace(/[*`_]/g, "").trim();
610
+ if (/^(yes|y|ok|okay|go|sure|fine|sounds good|go ahead)\b/i.test(body))
611
+ return end(null);
612
+ // Anything else the author sends Huddle in the window is an objection: it was asked for a no, not a chat.
613
+ return end(body.replace(/^(no|nope|nah|don't|do not)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
614
+ }
615
+ // Models mix up recipients: a "no" sent to the helper itself counts too (left unread for the helper).
616
+ for (const m of db.inbox(helper, { peek: true })) {
617
+ if (m.from_agent !== author || m.created_at < since)
618
+ continue;
619
+ const body = m.body.replace(/[*`_]/g, "").trim();
620
+ if (/^(no|nope|nah)\b/i.test(body))
621
+ return end(body.replace(/^(no|nope|nah)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
622
+ }
623
+ };
624
+ const unsub = db.onEvent((e) => {
625
+ if (e.type !== "agent.message")
626
+ return;
627
+ try {
628
+ look();
629
+ }
630
+ catch {
631
+ /* a read that fails is just no reply yet */
632
+ }
633
+ });
634
+ const timer = setTimeout(() => end(null), ms);
635
+ const poll = setInterval(() => {
636
+ try {
637
+ if (!still() || resolved || signal?.aborted)
638
+ end(null);
639
+ }
640
+ catch {
641
+ end(null);
642
+ }
643
+ }, 500);
644
+ timer.unref?.();
645
+ poll.unref?.();
646
+ look();
647
+ });
648
+ /**
649
+ * Pairing: a free agent proposes one separable part of the task furthest
650
+ * behind (or, if nothing is cleanly separable, its tests); the author is
651
+ * told and has pairWaitMs to say no; then the part goes on the board for
652
+ * the helper. Merge order makes landing safe: a code part lands before the
653
+ * parent (the parent is written to use it, so its tests need it on main);
654
+ * a tests part lands after the parent (its tests run against the parent's
655
+ * code at the gate, so they verify something). Either way they're
656
+ * disjoint files, so neither order conflicts; it's about green gates.
657
+ */
658
+ const pairUp = async (helper, author, h, t) => {
659
+ paired.add(t.id);
660
+ pairing.add(helper.id);
661
+ // Found live (round 4): 0 pairs, and nothing said whether one was ever tried. A dropped pairing says why.
662
+ const dropped = (reason) => {
663
+ try {
664
+ db.publish("pair.dropped", { task_id: t.id, agent_id: helper.id, payload: { with: author, title: t.title, reason } });
665
+ }
666
+ catch {
667
+ /* closed db */
668
+ }
669
+ };
670
+ try {
671
+ const still = () => running.get(author)?.taskId === t.id && (db.getTask(t.id)?.progress_pct ?? 0) < OFFER_MAX_PCT && !resolved && !signal?.aborted;
672
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 12_000);
673
+ const files = JSON.parse(t.files ?? "[]");
674
+ let part = null;
675
+ if (helper.adapter?.ask) {
676
+ // The run waits for a pairing, so its ask ends as soon as the pairing stops mattering (the author finished, say).
677
+ const stop = new AbortController();
678
+ const off = () => stop.abort();
679
+ sideAsks.signal.addEventListener("abort", off);
680
+ const watch = setInterval(() => {
681
+ try {
682
+ if (!still())
683
+ stop.abort();
684
+ }
685
+ catch {
686
+ stop.abort();
687
+ }
688
+ }, 500);
689
+ watch.unref?.();
690
+ const res = await askAgent(helper.adapter, pairPrompt({ helper: helper.id, author, title: t.title, spec: t.spec, files, contract: contract(), diff: wip.diff }), repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => {
691
+ clearInterval(watch);
692
+ sideAsks.signal.removeEventListener("abort", off);
693
+ });
694
+ if (res.ok && !stop.signal.aborted)
695
+ part = parsePart(res.text, wip.files, files);
696
+ }
697
+ part ??= testsPart(t.title, t.spec);
698
+ if (!still())
699
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished while ${helper.id} was proposing a part`);
700
+ const note = notifyAgent(db, author, pairNote(helper.id, part, pairWaitMs), "huddle", t.id);
701
+ const no = await waitForNo(author, helper.id, note.created_at, pairWaitMs, still);
702
+ if (no !== null) {
703
+ db.remember({ kind: "fact", body: `${author} kept "${t.title}" whole (declined ${helper.id} taking “${part.title}”): ${no}`.slice(0, 600), author: "huddle" });
704
+ return dropped(`${author} said no: ${no}`.slice(0, 300));
705
+ }
706
+ const parent = db.getTask(t.id);
707
+ if (!parent || !still() || !["claimed", "in_progress"].includes(parent.status))
708
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished during the no-window`);
709
+ const pct = parent.progress_pct;
710
+ const secs = Math.round((Date.now() - h.startedAt) / 1000);
711
+ const spec = `${part.spec}\n\n${author} keeps the rest of “${t.title}” and was told not to edit ${part.files.length ? part.files.join(", ") : "your test file"}. ` +
712
+ `Match their names exactly; unsure? huddle_ask(…, to="${author}").` +
713
+ (wip.diff ? `\n\nTHEIR WORK SO FAR (when you were paired):\n${cap(wip.diff, 6000)}` : "");
714
+ const prefers = [`@${helper.id}`, ...JSON.parse(parent.prefers)];
715
+ const { task: pt } = addSplitPart(db, parent, author, { title: part.title, spec, files: part.files, prefers });
716
+ if (!pt)
717
+ return;
718
+ if (part.tests) {
719
+ holdFor.set(pt.id, parent.id);
720
+ testsOf.add(pt.id);
721
+ }
722
+ else
723
+ holdFor.set(parent.id, pt.id);
724
+ onEvent({
725
+ type: "pair",
726
+ agent: helper.id,
727
+ with: author,
728
+ task: parent.id,
729
+ title: parent.title,
730
+ part: part.tests ? `tests: ${pt.title}` : `${pt.title} (${part.files.join(", ")})`,
731
+ reason: `${parent.title} at ${pct}% after ${secs}s; ${helper.id} was free`,
732
+ });
733
+ }
734
+ catch {
735
+ /* pairing never breaks a run */
736
+ }
737
+ finally {
738
+ pairing.delete(helper.id);
739
+ if (!resolved)
740
+ fill(); // the part (if any) goes to the helper now
386
741
  }
387
742
  };
388
- /** A free agent with nothing to do offers to take part of each long-running task, once. */
743
+ /**
744
+ * Every tick: a free agent with nothing to do pairs up with the task
745
+ * furthest behind. A helper that can't be asked (no read-only mode) only
746
+ * sends the old help offer, which leaves the split to the busy agent.
747
+ */
389
748
  const offerHelp = () => {
390
- if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
749
+ if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length || overBudget())
391
750
  return;
392
- const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
751
+ const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
393
752
  if (!helper)
394
753
  return;
754
+ if (helper.adapter.ask) {
755
+ const now = Date.now();
756
+ const left = (t, h) => ((now - h.startedAt) * (100 - t.progress_pct)) / Math.max(t.progress_pct, 5);
757
+ // A task that never called huddle_progress isn't at 0%: it has no number (see pairEligible).
758
+ const reported = new Set(taskViews(db, now).filter((v) => v.reported).map((v) => v.id));
759
+ const best = [...running]
760
+ .map(([id, h]) => ({ id, h, t: db.getTask(h.taskId) }))
761
+ .filter((c) => !!c.t &&
762
+ !!c.h.child &&
763
+ c.id !== helper.id &&
764
+ c.t.claimed_by === c.id &&
765
+ ["claimed", "in_progress"].includes(c.t.status) &&
766
+ !paired.has(c.t.id) &&
767
+ !resolving.has(c.t.id) &&
768
+ !testFixed.has(c.t.id) &&
769
+ !reviewed.has(c.t.id) &&
770
+ !splitFrom.has(c.t.id) && // a part isn't split again
771
+ !isSpecialTask(c.t) &&
772
+ now - c.h.startedAt >= helpAfterMs &&
773
+ pairEligible({
774
+ reported: reported.has(c.t.id),
775
+ pct: c.t.progress_pct,
776
+ ranMs: now - c.h.startedAt,
777
+ helpAfterMs,
778
+ planned: JSON.parse(c.t.files ?? "[]").length,
779
+ touched: () => wipDiff(repoRoot, c.h.worktree, c.h.excludes, 0).files.length,
780
+ }))
781
+ .sort((a, b) => left(b.t, b.h) - left(a.t, a.h))[0];
782
+ if (best)
783
+ void pairUp(helper, best.id, best.h, best.t);
784
+ return;
785
+ }
395
786
  for (const [id, h] of running) {
396
787
  if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
397
788
  continue;
@@ -404,7 +795,7 @@ export async function runBoard(opts) {
404
795
  if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
405
796
  continue;
406
797
  offered.add(t.id);
407
- db.sendMessage({ from: helper.id, to: id, kind: "note", task_id: t.id, body: helpOffer(helper.id, t.id, t.title) });
798
+ notifyAgent(db, id, helpOffer(helper.id, t.id, t.title), helper.id, t.id);
408
799
  onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
409
800
  }
410
801
  };
@@ -418,6 +809,16 @@ export async function runBoard(opts) {
418
809
  const by = db.getTask(e.task_id)?.claimed_by;
419
810
  if (by && running.has(by))
420
811
  lastSeen.set(by, Date.now());
812
+ let pct = 0;
813
+ try {
814
+ pct = Number(JSON.parse(e.payload || "{}").pct) || 0;
815
+ }
816
+ catch {
817
+ /* keep 0 */
818
+ }
819
+ const id = e.task_id;
820
+ if (pct >= BUDDY_AT[0])
821
+ setImmediate(() => void buddyCheck(id, pct).catch(() => { })); // never an unhandled rejection
421
822
  }
422
823
  else if (e.type === "task.split" && e.task_id) {
423
824
  let p = {};
@@ -430,17 +831,69 @@ export async function runBoard(opts) {
430
831
  splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
431
832
  setImmediate(() => !resolved && fill()); // a free agent may take it right away
432
833
  }
834
+ else if (e.type === "note.closed" && e.task_id) {
835
+ // A note that needed an answer, settled at huddle_done (server.ts) or as its task finished.
836
+ let p = {};
837
+ try {
838
+ p = JSON.parse(e.payload || "{}");
839
+ }
840
+ catch {
841
+ /* keep {} */
842
+ }
843
+ const how = p.how === "replied" || p.how === "fixed" ? p.how : "ignored";
844
+ onEvent({ type: "followup", agent: String(p.to ?? e.agent_id ?? ""), by: String(p.by ?? ""), task: e.task_id, title: db.getTask(e.task_id)?.title ?? e.task_id, kind: p.kind === "radar" ? "radar" : "buddy", how, note: String(p.body ?? "") });
845
+ }
846
+ else if (e.type === "contract.amended") {
847
+ let p = {};
848
+ try {
849
+ p = JSON.parse(e.payload || "{}");
850
+ }
851
+ catch {
852
+ /* keep {} */
853
+ }
854
+ onEvent({ type: "contract", agent: String(p.by ?? e.agent_id ?? ""), version: Number(p.version) || 0, section: String(p.section ?? ""), change: String(p.change ?? "") });
855
+ }
433
856
  });
434
857
  const tick = setInterval(() => {
858
+ opts.budget?.check();
435
859
  watchSilence();
436
860
  offerHelp();
861
+ buddyBySilence();
437
862
  }, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
438
863
  tick.unref();
439
864
  stopWatch = () => {
440
865
  clearInterval(tick);
441
866
  unwatch();
442
867
  };
868
+ // Integration radar: compare in-flight agents' uncommitted work for
869
+ // conflicts and name mismatches while they can still fix them (radar.ts).
870
+ // Local agents only — a teammate's work lives on their machine.
871
+ if (opts.radarMs !== null)
872
+ stopRadar = startRadar({
873
+ db,
874
+ repoRoot,
875
+ intervalMs: opts.radarMs,
876
+ inFlight: () => [...running.values()]
877
+ // Still working: an exited agent's worktree is being committed and rebased by finish().
878
+ .filter((h) => h.child && h.child.exitCode === null && h.child.signalCode === null && !resolving.has(h.taskId))
879
+ .map((h) => ({ agentId: h.agentId, taskId: h.taskId, title: db.getTask(h.taskId)?.title ?? h.taskId, worktree: h.worktree, excludes: taskWt.get(h.taskId)?.excludes ?? h.excludes })),
880
+ // A finding is a note its reader must answer (fix it, or say why not) before huddle_done.
881
+ notify: (to, message, key) => {
882
+ const h = running.get(to);
883
+ const m = notifyAgent(db, to, message, "huddle", h?.taskId ?? null);
884
+ if (h)
885
+ trackNote(db, m, { by: "huddle", kind: "radar", worktree: h.worktree, key });
886
+ },
887
+ // The radar no longer sees it: whoever fixed it, every note about it is answered.
888
+ resolved: (key) => resolveNotes(db, key),
889
+ contractNames: () => contractNames(getContract(db)),
890
+ onEvent,
891
+ });
443
892
  }
893
+ /** Notes that came back after the author exited: one more round to answer them, before anything merges. */
894
+ const followUpBrief = (notes) => `\n\nWhile you were finishing, a teammate looked over your work and these notes reached Huddle after you exited — nothing is merged yet. ` +
895
+ `Address each one: fix it, or reply huddle_send(to="<who>", reply_to="<id>", message="won't fix: <why>"). Then huddle_done.\n` +
896
+ notes.map((n) => `- [id ${n.id}, from ${n.kind === "radar" ? "huddle" : n.by}] ${cap(n.body, 800)}`).join("\n");
444
897
  const finish = async (w, h, exitCode, why) => {
445
898
  const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
446
899
  running.delete(w.id);
@@ -449,14 +902,46 @@ export async function runBoard(opts) {
449
902
  const task = db.getTask(h.taskId);
450
903
  const quiet = silenced.get(task.id);
451
904
  silenced.delete(task.id);
905
+ // A buddy check still being written is about THIS work. Found live (capstone
906
+ // round 4): 3 of 4 buddy notes came back after the author finished, and
907
+ // reached its next task instead. Hold the author (and its merge) for it, bounded.
908
+ const check = buddyRuns.get(task.id);
909
+ if (check && exitCode === 0 && !signal?.aborted) {
910
+ reserved.add(w.id);
911
+ pending++; // the run isn't over while this waits
912
+ let timer;
913
+ await Promise.race([check.landed, new Promise((r) => (timer = setTimeout(r, Math.max(0, check.at + buddyWaitMs - Date.now()))))]);
914
+ clearTimeout(timer);
915
+ pending--;
916
+ reserved.delete(w.id);
917
+ }
452
918
  if (signal?.aborted) {
453
- fail(w, h, task.title, "stopped by you", workMs);
919
+ fail(w, h, task.title, stoppedWhy(), workMs);
454
920
  }
455
921
  else if (exitCode !== 0) {
456
922
  failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
457
923
  }
458
924
  else {
925
+ // Notes it never saw (they came back after it exited). One it saw and didn't answer is just ignored.
926
+ const late = w.adapter ? openNotes(db, task.id).filter((n) => n.to === w.id && !db.getMessage(n.id)?.read_at) : [];
927
+ if (late.length && !followedUp.has(task.id)) {
928
+ followedUp.add(task.id);
929
+ for (const n of late)
930
+ db.markRead(n.id, "prompt"); // delivered in the brief, not the inbox
931
+ carry.set(task.id, totals(task.id, h, workMs));
932
+ db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) got ${late.length} teammate note(s) after it finished; one more round to answer them`, author: "huddle" });
933
+ start(w, task, followUpBrief(late));
934
+ return fill();
935
+ }
936
+ closeNotes(db, task.id); // each note: addressed or ignored (the note.closed watcher reports it)
459
937
  db.setTaskStatus(task.id, "review");
938
+ // A paired task lands in a safe order (see pairUp): wait for its partner first.
939
+ const first = holdFor.get(task.id);
940
+ if (first && !settled(first)) {
941
+ held.push({ w, h, task, workMs, after: first });
942
+ pending++;
943
+ return fill();
944
+ }
460
945
  return mergeTask(w, h, task, workMs);
461
946
  }
462
947
  fill();
@@ -489,6 +974,7 @@ export async function runBoard(opts) {
489
974
  // Cross-review by an otherwise-idle agent: one round, never blocks on its own.
490
975
  const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
491
976
  let skippedReview = false;
977
+ let shallowReview = false;
492
978
  if (reviewer) {
493
979
  reviewed.add(task.id);
494
980
  reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
@@ -499,7 +985,7 @@ export async function runBoard(opts) {
499
985
  onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
500
986
  fill(); // everyone else keeps going
501
987
  const t0 = Date.now();
502
- const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt)), wt.path, {
988
+ const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
503
989
  signal,
504
990
  timeoutMs: 90_000,
505
991
  noTools: true, // the diff is in the prompt — no need to explore
@@ -510,6 +996,7 @@ export async function runBoard(opts) {
510
996
  db.setAgentStatus(reviewer.id, "idle");
511
997
  pending--;
512
998
  skippedReview = !!verdict.skipped;
999
+ shallowReview = !!verdict.shallow;
513
1000
  onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
514
1001
  if (!verdict.approve && !signal?.aborted) {
515
1002
  db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
@@ -553,7 +1040,7 @@ export async function runBoard(opts) {
553
1040
  recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
554
1041
  const resolvedBy = resolving.get(task.id)?.by.at(-1);
555
1042
  const reason = prep.reason +
556
- (reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}` : "") +
1043
+ (reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}${shallowReview ? " (shallow approve)" : ""}` : "") +
557
1044
  (resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
558
1045
  onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
559
1046
  }
@@ -576,6 +1063,7 @@ export async function runBoard(opts) {
576
1063
  startedAt: Date.now(),
577
1064
  meter: new UsageMeter(),
578
1065
  };
1066
+ opts.budget?.track(h.meter, h.agentId);
579
1067
  db.setTaskStatus(task.id, "in_progress");
580
1068
  db.setAgentStatus(w.id, "working");
581
1069
  running.set(w.id, h);
@@ -622,8 +1110,13 @@ export async function runBoard(opts) {
622
1110
  db.publish("handoff", { task_id: task.id, agent_id: h.agent_id, payload: { from: h.agent_id, to: w.id, task: h.task_id, title: h.title, files: h.files, symbols: h.symbols } });
623
1111
  onEvent({ type: "handoff", agent: w.id, from: h.agent_id, task: task.id, title: h.title, files: h.files.length, symbols: h.symbols.length });
624
1112
  }
1113
+ // The contract as it stands now (amendments included): each launch is a fresh process.
1114
+ const cb = contractBrief(db);
1115
+ // What the user said mid-run (room/user.ts) outranks the plan and the contract.
1116
+ const ub = userBrief(db);
1117
+ const contract = (cb ? `\n\n${cb}` : "") + (ub ? `\n\n${ub}` : "");
625
1118
  if (w.remote)
626
- return void startRemote(w, { ...task, spec: task.spec + ho.text }, extra);
1119
+ return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
627
1120
  // Who else is mid-task right now, so this agent knows whom to tell (first round only;
628
1121
  // fill() claims a whole wave before launching any of it, so siblings see each other).
629
1122
  const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
@@ -635,7 +1128,7 @@ export async function runBoard(opts) {
635
1128
  agentId: w.id,
636
1129
  role: "worker",
637
1130
  taskId: task.id,
638
- taskSpec: `${task.title}\n\n${task.spec}${ho.text}${along}${extra}`,
1131
+ taskSpec: `${task.title}\n\n${task.spec}${contract}${ho.text}${along}${extra}`,
639
1132
  roomUrl,
640
1133
  roomSecret: opts.roomSecret,
641
1134
  approvals: opts.approvals,
@@ -644,27 +1137,44 @@ export async function runBoard(opts) {
644
1137
  });
645
1138
  // Every agent's Plug wiring that sat in this worktree stays out of the commit.
646
1139
  const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
1140
+ opts.budget?.track(h.meter, h.agentId);
647
1141
  taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
648
1142
  db.setTaskStatus(task.id, "in_progress");
649
1143
  db.setAgentStatus(w.id, "working", h.worktree);
650
1144
  running.set(w.id, h);
651
1145
  heard(w.id, h);
652
1146
  onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra && !takeoverFrom, ...(takeoverFrom ? { takeover: takeoverFrom } : {}) });
653
- let exited = false; // 'error' and 'exit' can both fire
654
- const once = (code) => {
655
- if (exited)
656
- return;
657
- exited = true;
658
- void finish(w, h, code);
659
- };
660
- h.child.on("exit", once);
661
- h.child.on("error", () => once(-1));
1147
+ // On 'close', capped at 2s after 'exit': found live, an agent's final stdout
1148
+ // lines (its token usage, the log's tail) were lost when finish() ran on 'exit'.
1149
+ onFinished(h.child, (code) => void finish(w, h, code));
662
1150
  };
663
1151
  const fill = () => {
1152
+ // Paired tasks whose partner has landed (or failed: then nothing to wait for) merge now.
1153
+ // Every ready one is taken out before any merge starts: a merge re-enters fill().
1154
+ const ready = held.filter((x) => signal?.aborted || settled(x.after));
1155
+ if (ready.length) {
1156
+ held.splice(0, held.length, ...held.filter((x) => !ready.includes(x)));
1157
+ let merging = 0;
1158
+ for (const x of ready) {
1159
+ pending--;
1160
+ holdFor.delete(x.task.id);
1161
+ if (signal?.aborted)
1162
+ fail(x.w, x.h, x.task.title, stoppedWhy(), x.workMs);
1163
+ // Tests for code that never landed would only go red at the gate and burn a fix round.
1164
+ else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
1165
+ fail(x.w, x.h, x.task.title, `the task it tests (“${db.getTask(x.after)?.title ?? x.after}”) didn't land — work kept on branch ${x.h.branch}`, x.workMs);
1166
+ else {
1167
+ merging++;
1168
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1169
+ }
1170
+ }
1171
+ if (merging)
1172
+ return; // each merge fills again when it's done
1173
+ }
664
1174
  // Conflicts first: they hold finished work, and the author is usually free.
665
1175
  for (const c of conflicts.splice(0)) {
666
1176
  if (signal?.aborted) {
667
- parkForYou(c, "stopped by you");
1177
+ parkForYou(c, stoppedWhy());
668
1178
  continue;
669
1179
  }
670
1180
  const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
@@ -680,13 +1190,17 @@ export async function runBoard(opts) {
680
1190
  fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
681
1191
  continue;
682
1192
  }
1193
+ if (overBudget()) {
1194
+ fail(rc.from, rc.h, title, `${rc.reason} (not taken over: the token budget is nearly spent)`, 0);
1195
+ continue;
1196
+ }
683
1197
  const r = pickRescuer(rc);
684
1198
  if (r)
685
1199
  startRescue(r, rc);
686
1200
  else
687
1201
  rescues.push(rc); // the next other agent to finish takes it
688
1202
  }
689
- if (!signal?.aborted) {
1203
+ if (!signal?.aborted && !overBudget()) {
690
1204
  // Fairness: the free agent that has taken the fewest tasks goes first
691
1205
  // (roster order breaks ties), so one agent doesn't take every task that
692
1206
  // unblocks just because it's listed first.
@@ -730,14 +1244,25 @@ export async function runBoard(opts) {
730
1244
  start(w, task);
731
1245
  }
732
1246
  offerHelp();
733
- if (running.size === 0 && pending === 0) {
1247
+ // Nobody is working and only paired merges wait: their partner can't land now, so don't wait for it.
1248
+ if (running.size === 0 && held.length && pending === held.length && !pairing.size) {
1249
+ const x = held.shift();
1250
+ pending--;
1251
+ holdFor.delete(x.task.id);
1252
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1253
+ return;
1254
+ }
1255
+ if (running.size === 0 && pending === 0 && !pairing.size) {
734
1256
  // Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
735
1257
  for (const c of conflicts.splice(0))
736
1258
  parkForYou(c, "no agent on this machine could take the resolution");
737
1259
  for (const rc of rescues.splice(0))
738
1260
  fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
739
1261
  const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
740
- if (blocked.length && !signal?.aborted)
1262
+ const b = opts.budget;
1263
+ if (b?.limit && b.level !== "ok")
1264
+ onEvent({ type: "budget", level: b.level === "hard" ? "hard" : "soft", tokens: b.tokens, limit: b.limit, left: blocked });
1265
+ else if (blocked.length && !signal?.aborted)
741
1266
  onEvent({ type: "stuck", blocked });
742
1267
  done();
743
1268
  }