huddle-cli 0.0.0-stage → 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 (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,1207 @@
1
+ // The scheduler: keeps every ready agent busy until the board is empty.
2
+ //
3
+ // Loop: any idle agent takes the best unblocked backlog task for its strengths
4
+ // (dependencies must be merged first). When its process exits, the merge engine
5
+ // runs the test gate; in manual mode you approve the merge; landing it can
6
+ // unblock more tasks, so we fill again. Nobody idles while there's work.
7
+ import { launchAgent, stopChild } from "./launcher.js";
8
+ import { prepareMerge, landMerge, removeWorktree, branchDiff, wipDiff, worktreeFor, createWorktree, beginConflictMerge, conflictBrief, finishConflictMerge, abortConflictMerge, } from "../sandbox/worktree.js";
9
+ import { fetchBranch, deleteRemoteBranch } from "../sandbox/sync.js";
10
+ import { UsageMeter } from "../adapters/usage.js";
11
+ import { askAgent } from "./ask.js";
12
+ import { handoffsFor, headSha, recordHandoff } from "./handoff.js";
13
+ import { takeoverBrief, silenceMsFromEnv, helpAfterMsFromEnv, pairWaitMsFromEnv, buddyAfterMsFromEnv, isSpecialTask, buddyPrompt, parseBuddy, pairPrompt, parsePart, testsPart, pairNote, BUDDY_AT, pairEligible, } from "./takeover.js";
14
+ import { helpOffer, alongsideBrief, addSplitPart, taskViews } from "../room/progress.js";
15
+ import { contractBrief, contractNames, getContract } from "../room/contract.js";
16
+ import { notifyAgent } from "../room/mail.js";
17
+ import { closeNotes, openNotes, resolveNotes, trackNote } from "../room/followup.js";
18
+ import { onFinished } from "../platform.js";
19
+ import { startRadar } from "./radar.js";
20
+ const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
21
+ /** No help offers on a task whose reported progress is this far along. */
22
+ export const OFFER_MAX_PCT = 60;
23
+ /** Resolver runs per conflicted task before it's parked for you. */
24
+ export const MAX_RESOLVE_ATTEMPTS = 2;
25
+ const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
26
+ function resolvePrompt(o) {
27
+ return `Your branch for this task is finished, but another agent's change landed on main touching the same lines.
28
+ Huddle is merging main INTO this task's branch in your current directory, and git stopped on conflicts.
29
+ Your job is ONLY to resolve them so that BOTH changes survive and work together.
30
+
31
+ THIS TASK (its change is "this task" below): ${o.title}
32
+ ${cap(o.spec, 1500)}
33
+
34
+ CONFLICTED FILES, with git's markers (<<<<<<< this task · ||||||| common base · ======= · >>>>>>> main):
35
+ ${o.brief.marked}
36
+
37
+ WHAT THIS TASK CHANGED (base → this task):
38
+ ${o.brief.oursDiff || "(no diff)"}
39
+
40
+ WHAT LANDED ON MAIN MEANWHILE (base → main):
41
+ ${o.brief.theirsDiff || "(no diff)"}
42
+
43
+ THE OTHER WORK ON MAIN, and what its agents noted:
44
+ ${o.others || "(no notes)"}
45
+ ${o.retry ? `\nA previous attempt was rejected: ${o.retry}\n` : ""}
46
+ HOW:
47
+ 1. Edit each conflicted file: remove every marker line and write ONE version in which both changes work together.
48
+ 2. Never take one side wholesale (no "git checkout --ours/--theirs", no copying one version over the other):
49
+ Huddle rejects a file identical to either side, because that silently drops a teammate's work.
50
+ 3. Change only what the combination needs. Don't commit, merge, rebase or reset — Huddle commits the
51
+ resolution and re-runs the tests before anything lands.
52
+ 4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
53
+ }
54
+ function reviewPrompt(title, spec, diff, contract = "") {
55
+ return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
56
+
57
+ TASK: ${title}
58
+ ${spec}
59
+ ${contract ? `\n${cap(contract, 2500)}\n` : ""}
60
+ DIFF (against main):
61
+ ${diff || "(empty diff)"}
62
+
63
+ 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.
64
+ Check, in order: (1) names and shapes match the ${contract ? "contract and the " : ""}task (functions, fields, routes, messages);
65
+ (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.
66
+ Reply with the first line exactly APPROVE or CHANGES.
67
+ If APPROVE, add one line "Verified: <what you checked and found working>".
68
+ If CHANGES, add up to 6 short lines, each one concrete blocking problem and where it is.
69
+ Style preferences and nice-to-haves are NOT blocking: APPROVE if that's all you have. Do not edit anything.`;
70
+ }
71
+ /**
72
+ * The first APPROVE/CHANGES line decides. Anything else is "skipped" — shown
73
+ * honestly, and it never blocks a merge (a reviewer can't block by accident).
74
+ */
75
+ export function parseVerdict(text) {
76
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
77
+ // "APPROVED" is an approve too (it used to read as no verdict at all).
78
+ const i = lines.findIndex((l) => /^(APPROVED?|CHANGES)\b/.test(l.replace(/[*`#_]/g, "").toUpperCase()));
79
+ if (i < 0)
80
+ return { approve: true, skipped: true, notes: "no verdict" };
81
+ if (lines[i].replace(/[*`#_]/g, "").toUpperCase().startsWith("CHANGES"))
82
+ return { approve: false, notes: lines.slice(i + 1, i + 7).join("\n") || "(no details given)" };
83
+ // Found live: ~9s bare APPROVEs. An approve that says nothing about what was verified is shown as shallow.
84
+ const said = [lines[i].replace(/[*`#_]/g, "").replace(/^\s*APPROVE[D]?\b[\s:.,!—-]*/i, ""), ...lines.slice(i + 1, i + 3)].join(" ").trim();
85
+ // "Verified: ok" / "LGTM" say nothing either: what's left after the label has to name something.
86
+ const what = said.replace(/^(verified|checked)\s*:?\s*/i, "").replace(/^(lgtm|looks good|ok|fine)\b[\s.!,]*/i, "");
87
+ return what.length >= 10 ? { approve: true, notes: said } : { approve: true, shallow: true, notes: "approve (shallow)" };
88
+ }
89
+ export async function runBoard(opts) {
90
+ const { db, repoRoot, roomUrl, workers, testCmd, signal } = opts;
91
+ // Every scheduler event (but the per-tick token meter) is also kept in the
92
+ // Room db as "run.<type>", so `huddle story` can replay a run afterwards.
93
+ const onEvent = (e) => {
94
+ if (e.type !== "tokens") {
95
+ try {
96
+ const ev = e;
97
+ db.publish(`run.${e.type}`, { agent_id: ev.agent ?? null, task_id: ev.task ?? null, payload: e });
98
+ }
99
+ catch {
100
+ /* a closed db never breaks a run */
101
+ }
102
+ }
103
+ opts.onEvent(e);
104
+ };
105
+ // The shared contract, when the Council wrote one ("" when there is none).
106
+ const contractOf = opts.contractBrief ?? contractBrief;
107
+ const contract = () => {
108
+ try {
109
+ return contractOf(db) || "";
110
+ }
111
+ catch {
112
+ return "";
113
+ }
114
+ };
115
+ const running = new Map(); // agent id -> handle
116
+ let merged = 0;
117
+ let failed = 0;
118
+ let pending = 0; // merges awaiting approval
119
+ const all = () => [...workers, ...(opts.remoteWorkers?.() ?? [])];
120
+ const known = new Set();
121
+ const register = (w) => {
122
+ if (known.has(w.id))
123
+ return;
124
+ known.add(w.id);
125
+ db.upsertAgent({
126
+ id: w.id,
127
+ vendor: w.adapter?.vendor ?? `${w.remote?.member}'s`,
128
+ model: w.adapter?.models.worker || "default",
129
+ role: "worker",
130
+ });
131
+ };
132
+ workers.forEach(register);
133
+ const unavailable = new Set(); // teammates' agents that declined this run
134
+ return new Promise((resolve) => {
135
+ let resolved = false;
136
+ let stopWatch = () => { }; // the silence watchdog + help offers (set up below)
137
+ let stopRadar = () => { }; // the integration radar (set up below)
138
+ // Buddy checks and pairing asks end with the run (or with you): no ask outlives it.
139
+ const sideAsks = new AbortController();
140
+ signal?.addEventListener("abort", () => sideAsks.abort());
141
+ const done = () => {
142
+ if (resolved)
143
+ return;
144
+ resolved = true;
145
+ sideAsks.abort();
146
+ stopWatch();
147
+ stopRadar();
148
+ process.off("exit", killAll);
149
+ resolve({ merged, failed, stopped: !!signal?.aborted });
150
+ };
151
+ const killAll = () => {
152
+ for (const h of running.values())
153
+ stopChild(h.child);
154
+ };
155
+ signal?.addEventListener("abort", killAll);
156
+ // If Huddle itself exits, never leave orphaned agents working for nobody.
157
+ process.once("exit", killAll);
158
+ // Agents held out of fill(): an author waiting on review, a reviewer reviewing.
159
+ const reserved = new Set();
160
+ const reviewed = new Set(); // task ids that already had their one review round
161
+ const testFixed = new Set(); // task ids that already had their one red-tests fix round
162
+ const carry = new Map(); // earlier rounds
163
+ const taken = new Map(); // agent id -> backlog tasks it has claimed this run (fairness in fill)
164
+ const firstStart = new Map(); // task id -> when its first round began (handoff notes window)
165
+ const totals = (taskId, h, ms) => {
166
+ const c = carry.get(taskId) ?? { ms: 0, tokens: 0, cached: 0 };
167
+ return { ms: c.ms + ms, tokens: c.tokens + h.meter.tokens, cached: c.cached + h.meter.cached };
168
+ };
169
+ const fail = (w, h, title, reason, ms) => {
170
+ db.setTaskStatus(h.taskId, "failed");
171
+ db.remember({ kind: "gotcha", body: `Task "${title}" didn't merge: ${reason}`, author: "huddle" });
172
+ failed++;
173
+ onEvent({ type: "failed", agent: w.id, task: h.taskId, title, reason, log: h.logPath, ...totals(h.taskId, h, ms) });
174
+ };
175
+ const reviewsBy = new Map(); // agent id -> reviews it gave this run
176
+ /**
177
+ * An agent with nothing else it could do right now — the no-idle rule's
178
+ * reviewer (local only). Fewest reviews given so far goes first, roster
179
+ * order breaks ties. Found live (capstone round 2): roster-first claude was
180
+ * the idle one at almost every merge and did 4 of the 6 reviews.
181
+ */
182
+ const idleReviewer = (authorId) => workers
183
+ .filter((w) => w.id !== authorId &&
184
+ !running.has(w.id) &&
185
+ !reserved.has(w.id) &&
186
+ w.adapter?.ask &&
187
+ !db.nextBacklogTask(prefersOf(w)))
188
+ .map((w, i) => ({ w, i }))
189
+ .sort((a, b) => (reviewsBy.get(a.w.id) ?? 0) - (reviewsBy.get(b.w.id) ?? 0) || a.i - b.i)[0]?.w;
190
+ // --- merge conflicts -----------------------------------------------------
191
+ // A branch that can't rebase onto main goes to a resolver: the author if
192
+ // it's free (it knows the change), else any free agent, fresh eyes on a
193
+ // retry. Its work is checked (no markers, neither side dropped), the tests
194
+ // re-run, and it lands. After MAX_RESOLVE_ATTEMPTS it's parked for you —
195
+ // main untouched, the branch exactly as the author left it.
196
+ const conflicts = []; // waiting for a free agent
197
+ const resolving = new Map(); // task id -> attempts
198
+ const resolveConflict = (w, h, taskId, ms) => {
199
+ carry.set(taskId, totals(taskId, h, ms));
200
+ db.setTaskStatus(taskId, "review");
201
+ conflicts.push({ author: w, h: { ...h, meter: new UsageMeter() }, taskId }); // its tokens are in carry now
202
+ };
203
+ const parkForYou = (c, reason) => {
204
+ const task = db.getTask(c.taskId);
205
+ db.setTaskStatus(task.id, "needs_you");
206
+ db.remember({
207
+ kind: "gotcha",
208
+ body: `Task "${task.title}" conflicts with main and needs you: ${reason}. Nothing landed; the work is on branch ${c.h.branch}.`,
209
+ author: "huddle",
210
+ });
211
+ failed++;
212
+ const t = carry.get(task.id) ?? { ms: 0, tokens: 0, cached: 0 };
213
+ onEvent({
214
+ type: "failed",
215
+ needsYou: true,
216
+ agent: c.author.id,
217
+ task: task.id,
218
+ title: task.title,
219
+ reason: `merge conflict needs you — ${reason}; main untouched, work kept on branch ${c.h.branch} (${c.h.worktree})`,
220
+ log: c.h.logPath,
221
+ ...t,
222
+ });
223
+ };
224
+ const pickResolver = (author, tried) => {
225
+ const free = workers.filter((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id));
226
+ return (free.find((x) => x.id === author.id && !tried.includes(x.id)) ??
227
+ free.find((x) => !tried.includes(x.id)) ??
228
+ free.find((x) => x.id === author.id) ??
229
+ free[0]);
230
+ };
231
+ const startResolve = (r, c) => {
232
+ const task = db.getTask(c.taskId);
233
+ const wt = { branch: c.h.branch, path: c.h.worktree };
234
+ const st = resolving.get(task.id) ?? { n: 0, by: [] };
235
+ if (st.n >= MAX_RESOLVE_ATTEMPTS)
236
+ return parkForYou(c, `${st.n} resolver attempts didn't hold (last: ${st.last})`);
237
+ const cs = beginConflictMerge(repoRoot, wt, `huddle: merge main into "${task.title}"`);
238
+ if ("error" in cs)
239
+ return parkForYou(c, cs.error);
240
+ if (!cs.files.length)
241
+ return void mergeTask(c.author, c.h, task, 0); // main moved on and it now merges cleanly
242
+ st.n++;
243
+ st.by.push(r.id);
244
+ resolving.set(task.id, st);
245
+ const brief = conflictBrief(wt, cs);
246
+ const others = brief.theirCommits
247
+ .map((subject) => {
248
+ const title = /^huddle\(([^)]*)\): (.*)$/.exec(subject)?.[2];
249
+ const t = title ? db.listTasks().find((x) => x.title === title) : undefined;
250
+ if (!t)
251
+ return `- ${subject}`;
252
+ // Its done-summary, then what its agent wrote down (decisions say WHY the lines changed).
253
+ const done = db.recall(`Task ${t.id} done:`);
254
+ const notes = [...done, ...db.recall().filter((m) => m.author === t.claimed_by && !done.some((d) => d.id === m.id))];
255
+ return [`- "${t.title}" (${t.claimed_by ?? "?"}): ${cap(t.spec, 600)}`, ...notes.slice(0, 4).map((m) => ` note (${m.author}): ${cap(m.body, 300)}`)].join("\n");
256
+ })
257
+ .join("\n");
258
+ const h2 = launchAgent({
259
+ repoRoot,
260
+ adapter: r.adapter,
261
+ agentId: r.id,
262
+ role: "worker",
263
+ taskId: task.id,
264
+ // The contract decides a name clash: both sides code to it.
265
+ 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)}` : ""}`,
266
+ roomUrl,
267
+ roomSecret: opts.roomSecret,
268
+ approvals: opts.approvals,
269
+ worktree: wt,
270
+ onTokens: (tokens) => onEvent({ type: "tokens", agent: r.id, task: task.id, tokens }),
271
+ });
272
+ db.setTaskStatus(task.id, "in_progress");
273
+ db.setAgentStatus(r.id, "working", wt.path);
274
+ running.set(r.id, h2);
275
+ heard(r.id, h2);
276
+ 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 });
277
+ // On 'close' (capped): its last stdout lines (token usage) can still be in the pipe at 'exit'.
278
+ onFinished(h2.child, (code) => void endResolve(r, c, h2, cs, code));
279
+ };
280
+ const endResolve = async (r, c, h2, cs, code) => {
281
+ const ms = Date.now() - h2.startedAt;
282
+ running.delete(r.id);
283
+ db.releaseClaims(r.id);
284
+ db.setAgentStatus(r.id, "idle");
285
+ const task = db.getTask(c.taskId);
286
+ silenced.delete(task.id); // a silent resolver is just a failed attempt
287
+ const wt = { branch: c.h.branch, path: c.h.worktree };
288
+ // Both agents' Plug wiring may sit in this worktree; neither may be committed.
289
+ const h = { ...h2, excludes: [...new Set([...c.h.excludes, ...h2.excludes])] };
290
+ const res = signal?.aborted
291
+ ? { ok: false, reason: "stopped by you" }
292
+ : code === 0
293
+ ? finishConflictMerge(wt, cs, h.excludes)
294
+ : { ok: false, reason: `resolver exited with code ${code}` };
295
+ onEvent({ type: "resolved", agent: r.id, author: c.author.id, task: task.id, title: task.title, ok: res.ok, reason: res.reason, ms });
296
+ if (res.ok) {
297
+ db.remember({ kind: "decision", body: `Merge conflict in "${task.title}" (${cs.files.join(", ")}) resolved by ${r.id}; tests re-run before it lands`, author: "huddle" });
298
+ db.setTaskStatus(task.id, "review");
299
+ return mergeTask(c.author, h, task, ms); // tests again → review → land
300
+ }
301
+ abortConflictMerge(wt, cs); // the branch is the author's own commits again
302
+ resolving.get(task.id).last = res.reason;
303
+ db.remember({ kind: "gotcha", body: `Resolving the conflict in "${task.title}" (${r.id}) didn't hold: ${res.reason}`, author: "huddle" });
304
+ resolveConflict(c.author, h, task.id, ms);
305
+ fill();
306
+ };
307
+ // --- takeovers ------------------------------------------------------------
308
+ // Rescue: a task whose agent crashed, stayed red after its fix round, or
309
+ // went silent goes to a DIFFERENT free agent, which continues in the same
310
+ // worktree with a brief (takeover.ts). One takeover per task; a rescuer
311
+ // that can't land it either parks it for you, branch kept. Split: a busy
312
+ // agent's huddle_split puts a part on the board and a free agent takes it.
313
+ const silenceMs = opts.silenceMs ?? silenceMsFromEnv();
314
+ const helpAfterMs = opts.helpAfterMs !== undefined ? opts.helpAfterMs : helpAfterMsFromEnv();
315
+ const taskWt = new Map(); // task id -> its worktree, whoever works it
316
+ const lastSeen = new Map(); // agent id -> its last output or huddle_* call
317
+ const silenced = new Map(); // task id -> why we stopped its agent
318
+ /** Start listening for signs of life from a freshly launched agent. */
319
+ const heard = (id, h) => {
320
+ const bump = () => lastSeen.set(id, Date.now());
321
+ bump();
322
+ h.child?.stdout?.on("data", bump);
323
+ h.child?.stderr?.on("data", bump);
324
+ };
325
+ const rescues = []; // waiting for a free agent other than the author
326
+ const takenOver = new Map(); // task id -> its one takeover
327
+ const offered = new Set(); // task ids a free agent already offered to help with
328
+ const splitFrom = new Map(); // part task id -> who split it off what
329
+ /** Fail it — or, the first time, hand it to another agent to finish from the same branch. */
330
+ const failOrRescue = (w, h, task, reason, ms, testOutput) => {
331
+ const prior = takenOver.get(task.id);
332
+ if (prior)
333
+ return parkTakeover(w, h, task, reason, ms, prior.from);
334
+ const someoneElse = workers.some((x) => x.adapter && x.id !== w.id);
335
+ if (signal?.aborted || w.remote || !someoneElse)
336
+ return fail(w, h, task.title, reason, ms);
337
+ carry.set(task.id, totals(task.id, h, ms));
338
+ db.setTaskStatus(task.id, "claimed"); // held (files stay busy) until a rescuer starts
339
+ db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) stopped: ${reason} — another agent takes it over from its branch`, author: "huddle" });
340
+ rescues.push({ from: w, h: { ...h, meter: new UsageMeter() }, taskId: task.id, reason, testOutput }); // its tokens are in carry now
341
+ };
342
+ const parkTakeover = (w, h, task, reason, ms, from) => {
343
+ db.setTaskStatus(task.id, "needs_you");
344
+ db.remember({
345
+ kind: "gotcha",
346
+ body: `Task "${task.title}" needs you: ${from} and then ${w.id} couldn't land it (last: ${reason}). Nothing landed; the work is on branch ${h.branch}.`,
347
+ author: "huddle",
348
+ });
349
+ failed++;
350
+ onEvent({
351
+ type: "failed",
352
+ needsYou: true,
353
+ agent: w.id,
354
+ task: task.id,
355
+ title: task.title,
356
+ reason: `needs you — taken over from ${from}, still not landing: ${reason}; main untouched, work kept on branch ${h.branch} (${h.worktree})`,
357
+ log: h.logPath,
358
+ ...totals(task.id, h, ms),
359
+ });
360
+ };
361
+ /** A different free local agent: another vendor first (fresh eyes), then one with nothing else to do. */
362
+ const pickRescuer = (rc) => {
363
+ const vendor = rc.from.adapter?.vendor;
364
+ const score = (x) => (x.adapter.vendor === vendor ? 2 : 0) + (db.nextBacklogTask(prefersOf(x)) ? 1 : 0) + (taken.get(x.id) ?? 0) / 100;
365
+ return workers
366
+ .filter((x) => x.adapter && x.id !== rc.from.id && !running.has(x.id) && !reserved.has(x.id))
367
+ .sort((a, b) => score(a) - score(b))[0];
368
+ };
369
+ const startRescue = (r, rc) => {
370
+ const task = db.getTask(rc.taskId);
371
+ takenOver.set(task.id, { from: rc.from.id, by: r.id });
372
+ db.reassignTask(task.id, r.id);
373
+ // The author's rounds are reported here: no merged/failed event will carry them.
374
+ const t = carry.get(task.id) ?? { ms: 0, tokens: 0, cached: 0 };
375
+ carry.delete(task.id);
376
+ db.publish("task.takeover", {
377
+ task_id: task.id,
378
+ agent_id: r.id,
379
+ payload: { kind: "rescue", from: rc.from.id, to: r.id, title: task.title, reason: rc.reason, branch: rc.h.branch },
380
+ });
381
+ db.remember({ kind: "decision", body: `${r.id} took over ${rc.from.id}'s "${task.title}" (${rc.reason}) and continues on its branch`, author: "huddle" });
382
+ onEvent({ type: "takeover", kind: "rescue", agent: r.id, from: rc.from.id, task: task.id, title: task.title, reason: rc.reason, ...t });
383
+ taken.set(r.id, (taken.get(r.id) ?? 0) + 1);
384
+ const brief = takeoverBrief(db, {
385
+ taskId: task.id,
386
+ from: rc.from.id,
387
+ reason: rc.reason,
388
+ branch: rc.h.branch,
389
+ since: firstStart.get(task.id) ?? rc.h.startedAt,
390
+ testOutput: rc.testOutput,
391
+ logPath: rc.h.logPath,
392
+ });
393
+ start(r, task, brief, rc.from.id);
394
+ };
395
+ /** Stop agents that have gone quiet; they're taken over in finish(). */
396
+ const watchSilence = () => {
397
+ for (const [id, h] of running) {
398
+ if (!h.child || silenced.has(h.taskId) || h.child.exitCode !== null || h.child.signalCode !== null)
399
+ continue;
400
+ const quiet = Date.now() - Math.max(lastSeen.get(id) ?? 0, h.startedAt);
401
+ if (quiet < silenceMs)
402
+ continue;
403
+ const span = quiet >= 60_000 ? `${Math.round(quiet / 60_000)} min` : `${Math.round(quiet / 1000)}s`;
404
+ silenced.set(h.taskId, `went silent — no output and no huddle_* call for ${span}`);
405
+ stopChild(h.child);
406
+ }
407
+ };
408
+ // --- teamwork mid-task --------------------------------------------------
409
+ // Found live (3 capstone runs): every help offer was declined, no split
410
+ // ever happened, and reviews came only after huddle_done. So Huddle acts:
411
+ // buddy checks put a second agent's eyes on work in progress, and pairing
412
+ // hands a part of the slowest task to a free agent unless its author says no.
413
+ const buddyOn = opts.buddyChecks ?? !/^(off|no|0|false)$/i.test(process.env.HUDDLE_BUDDY ?? "");
414
+ const pairWaitMs = opts.pairWaitMs ?? pairWaitMsFromEnv();
415
+ const buddyAfterMs = opts.buddyAfterMs !== undefined ? opts.buddyAfterMs : buddyAfterMsFromEnv();
416
+ const buddyBy = new Map(); // agent id -> buddy checks it gave
417
+ const buddyDone = new Map(); // task id -> its checks so far (and the first one's pct)
418
+ const buddyBusy = new Set(); // task ids with a check in flight
419
+ const buddyRuns = new Map(); // task id -> its check in flight, for finish() to wait on
420
+ const buddyWaitMs = opts.buddyWaitMs ?? 60_000;
421
+ const followedUp = new Set(); // task ids that had their one round for notes that came too late
422
+ const paired = new Set(); // task ids that had their one pairing (or attempt)
423
+ const pairing = new Set(); // helper ids mid-pairing (asking, or waiting for a no)
424
+ const holdFor = new Map(); // task id -> the partner task that must land first
425
+ const testsOf = new Set(); // tests-part task ids (they test their partner's code)
426
+ const held = []; // finished, waiting for their partner to land
427
+ const settled = (id) => {
428
+ const st = db.getTask(id)?.status;
429
+ return !st || st === "merged" || st === "failed" || st === "needs_you";
430
+ };
431
+ /** The other tasks in flight, with their work so far (capped): where a buddy spots XYZ vs ABC. */
432
+ const othersInFlight = (taskId) => [...running.values()]
433
+ .filter((x) => x.taskId !== taskId && x.child)
434
+ .slice(0, 3)
435
+ .map((x) => {
436
+ const t = db.getTask(x.taskId);
437
+ const wip = wipDiff(repoRoot, x.worktree, x.excludes, 2500);
438
+ 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}` : ""}`;
439
+ })
440
+ .join("\n");
441
+ /** Never the author; a free agent first, then the one that gave the fewest buddy checks. */
442
+ const pickBuddy = (authorId) => workers
443
+ .filter((x) => x.id !== authorId && x.adapter?.ask)
444
+ .map((w, i) => ({ w, i, busy: running.has(w.id) || reserved.has(w.id) ? 1 : 0 }))
445
+ .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;
446
+ /** A teammate reads the work in progress and its notes land in the author's inbox now. Never blocks the author. */
447
+ const buddyCheck = async (taskId, pct, silentMs = 0) => {
448
+ if (!buddyOn || resolved || signal?.aborted || buddyBusy.has(taskId) || resolving.has(taskId))
449
+ return;
450
+ const t = db.getTask(taskId);
451
+ if (!t?.claimed_by || isSpecialTask(t))
452
+ return;
453
+ const author = t.claimed_by;
454
+ const h = running.get(author);
455
+ if (!h || h.taskId !== taskId || !h.child)
456
+ return; // local, and mid-task
457
+ const st = buddyDone.get(taskId) ?? { n: 0, notes: false, at: 0 };
458
+ // The second look is a follow-up on the first one's notes; a first look at 80% already was the late one.
459
+ // silentMs: the author never reported progress (found live: Claude called
460
+ // huddle_progress twice in 4 tasks), so the first look goes by time instead.
461
+ 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];
462
+ if (!due)
463
+ return;
464
+ const buddy = pickBuddy(author);
465
+ if (!buddy)
466
+ return;
467
+ // Nothing written yet: nothing to look at, and not worth a teammate's tokens. The next report tries again.
468
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 16_000);
469
+ if (!wip.diff)
470
+ return;
471
+ st.n++;
472
+ if (st.n === 1)
473
+ st.at = pct;
474
+ buddyDone.set(taskId, st);
475
+ buddyBusy.add(taskId);
476
+ buddyBy.set(buddy.id, (buddyBy.get(buddy.id) ?? 0) + 1);
477
+ const t0 = Date.now();
478
+ let landed = () => { };
479
+ buddyRuns.set(taskId, { landed: new Promise((r) => (landed = r)), at: t0 });
480
+ try {
481
+ // The Room's huddle_done waits on this pair (server.ts), so the notes reach the author before it exits.
482
+ db.publish("buddy.start", { task_id: taskId, agent_id: buddy.id, payload: { author, pct } });
483
+ const when = silentMs ? `${Math.round(silentMs / 60_000) || 1} min in` : `${pct}%`;
484
+ const prompt = buddyPrompt({ author, when, title: t.title, spec: t.spec, contract: contract(), others: othersInFlight(taskId), diff: wip.diff });
485
+ // Its own signal, tied to the run's: askAgent never unhooks, and a long run would pile listeners on one signal.
486
+ const stop = new AbortController();
487
+ const off = () => stop.abort();
488
+ sideAsks.signal.addEventListener("abort", off);
489
+ const res = await askAgent(buddy.adapter, prompt, repoRoot, { signal: stop.signal, timeoutMs: 90_000, noTools: true, quick: true }).finally(() => sideAsks.signal.removeEventListener("abort", off));
490
+ if (resolved || !res.ok)
491
+ return; // no answer: the author never knew it was asked
492
+ const notes = parseBuddy(res.text);
493
+ st.notes = !!notes;
494
+ // Its author is past it (finish() waited buddyWaitMs; it's merging or landed): the notes would only reach the
495
+ // author's NEXT task, where they mean nothing. Shared memory keeps them for QA and later tasks.
496
+ const st2 = db.getTask(taskId)?.status;
497
+ const late = !!notes && !!st2 && !["claimed", "in_progress"].includes(st2);
498
+ if (notes && late)
499
+ 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 });
500
+ else if (notes) {
501
+ const m = notifyAgent(db, author, `buddy check from ${buddy.id} ${silentMs ? when : `at ${when}`}:\n${notes}`, "huddle", taskId);
502
+ trackNote(db, m, { by: buddy.id, kind: "buddy", worktree: h.worktree });
503
+ }
504
+ onEvent({ type: "checkpoint", agent: buddy.id, author, task: taskId, title: t.title, pct, notes: notes ?? "LOOKS GOOD", ms: Date.now() - t0, ...(late ? { late } : {}) });
505
+ }
506
+ catch {
507
+ /* a buddy check never breaks a run */
508
+ }
509
+ finally {
510
+ buddyBusy.delete(taskId);
511
+ buddyRuns.delete(taskId);
512
+ try {
513
+ db.publish("buddy.end", { task_id: taskId, agent_id: buddy.id });
514
+ }
515
+ catch {
516
+ /* closed db */
517
+ }
518
+ landed();
519
+ }
520
+ };
521
+ /** Tasks whose author never got to 40% in huddle_progress still get a look mid-task, after buddyAfterMs. */
522
+ const buddyBySilence = () => {
523
+ if (buddyAfterMs === null || !buddyOn)
524
+ return;
525
+ const now = Date.now();
526
+ for (const h of running.values()) {
527
+ if (!h.child || buddyDone.has(h.taskId) || buddyBusy.has(h.taskId) || now - h.startedAt < buddyAfterMs)
528
+ continue;
529
+ // Reached 40%: that report triggers it. One early "10%" and then quiet still counts as quiet.
530
+ const pct = db.getTask(h.taskId)?.progress_pct ?? 0;
531
+ if (pct >= BUDDY_AT[0])
532
+ continue;
533
+ void buddyCheck(h.taskId, pct, now - h.startedAt).catch(() => { });
534
+ }
535
+ };
536
+ /** Resolves with the author's "no: …" reason, or null when the window closes (or it says yes, or stops mattering). */
537
+ const waitForNo = (author, helper, since, ms, still) => new Promise((resolve) => {
538
+ let over = false;
539
+ const end = (v) => {
540
+ if (over)
541
+ return;
542
+ over = true;
543
+ clearTimeout(timer);
544
+ clearInterval(poll);
545
+ unsub();
546
+ resolve(v);
547
+ };
548
+ const look = () => {
549
+ if (over)
550
+ return;
551
+ for (const m of db.inbox("huddle", { peek: true })) {
552
+ if (m.from_agent !== author || m.created_at < since)
553
+ continue;
554
+ db.markRead(m.id);
555
+ const body = m.body.replace(/[*`_]/g, "").trim();
556
+ if (/^(yes|y|ok|okay|go|sure|fine|sounds good|go ahead)\b/i.test(body))
557
+ return end(null);
558
+ // Anything else the author sends Huddle in the window is an objection: it was asked for a no, not a chat.
559
+ return end(body.replace(/^(no|nope|nah|don't|do not)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
560
+ }
561
+ // Models mix up recipients: a "no" sent to the helper itself counts too (left unread for the helper).
562
+ for (const m of db.inbox(helper, { peek: true })) {
563
+ if (m.from_agent !== author || m.created_at < since)
564
+ continue;
565
+ const body = m.body.replace(/[*`_]/g, "").trim();
566
+ if (/^(no|nope|nah)\b/i.test(body))
567
+ return end(body.replace(/^(no|nope|nah)\b[\s:,.!—-]*/i, "").trim() || "no reason given");
568
+ }
569
+ };
570
+ const unsub = db.onEvent((e) => {
571
+ if (e.type !== "agent.message")
572
+ return;
573
+ try {
574
+ look();
575
+ }
576
+ catch {
577
+ /* a read that fails is just no reply yet */
578
+ }
579
+ });
580
+ const timer = setTimeout(() => end(null), ms);
581
+ const poll = setInterval(() => {
582
+ try {
583
+ if (!still() || resolved || signal?.aborted)
584
+ end(null);
585
+ }
586
+ catch {
587
+ end(null);
588
+ }
589
+ }, 500);
590
+ timer.unref?.();
591
+ poll.unref?.();
592
+ look();
593
+ });
594
+ /**
595
+ * Pairing: a free agent proposes one separable part of the task furthest
596
+ * behind (or, if nothing is cleanly separable, its tests); the author is
597
+ * told and has pairWaitMs to say no; then the part goes on the board for
598
+ * the helper. Merge order makes landing safe: a code part lands before the
599
+ * parent (the parent is written to use it, so its tests need it on main);
600
+ * a tests part lands after the parent (its tests run against the parent's
601
+ * code at the gate, so they verify something). Either way they're
602
+ * disjoint files, so neither order conflicts; it's about green gates.
603
+ */
604
+ const pairUp = async (helper, author, h, t) => {
605
+ paired.add(t.id);
606
+ pairing.add(helper.id);
607
+ // Found live (round 4): 0 pairs, and nothing said whether one was ever tried. A dropped pairing says why.
608
+ const dropped = (reason) => {
609
+ try {
610
+ db.publish("pair.dropped", { task_id: t.id, agent_id: helper.id, payload: { with: author, title: t.title, reason } });
611
+ }
612
+ catch {
613
+ /* closed db */
614
+ }
615
+ };
616
+ try {
617
+ const still = () => running.get(author)?.taskId === t.id && (db.getTask(t.id)?.progress_pct ?? 0) < OFFER_MAX_PCT && !resolved && !signal?.aborted;
618
+ const wip = wipDiff(repoRoot, h.worktree, h.excludes, 12_000);
619
+ const files = JSON.parse(t.files ?? "[]");
620
+ let part = null;
621
+ if (helper.adapter?.ask) {
622
+ // The run waits for a pairing, so its ask ends as soon as the pairing stops mattering (the author finished, say).
623
+ const stop = new AbortController();
624
+ const off = () => stop.abort();
625
+ sideAsks.signal.addEventListener("abort", off);
626
+ const watch = setInterval(() => {
627
+ try {
628
+ if (!still())
629
+ stop.abort();
630
+ }
631
+ catch {
632
+ stop.abort();
633
+ }
634
+ }, 500);
635
+ watch.unref?.();
636
+ 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(() => {
637
+ clearInterval(watch);
638
+ sideAsks.signal.removeEventListener("abort", off);
639
+ });
640
+ if (res.ok && !stop.signal.aborted)
641
+ part = parsePart(res.text, wip.files, files);
642
+ }
643
+ part ??= testsPart(t.title, t.spec);
644
+ if (!still())
645
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished while ${helper.id} was proposing a part`);
646
+ const note = notifyAgent(db, author, pairNote(helper.id, part, pairWaitMs), "huddle", t.id);
647
+ const no = await waitForNo(author, helper.id, note.created_at, pairWaitMs, still);
648
+ if (no !== null) {
649
+ db.remember({ kind: "fact", body: `${author} kept "${t.title}" whole (declined ${helper.id} taking “${part.title}”): ${no}`.slice(0, 600), author: "huddle" });
650
+ return dropped(`${author} said no: ${no}`.slice(0, 300));
651
+ }
652
+ const parent = db.getTask(t.id);
653
+ if (!parent || !still() || !["claimed", "in_progress"].includes(parent.status))
654
+ return dropped(`${author} got past ${OFFER_MAX_PCT}% or finished during the no-window`);
655
+ const pct = parent.progress_pct;
656
+ const secs = Math.round((Date.now() - h.startedAt) / 1000);
657
+ 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"}. ` +
658
+ `Match their names exactly; unsure? huddle_ask(…, to="${author}").` +
659
+ (wip.diff ? `\n\nTHEIR WORK SO FAR (when you were paired):\n${cap(wip.diff, 6000)}` : "");
660
+ const prefers = [`@${helper.id}`, ...JSON.parse(parent.prefers)];
661
+ const { task: pt } = addSplitPart(db, parent, author, { title: part.title, spec, files: part.files, prefers });
662
+ if (!pt)
663
+ return;
664
+ if (part.tests) {
665
+ holdFor.set(pt.id, parent.id);
666
+ testsOf.add(pt.id);
667
+ }
668
+ else
669
+ holdFor.set(parent.id, pt.id);
670
+ onEvent({
671
+ type: "pair",
672
+ agent: helper.id,
673
+ with: author,
674
+ task: parent.id,
675
+ title: parent.title,
676
+ part: part.tests ? `tests: ${pt.title}` : `${pt.title} (${part.files.join(", ")})`,
677
+ reason: `${parent.title} at ${pct}% after ${secs}s; ${helper.id} was free`,
678
+ });
679
+ }
680
+ catch {
681
+ /* pairing never breaks a run */
682
+ }
683
+ finally {
684
+ pairing.delete(helper.id);
685
+ if (!resolved)
686
+ fill(); // the part (if any) goes to the helper now
687
+ }
688
+ };
689
+ /**
690
+ * Every tick: a free agent with nothing to do pairs up with the task
691
+ * furthest behind. A helper that can't be asked (no read-only mode) only
692
+ * sends the old help offer, which leaves the split to the busy agent.
693
+ */
694
+ const offerHelp = () => {
695
+ if (helpAfterMs === null || signal?.aborted || conflicts.length || rescues.length)
696
+ return;
697
+ const helper = workers.find((x) => x.adapter && !running.has(x.id) && !reserved.has(x.id) && !pairing.has(x.id) && !db.nextBacklogTask(prefersOf(x)));
698
+ if (!helper)
699
+ return;
700
+ if (helper.adapter.ask) {
701
+ const now = Date.now();
702
+ const left = (t, h) => ((now - h.startedAt) * (100 - t.progress_pct)) / Math.max(t.progress_pct, 5);
703
+ // A task that never called huddle_progress isn't at 0%: it has no number (see pairEligible).
704
+ const reported = new Set(taskViews(db, now).filter((v) => v.reported).map((v) => v.id));
705
+ const best = [...running]
706
+ .map(([id, h]) => ({ id, h, t: db.getTask(h.taskId) }))
707
+ .filter((c) => !!c.t &&
708
+ !!c.h.child &&
709
+ c.id !== helper.id &&
710
+ c.t.claimed_by === c.id &&
711
+ ["claimed", "in_progress"].includes(c.t.status) &&
712
+ !paired.has(c.t.id) &&
713
+ !resolving.has(c.t.id) &&
714
+ !testFixed.has(c.t.id) &&
715
+ !reviewed.has(c.t.id) &&
716
+ !splitFrom.has(c.t.id) && // a part isn't split again
717
+ !isSpecialTask(c.t) &&
718
+ now - c.h.startedAt >= helpAfterMs &&
719
+ pairEligible({
720
+ reported: reported.has(c.t.id),
721
+ pct: c.t.progress_pct,
722
+ ranMs: now - c.h.startedAt,
723
+ helpAfterMs,
724
+ planned: JSON.parse(c.t.files ?? "[]").length,
725
+ touched: () => wipDiff(repoRoot, c.h.worktree, c.h.excludes, 0).files.length,
726
+ }))
727
+ .sort((a, b) => left(b.t, b.h) - left(a.t, a.h))[0];
728
+ if (best)
729
+ void pairUp(helper, best.id, best.h, best.t);
730
+ return;
731
+ }
732
+ for (const [id, h] of running) {
733
+ if (id === helper.id || offered.has(h.taskId) || resolving.has(h.taskId) || Date.now() - h.startedAt < helpAfterMs)
734
+ continue;
735
+ const t = db.getTask(h.taskId);
736
+ if (!t || t.claimed_by !== id)
737
+ continue;
738
+ // Nearly done (its own huddle_progress says so): nothing left worth
739
+ // splitting. Found live (capstone round 2): offers landed on tasks at
740
+ // 70-100%, every one was ignored, and they were most of the inbox traffic.
741
+ if ((t.progress_pct ?? 0) >= OFFER_MAX_PCT)
742
+ continue;
743
+ offered.add(t.id);
744
+ notifyAgent(db, id, helpOffer(helper.id, t.id, t.title), helper.id, t.id);
745
+ onEvent({ type: "offer", agent: helper.id, to: id, task: t.id, title: t.title });
746
+ }
747
+ };
748
+ {
749
+ const unwatch = db.onEvent((e) => {
750
+ if (resolved)
751
+ return;
752
+ if (e.type === "plug.call" && e.agent_id && running.has(e.agent_id))
753
+ lastSeen.set(e.agent_id, Date.now());
754
+ else if (e.type === "task.progress" && e.task_id) {
755
+ const by = db.getTask(e.task_id)?.claimed_by;
756
+ if (by && running.has(by))
757
+ lastSeen.set(by, Date.now());
758
+ let pct = 0;
759
+ try {
760
+ pct = Number(JSON.parse(e.payload || "{}").pct) || 0;
761
+ }
762
+ catch {
763
+ /* keep 0 */
764
+ }
765
+ const id = e.task_id;
766
+ if (pct >= BUDDY_AT[0])
767
+ setImmediate(() => void buddyCheck(id, pct).catch(() => { })); // never an unhandled rejection
768
+ }
769
+ else if (e.type === "task.split" && e.task_id) {
770
+ let p = {};
771
+ try {
772
+ p = JSON.parse(e.payload || "{}");
773
+ }
774
+ catch {
775
+ /* keep {} */
776
+ }
777
+ splitFrom.set(e.task_id, { from: String(p.from ?? e.agent_id ?? ""), parentTitle: String(p.parent_title ?? "") });
778
+ setImmediate(() => !resolved && fill()); // a free agent may take it right away
779
+ }
780
+ else if (e.type === "note.closed" && e.task_id) {
781
+ // A note that needed an answer, settled at huddle_done (server.ts) or as its task finished.
782
+ let p = {};
783
+ try {
784
+ p = JSON.parse(e.payload || "{}");
785
+ }
786
+ catch {
787
+ /* keep {} */
788
+ }
789
+ const how = p.how === "replied" || p.how === "fixed" ? p.how : "ignored";
790
+ 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 ?? "") });
791
+ }
792
+ else if (e.type === "contract.amended") {
793
+ let p = {};
794
+ try {
795
+ p = JSON.parse(e.payload || "{}");
796
+ }
797
+ catch {
798
+ /* keep {} */
799
+ }
800
+ onEvent({ type: "contract", agent: String(p.by ?? e.agent_id ?? ""), version: Number(p.version) || 0, section: String(p.section ?? ""), change: String(p.change ?? "") });
801
+ }
802
+ });
803
+ const tick = setInterval(() => {
804
+ watchSilence();
805
+ offerHelp();
806
+ buddyBySilence();
807
+ }, Math.max(50, Math.min(10_000, Math.floor(silenceMs / 4))));
808
+ tick.unref();
809
+ stopWatch = () => {
810
+ clearInterval(tick);
811
+ unwatch();
812
+ };
813
+ // Integration radar: compare in-flight agents' uncommitted work for
814
+ // conflicts and name mismatches while they can still fix them (radar.ts).
815
+ // Local agents only — a teammate's work lives on their machine.
816
+ if (opts.radarMs !== null)
817
+ stopRadar = startRadar({
818
+ db,
819
+ repoRoot,
820
+ intervalMs: opts.radarMs,
821
+ inFlight: () => [...running.values()]
822
+ // Still working: an exited agent's worktree is being committed and rebased by finish().
823
+ .filter((h) => h.child && h.child.exitCode === null && h.child.signalCode === null && !resolving.has(h.taskId))
824
+ .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 })),
825
+ // A finding is a note its reader must answer (fix it, or say why not) before huddle_done.
826
+ notify: (to, message, key) => {
827
+ const h = running.get(to);
828
+ const m = notifyAgent(db, to, message, "huddle", h?.taskId ?? null);
829
+ if (h)
830
+ trackNote(db, m, { by: "huddle", kind: "radar", worktree: h.worktree, key });
831
+ },
832
+ // The radar no longer sees it: whoever fixed it, every note about it is answered.
833
+ resolved: (key) => resolveNotes(db, key),
834
+ contractNames: () => contractNames(getContract(db)),
835
+ onEvent,
836
+ });
837
+ }
838
+ /** Notes that came back after the author exited: one more round to answer them, before anything merges. */
839
+ 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. ` +
840
+ `Address each one: fix it, or reply huddle_send(to="<who>", reply_to="<id>", message="won't fix: <why>"). Then huddle_done.\n` +
841
+ notes.map((n) => `- [id ${n.id}, from ${n.kind === "radar" ? "huddle" : n.by}] ${cap(n.body, 800)}`).join("\n");
842
+ const finish = async (w, h, exitCode, why) => {
843
+ const workMs = Date.now() - h.startedAt; // agent time, not test/merge/approval time
844
+ running.delete(w.id);
845
+ db.releaseClaims(w.id);
846
+ db.setAgentStatus(w.id, "idle");
847
+ const task = db.getTask(h.taskId);
848
+ const quiet = silenced.get(task.id);
849
+ silenced.delete(task.id);
850
+ // A buddy check still being written is about THIS work. Found live (capstone
851
+ // round 4): 3 of 4 buddy notes came back after the author finished, and
852
+ // reached its next task instead. Hold the author (and its merge) for it, bounded.
853
+ const check = buddyRuns.get(task.id);
854
+ if (check && exitCode === 0 && !signal?.aborted) {
855
+ reserved.add(w.id);
856
+ pending++; // the run isn't over while this waits
857
+ let timer;
858
+ await Promise.race([check.landed, new Promise((r) => (timer = setTimeout(r, Math.max(0, check.at + buddyWaitMs - Date.now()))))]);
859
+ clearTimeout(timer);
860
+ pending--;
861
+ reserved.delete(w.id);
862
+ }
863
+ if (signal?.aborted) {
864
+ fail(w, h, task.title, "stopped by you", workMs);
865
+ }
866
+ else if (exitCode !== 0) {
867
+ failOrRescue(w, h, task, quiet ?? why ?? `agent exited with code ${exitCode}`, workMs);
868
+ }
869
+ else {
870
+ // Notes it never saw (they came back after it exited). One it saw and didn't answer is just ignored.
871
+ const late = w.adapter ? openNotes(db, task.id).filter((n) => n.to === w.id && !db.getMessage(n.id)?.read_at) : [];
872
+ if (late.length && !followedUp.has(task.id)) {
873
+ followedUp.add(task.id);
874
+ for (const n of late)
875
+ db.markRead(n.id, "prompt"); // delivered in the brief, not the inbox
876
+ carry.set(task.id, totals(task.id, h, workMs));
877
+ 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" });
878
+ start(w, task, followUpBrief(late));
879
+ return fill();
880
+ }
881
+ closeNotes(db, task.id); // each note: addressed or ignored (the note.closed watcher reports it)
882
+ db.setTaskStatus(task.id, "review");
883
+ // A paired task lands in a safe order (see pairUp): wait for its partner first.
884
+ const first = holdFor.get(task.id);
885
+ if (first && !settled(first)) {
886
+ held.push({ w, h, task, workMs, after: first });
887
+ pending++;
888
+ return fill();
889
+ }
890
+ return mergeTask(w, h, task, workMs);
891
+ }
892
+ fill();
893
+ };
894
+ /** Test gate → cross-review → your approval → land. Re-entered after a conflict is resolved. */
895
+ const mergeTask = async (w, h, task, workMs) => {
896
+ const wt = { branch: h.branch, path: h.worktree };
897
+ const prep = prepareMerge(repoRoot, wt, testCmd(wt.path), h.excludes, `huddle(${w.id}): ${task.title}`);
898
+ if (!prep.ok) {
899
+ // Another agent's change landed on the same lines: hand it to a resolver, never drop it.
900
+ if (prep.conflict)
901
+ resolveConflict(w, h, task.id, workMs);
902
+ else if (prep.testOutput !== undefined && !testFixed.has(task.id) && !w.remote && !resolving.has(task.id) && !signal?.aborted) {
903
+ // Red test gate: one fix round for the author, in the same worktree,
904
+ // with the failing output in hand (found live: an agent finished with
905
+ // a test it hadn't been told it could fix). A second red gate fails it.
906
+ testFixed.add(task.id);
907
+ carry.set(task.id, totals(task.id, h, workMs));
908
+ db.remember({ kind: "gotcha", body: `"${task.title}" (${w.id}) hit a red test gate; one fix round`, author: "huddle" });
909
+ start(w, task, `\n\nHuddle's merge gate ran the full test suite (\`${testCmd(wt.path)}\`) on your work and it FAILED — nothing was merged. ` +
910
+ `Make the whole suite pass, including tests you didn't write; if that needs a file outside your planned list, ` +
911
+ `huddle_announce_plan it and edit it. Then huddle_done. Test output (tail):\n${prep.testOutput || "(no output)"}`);
912
+ }
913
+ else if (prep.testOutput !== undefined)
914
+ failOrRescue(w, h, task, prep.reason, workMs, prep.testOutput);
915
+ else
916
+ fail(w, h, task.title, prep.reason, workMs);
917
+ return fill();
918
+ }
919
+ // Cross-review by an otherwise-idle agent: one round, never blocks on its own.
920
+ const reviewer = reviewed.has(task.id) ? undefined : idleReviewer(w.id);
921
+ let skippedReview = false;
922
+ let shallowReview = false;
923
+ if (reviewer) {
924
+ reviewed.add(task.id);
925
+ reviewsBy.set(reviewer.id, (reviewsBy.get(reviewer.id) ?? 0) + 1);
926
+ reserved.add(w.id);
927
+ reserved.add(reviewer.id);
928
+ pending++;
929
+ db.setAgentStatus(reviewer.id, "working");
930
+ onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
931
+ fill(); // everyone else keeps going
932
+ const t0 = Date.now();
933
+ const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
934
+ signal,
935
+ timeoutMs: 90_000,
936
+ noTools: true, // the diff is in the prompt — no need to explore
937
+ quick: true, // 90s box: an adapter may use its fast model
938
+ });
939
+ const verdict = res.ok ? parseVerdict(res.text) : { approve: true, skipped: true, notes: "timed out" };
940
+ reserved.delete(reviewer.id);
941
+ db.setAgentStatus(reviewer.id, "idle");
942
+ pending--;
943
+ skippedReview = !!verdict.skipped;
944
+ shallowReview = !!verdict.shallow;
945
+ onEvent({ type: "reviewed", agent: reviewer.id, author: w.id, task: task.id, title: task.title, ...verdict, ms: Date.now() - t0 });
946
+ if (!verdict.approve && !signal?.aborted) {
947
+ db.remember({ kind: "gotcha", body: `Review of "${task.title}" by ${reviewer.id}: ${verdict.notes}`, author: reviewer.id });
948
+ carry.set(task.id, totals(task.id, h, workMs));
949
+ reserved.delete(w.id);
950
+ // The author fixes it in the same worktree, with the review in hand.
951
+ start(w, task, `\n\nA reviewer (${reviewer.id}) asked for these changes before merge — fix them:\n${verdict.notes}`);
952
+ return fill();
953
+ }
954
+ reserved.delete(w.id);
955
+ }
956
+ let approved = true;
957
+ if (opts.approveMerge) {
958
+ pending++;
959
+ fill(); // keep the other agents busy while you decide
960
+ approved = await opts.approveMerge({ agent: w.id, title: task.title, diffstat: prep.diffstat });
961
+ pending--;
962
+ }
963
+ if (!approved)
964
+ fail(w, h, task.title, `you declined — work kept on branch ${h.branch}`, workMs);
965
+ else {
966
+ const base = headSha(repoRoot); // main before this lands — the handoff's diff base
967
+ let land = landMerge(repoRoot, wt);
968
+ if (!land.merged) {
969
+ // main moved while this waited (on review, or on you): rebase + re-test once, then land.
970
+ const again = prepareMerge(repoRoot, wt, testCmd(wt.path), h.excludes, `huddle(${w.id}): ${task.title}`);
971
+ if (again.conflict) {
972
+ resolveConflict(w, h, task.id, workMs);
973
+ return fill();
974
+ }
975
+ land = again.ok ? landMerge(repoRoot, wt) : { merged: false, reason: again.reason };
976
+ }
977
+ if (land.merged) {
978
+ db.setProgress(task.id, 100);
979
+ db.setTaskStatus(task.id, "merged");
980
+ removeWorktree(repoRoot, h.worktree);
981
+ if (w.remote)
982
+ deleteRemoteBranch(repoRoot, h.branch); // delivered branch is merged now
983
+ merged++;
984
+ // What dependents will be told: files, exported signatures, the author's notes.
985
+ recordHandoff(db, repoRoot, { taskId: task.id, agentId: w.id, title: task.title, base, since: firstStart.get(task.id) ?? h.startedAt });
986
+ const resolvedBy = resolving.get(task.id)?.by.at(-1);
987
+ const reason = prep.reason +
988
+ (reviewer && !skippedReview ? ` · reviewed by ${reviewer.id}${shallowReview ? " (shallow approve)" : ""}` : "") +
989
+ (resolvedBy ? ` · conflict resolved by ${resolvedBy}` : "");
990
+ onEvent({ type: "merged", agent: w.id, task: task.id, title: task.title, reason, ...totals(task.id, h, workMs) });
991
+ }
992
+ else
993
+ fail(w, h, task.title, land.reason, workMs);
994
+ }
995
+ fill();
996
+ };
997
+ /** Hand a task to a teammate's agent; their machine does the work, a branch comes back. */
998
+ const startRemote = async (w, task, extra) => {
999
+ const { branch, path: wtPath } = worktreeFor(repoRoot, w.id, task.id);
1000
+ const h = {
1001
+ agentId: w.id,
1002
+ taskId: task.id,
1003
+ worktree: wtPath,
1004
+ branch,
1005
+ cmd: [],
1006
+ logPath: `(on ${w.remote.member}'s machine)`,
1007
+ excludes: [],
1008
+ startedAt: Date.now(),
1009
+ meter: new UsageMeter(),
1010
+ };
1011
+ db.setTaskStatus(task.id, "in_progress");
1012
+ db.setAgentStatus(w.id, "working");
1013
+ running.set(w.id, h);
1014
+ onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra, remote: w.remote.member });
1015
+ const base = opts.publish?.();
1016
+ if (!base)
1017
+ return void finish(w, h, 1, "can't delegate: main isn't pushed to the shared remote (turn on `sync`)");
1018
+ const accepted = await opts.bridge.assign(w.remote, { ...task, spec: task.spec + extra }, base, branch);
1019
+ if (!accepted) {
1020
+ running.delete(w.id);
1021
+ db.setAgentStatus(w.id, "idle");
1022
+ db.releaseTask(task.id);
1023
+ unavailable.add(w.id);
1024
+ onEvent({ type: "declined", agent: w.id, task: task.id, title: task.title });
1025
+ return fill();
1026
+ }
1027
+ const d = await opts.bridge.delivered(task.id);
1028
+ h.meter.add({ tokens: d.tokens, cached: d.cached, total: true });
1029
+ if (d.ms)
1030
+ h.startedAt = Date.now() - d.ms;
1031
+ if (!d.ok)
1032
+ return void finish(w, h, 1, d.reason ?? "teammate's agent failed");
1033
+ const f = fetchBranch(repoRoot, branch);
1034
+ if (!f.ok)
1035
+ return void finish(w, h, 1, `couldn't fetch ${branch}: ${f.note}`);
1036
+ createWorktree(repoRoot, w.id, task.id); // checks out the delivered branch
1037
+ void finish(w, h, 0);
1038
+ };
1039
+ const start = (w, task, extra = "", takeoverFrom) => {
1040
+ const first = !firstStart.has(task.id);
1041
+ if (first)
1042
+ firstStart.set(task.id, Date.now());
1043
+ const part = first ? splitFrom.get(task.id) : undefined;
1044
+ if (part && part.from !== w.id) {
1045
+ // "cursor took part of codex's task": a huddle_split part, picked up by a free agent.
1046
+ const reason = `split off “${part.parentTitle}”`;
1047
+ db.publish("task.takeover", { task_id: task.id, agent_id: w.id, payload: { kind: "split", from: part.from, to: w.id, title: task.title, reason } });
1048
+ onEvent({ type: "takeover", kind: "split", agent: w.id, from: part.from, task: task.id, title: task.title, reason, ms: 0, tokens: 0, cached: 0 });
1049
+ }
1050
+ // Upstream handoffs ride in the prompt — every round, since each launch is a fresh process.
1051
+ const ho = handoffsFor(db, task.id);
1052
+ if (first)
1053
+ for (const h of ho.from) {
1054
+ 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 } });
1055
+ onEvent({ type: "handoff", agent: w.id, from: h.agent_id, task: task.id, title: h.title, files: h.files.length, symbols: h.symbols.length });
1056
+ }
1057
+ // The contract as it stands now (amendments included): each launch is a fresh process.
1058
+ const cb = contractBrief(db);
1059
+ const contract = cb ? `\n\n${cb}` : "";
1060
+ if (w.remote)
1061
+ return void startRemote(w, { ...task, spec: task.spec + contract + ho.text }, extra);
1062
+ // Who else is mid-task right now, so this agent knows whom to tell (first round only;
1063
+ // fill() claims a whole wave before launching any of it, so siblings see each other).
1064
+ const along = first && !takeoverFrom ? alongsideBrief(db, task.id, w.id) : "";
1065
+ // Every round of a task runs in its first worktree — a rescuer continues there too.
1066
+ const prior = taskWt.get(task.id);
1067
+ const launched = launchAgent({
1068
+ repoRoot,
1069
+ adapter: w.adapter,
1070
+ agentId: w.id,
1071
+ role: "worker",
1072
+ taskId: task.id,
1073
+ taskSpec: `${task.title}\n\n${task.spec}${contract}${ho.text}${along}${extra}`,
1074
+ roomUrl,
1075
+ roomSecret: opts.roomSecret,
1076
+ approvals: opts.approvals,
1077
+ worktree: prior ? { branch: prior.branch, path: prior.path } : undefined,
1078
+ onTokens: (tokens) => onEvent({ type: "tokens", agent: w.id, task: task.id, tokens }),
1079
+ });
1080
+ // Every agent's Plug wiring that sat in this worktree stays out of the commit.
1081
+ const h = { ...launched, excludes: [...new Set([...(prior?.excludes ?? []), ...launched.excludes])] };
1082
+ taskWt.set(task.id, { branch: h.branch, path: h.worktree, excludes: h.excludes });
1083
+ db.setTaskStatus(task.id, "in_progress");
1084
+ db.setAgentStatus(w.id, "working", h.worktree);
1085
+ running.set(w.id, h);
1086
+ heard(w.id, h);
1087
+ onEvent({ type: "start", agent: w.id, task: task.id, title: task.title, log: h.logPath, fixing: !!extra && !takeoverFrom, ...(takeoverFrom ? { takeover: takeoverFrom } : {}) });
1088
+ // On 'close', capped at 2s after 'exit': found live, an agent's final stdout
1089
+ // lines (its token usage, the log's tail) were lost when finish() ran on 'exit'.
1090
+ onFinished(h.child, (code) => void finish(w, h, code));
1091
+ };
1092
+ const fill = () => {
1093
+ // Paired tasks whose partner has landed (or failed: then nothing to wait for) merge now.
1094
+ // Every ready one is taken out before any merge starts: a merge re-enters fill().
1095
+ const ready = held.filter((x) => signal?.aborted || settled(x.after));
1096
+ if (ready.length) {
1097
+ held.splice(0, held.length, ...held.filter((x) => !ready.includes(x)));
1098
+ let merging = 0;
1099
+ for (const x of ready) {
1100
+ pending--;
1101
+ holdFor.delete(x.task.id);
1102
+ if (signal?.aborted)
1103
+ fail(x.w, x.h, x.task.title, "stopped by you", x.workMs);
1104
+ // Tests for code that never landed would only go red at the gate and burn a fix round.
1105
+ else if (testsOf.has(x.task.id) && db.getTask(x.after)?.status !== "merged")
1106
+ 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);
1107
+ else {
1108
+ merging++;
1109
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1110
+ }
1111
+ }
1112
+ if (merging)
1113
+ return; // each merge fills again when it's done
1114
+ }
1115
+ // Conflicts first: they hold finished work, and the author is usually free.
1116
+ for (const c of conflicts.splice(0)) {
1117
+ if (signal?.aborted) {
1118
+ parkForYou(c, "stopped by you");
1119
+ continue;
1120
+ }
1121
+ const r = pickResolver(c.author, resolving.get(c.taskId)?.by ?? []);
1122
+ if (r)
1123
+ startResolve(r, c);
1124
+ else
1125
+ conflicts.push(c); // everyone's busy: the next agent to finish takes it
1126
+ }
1127
+ // Then takeovers: half-done work beats a fresh task.
1128
+ for (const rc of rescues.splice(0)) {
1129
+ const title = db.getTask(rc.taskId).title;
1130
+ if (signal?.aborted) {
1131
+ fail(rc.from, rc.h, title, `${rc.reason} (stopped before another agent could take it over)`, 0);
1132
+ continue;
1133
+ }
1134
+ const r = pickRescuer(rc);
1135
+ if (r)
1136
+ startRescue(r, rc);
1137
+ else
1138
+ rescues.push(rc); // the next other agent to finish takes it
1139
+ }
1140
+ if (!signal?.aborted) {
1141
+ // Fairness: the free agent that has taken the fewest tasks goes first
1142
+ // (roster order breaks ties), so one agent doesn't take every task that
1143
+ // unblocks just because it's listed first.
1144
+ const free = all().map((w, i) => ({ w, i })).sort((a, b) => (taken.get(a.w.id) ?? 0) - (taken.get(b.w.id) ?? 0) || a.i - b.i);
1145
+ // The whole wave is claimed first and launched after, so every task in
1146
+ // it is told who else is starting alongside it (alongsideBrief).
1147
+ const wave = [];
1148
+ const picked = new Set();
1149
+ // A task tagged "@<agent>" (a QA bug's fix, routed to whoever wrote
1150
+ // those files) goes to that agent first when it's free; if it's busy
1151
+ // or absent, the loop below lets any free agent take it.
1152
+ for (const { w } of free) {
1153
+ if (running.has(w.id) || reserved.has(w.id) || unavailable.has(w.id) || (w.remote && !opts.bridge))
1154
+ continue;
1155
+ const mine = db.nextBacklogTask([`@${w.id}`]);
1156
+ if (!mine || !JSON.parse(mine.prefers).includes(`@${w.id}`))
1157
+ continue;
1158
+ register(w);
1159
+ if (!db.claimTask(mine.id, w.id).ok)
1160
+ continue;
1161
+ taken.set(w.id, (taken.get(w.id) ?? 0) + 1);
1162
+ picked.add(w.id);
1163
+ wave.push({ w, task: mine });
1164
+ }
1165
+ for (const { w } of free) {
1166
+ if (picked.has(w.id) || running.has(w.id) || reserved.has(w.id) || unavailable.has(w.id))
1167
+ continue;
1168
+ if (w.remote && !opts.bridge)
1169
+ continue;
1170
+ register(w);
1171
+ const next = db.nextBacklogTask(prefersOf(w));
1172
+ if (!next)
1173
+ continue;
1174
+ if (!db.claimTask(next.id, w.id).ok)
1175
+ continue;
1176
+ taken.set(w.id, (taken.get(w.id) ?? 0) + 1);
1177
+ picked.add(w.id);
1178
+ wave.push({ w, task: next });
1179
+ }
1180
+ for (const { w, task } of wave)
1181
+ start(w, task);
1182
+ }
1183
+ offerHelp();
1184
+ // Nobody is working and only paired merges wait: their partner can't land now, so don't wait for it.
1185
+ if (running.size === 0 && held.length && pending === held.length && !pairing.size) {
1186
+ const x = held.shift();
1187
+ pending--;
1188
+ holdFor.delete(x.task.id);
1189
+ void mergeTask(x.w, x.h, x.task, x.workMs);
1190
+ return;
1191
+ }
1192
+ if (running.size === 0 && pending === 0 && !pairing.size) {
1193
+ // Nobody is running yet a conflict still waits: no agent on this machine can resolve it.
1194
+ for (const c of conflicts.splice(0))
1195
+ parkForYou(c, "no agent on this machine could take the resolution");
1196
+ for (const rc of rescues.splice(0))
1197
+ fail(rc.from, rc.h, db.getTask(rc.taskId).title, `${rc.reason} (no other agent could take it over)`, 0);
1198
+ const blocked = db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
1199
+ if (blocked.length && !signal?.aborted)
1200
+ onEvent({ type: "stuck", blocked });
1201
+ done();
1202
+ }
1203
+ };
1204
+ fill();
1205
+ });
1206
+ }
1207
+ //# sourceMappingURL=scheduler.js.map