huddle-cli 0.1.1 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/QUICKSTART.md +63 -2
- package/README.md +15 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +775 -0
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +433 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +46 -4
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +42 -3
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +11 -5
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +32 -5
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +105 -19
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +82 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +511 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/undo.js +131 -0
- package/dist/ui/watch.js +4 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
package/dist/ui/app.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Everything happens here. Claude, Codex, Gemini and Cursor run in the
|
|
12
12
|
// background as Huddle's workers; you watch the board, never their terminals.
|
|
13
|
+
import { issueGoal, issueMentions, issueRef, runAsync } from "../connectors/github.js";
|
|
14
|
+
import { connectorLine, startHub, statusLine, toolsNote } from "../connectors/hub.js";
|
|
13
15
|
import fs from "node:fs";
|
|
14
16
|
import os from "node:os";
|
|
15
17
|
import path from "node:path";
|
|
@@ -30,15 +32,21 @@ import { pickAsker } from "../orchestrator/ask.js";
|
|
|
30
32
|
import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
|
|
31
33
|
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
32
34
|
import { runBoard } from "../orchestrator/scheduler.js";
|
|
33
|
-
import { runQa, shouldQa } from "../orchestrator/qa.js";
|
|
35
|
+
import { QA_ROUNDS, runQa, shouldQa } from "../orchestrator/qa.js";
|
|
36
|
+
import { Budget, HARD_AT, SOFT_AT, budgetFlag, budgetNotes, budgetUse, costPreview, parseTokens, runHistory } from "../orchestrator/budget.js";
|
|
34
37
|
import { commitFile, isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
35
38
|
import { C, Dock, LiveLines, box, dot, fit, lineWidth, pad, truncate, width, wrap } from "./term.js";
|
|
36
39
|
import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
|
|
37
40
|
import { logoLines } from "./logo.js";
|
|
38
|
-
import { Dashboard } from "./dashboard.js";
|
|
41
|
+
import { Dashboard, estHint, progressLine, taskCounts, taskMeter } from "./dashboard.js";
|
|
39
42
|
import { learnFromRun } from "../orchestrator/learn.js";
|
|
43
|
+
import { briefLines, liftPointers, placePointers, writeBrief } from "./brief.js";
|
|
40
44
|
import { PasteCatcher, addPaths, asDrop, bracketedPaste, ctxCommand, flatten, interceptStdin, isLongPaste, looksLikePaths, pasteToScreen, screenEvent } from "./paste.js";
|
|
41
45
|
import { splitPathTokens } from "../room/context.js";
|
|
46
|
+
import { narration, narratorLine } from "./narrator.js";
|
|
47
|
+
import { clearCommands, openApp, saveReport, takeCommands, writeLive } from "../app/live.js";
|
|
48
|
+
import { KeyLine, Talk, addressee, questionLines, settledLine } from "./talk.js";
|
|
49
|
+
import { sayToAgents, USER_ID } from "../room/user.js";
|
|
42
50
|
// 0 = pick a free port per run, so sessions never collide.
|
|
43
51
|
export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
|
|
44
52
|
// ---- paths & repo ----------------------------------------------------------
|
|
@@ -115,7 +123,7 @@ export function readPrefs() {
|
|
|
115
123
|
return {};
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
|
-
function writePrefs(p) {
|
|
126
|
+
export function writePrefs(p) {
|
|
119
127
|
fs.mkdirSync(path.dirname(prefsFile()), { recursive: true });
|
|
120
128
|
fs.writeFileSync(prefsFile(), JSON.stringify(p, null, 2));
|
|
121
129
|
}
|
|
@@ -321,9 +329,12 @@ export async function offerSetup(rl, results, force = false) {
|
|
|
321
329
|
}
|
|
322
330
|
/**
|
|
323
331
|
* The live status block under the run's output, one line per agent:
|
|
324
|
-
* ● claude 60% Build the CLI adapter
|
|
325
|
-
*
|
|
326
|
-
*
|
|
332
|
+
* ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
|
|
333
|
+
* ● codex ······ — Build the HTTP API · no report yet · 3m20s
|
|
334
|
+
* ○ cursor idle · 2 merged
|
|
335
|
+
* The bar is the agent's own last huddle_progress %, never a guess. Every line
|
|
336
|
+
* fits `W` cells: the bar goes first on a narrow screen, then the title gives
|
|
337
|
+
* way, never the numbers.
|
|
327
338
|
*/
|
|
328
339
|
export function statusLines(stats, W, now = Date.now()) {
|
|
329
340
|
const idW = Math.min(12, Math.max(6, ...[...stats.agents.keys()].map((id) => id.length)));
|
|
@@ -333,15 +344,33 @@ export function statusLines(stats, W, now = Date.now()) {
|
|
|
333
344
|
const done = a.tasks + a.reviews + (a.qa ?? 0);
|
|
334
345
|
return head + C.faint(done ? `idle · ${a.tasks} merged${a.reviews ? `, ${a.reviews} reviewed` : ""}` : "idle");
|
|
335
346
|
}
|
|
336
|
-
const pct = a.pct === undefined ? C.faint(" · ") : C.violet(`${String(a.pct).padStart(3)}%`);
|
|
337
347
|
const quiet = a.noteAt === undefined ? now - a.busySince : now - a.noteAt;
|
|
338
|
-
const said = a.noteAt === undefined ? "no
|
|
339
|
-
const
|
|
348
|
+
const said = a.noteAt === undefined ? "no report yet" : `note ${fmtDuration(now - a.noteAt)} ago`;
|
|
349
|
+
const est = estHint(a.est, now - a.busySince);
|
|
350
|
+
const meter = taskMeter(a.pct ?? null, W >= 100 ? 8 : W >= 70 ? 6 : 0);
|
|
351
|
+
const base = (quiet > 120_000 ? C.yellow(` · ${said}`) : C.faint(` · ${said}`)) +
|
|
340
352
|
C.faint(` · ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? ` · ${fmtTokens(a.liveTokens)} tok` : ""}`);
|
|
341
|
-
|
|
342
|
-
|
|
353
|
+
// The estimate is a hint: it goes before the title would have to drop under 8 cells.
|
|
354
|
+
const withEst = est ? base + C.faint(" · ") + est : base;
|
|
355
|
+
const tail = W - width(head) - width(meter) - 1 - width(withEst) >= 8 ? withEst : base;
|
|
356
|
+
const room = Math.max(8, W - width(head) - width(meter) - 1 - width(tail));
|
|
357
|
+
return truncate(`${head}${meter} ${pad(truncate(a.doing ?? "working", room), Math.min(room, 34))}${tail}`, W);
|
|
343
358
|
});
|
|
344
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* The pinned block during a run: the overall bar, then (once agents are on tasks) a status line each.
|
|
362
|
+
* BUILD ███████░░░░░ 6/9 tasks · 12m03s
|
|
363
|
+
* ● claude ████░░ 60% Build the CLI adapter · note 20s ago · 1m12s
|
|
364
|
+
*/
|
|
365
|
+
export function runLines(stats, W, now = Date.now()) {
|
|
366
|
+
const r = stats.run;
|
|
367
|
+
const top = r ? ` ${progressLine(r, now - stats.startedAt, W - 2)}` : C.faint(` ⏱ ${fmtDuration(now - stats.startedAt)}`);
|
|
368
|
+
return [top, ...(!r || r.phase === "BUILD" || r.phase === "QA" ? statusLines(stats, W, now) : [])];
|
|
369
|
+
}
|
|
370
|
+
/** The pinned block with the narrator on top: what's happening now in plain words, and whether you're needed. */
|
|
371
|
+
export function liveLines(stats, W, now = Date.now()) {
|
|
372
|
+
return [narratorLine(stats, W, now), ...runLines(stats, W, now)];
|
|
373
|
+
}
|
|
345
374
|
export function newStats(workers) {
|
|
346
375
|
return {
|
|
347
376
|
startedAt: Date.now(),
|
|
@@ -441,6 +470,7 @@ async function prepareRepo(print, rl, goal = "") {
|
|
|
441
470
|
print(` ${C.green("✓")} git repo ready`);
|
|
442
471
|
}
|
|
443
472
|
ensureInit();
|
|
473
|
+
liftPointers(repoRoot()); // the brief's pointer is out of CLAUDE.md / AGENTS.md while the team works (ui/brief.ts)
|
|
444
474
|
const dirty = git(["status", "--porcelain"]).out.split(/\r?\n/).filter((l) => l && !l.includes(".huddle/"));
|
|
445
475
|
if (dirty.length) {
|
|
446
476
|
if (!rl) {
|
|
@@ -519,28 +549,55 @@ function phase(print, name, sub = "") {
|
|
|
519
549
|
const rest = sub ? " " + C.faint(lineWidth() ? oneLine(sub, space(head, " ")) : sub) : "";
|
|
520
550
|
print(`\n${head}${rest}`);
|
|
521
551
|
}
|
|
522
|
-
|
|
552
|
+
const plural = (k, one) => `${k} ${k === 1 ? one : one + "s"}`;
|
|
553
|
+
function printPlan(print, goal, plan, workers, verbose = process.env.HUDDLE_VERBOSE === "1", canType = false) {
|
|
523
554
|
const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
|
|
524
555
|
phase(print, "PLAN", `${plan.tasks.length} task(s) · by ${plan.by}${took}${plan.note ? " — " + plan.note : ""}`);
|
|
525
|
-
|
|
556
|
+
// First, in plain words: what this run makes and what using it looks like (the planner's "plain").
|
|
557
|
+
for (const l of plainLines(goal, plan))
|
|
558
|
+
print(l);
|
|
559
|
+
print(C.faint(" who does what:"));
|
|
526
560
|
const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
|
|
527
561
|
const likely = likelyAgents(plan.tasks, workers);
|
|
528
562
|
plan.tasks.forEach((t, i) => {
|
|
529
563
|
const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
|
|
530
564
|
const fit = likely[i];
|
|
531
|
-
|
|
532
|
-
const
|
|
565
|
+
// Whose idea it was is in TEAMWORK and huddle story; here it only pushed the title off the screen.
|
|
566
|
+
const idea = verbose && t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
|
|
567
|
+
const who = fit ? ` ${C.faint("→")} ${dot(fit)} ${fit}` : "";
|
|
533
568
|
const num = ` ${C.faint(String(i + 1).padStart(2) + ".")} `;
|
|
534
569
|
print(`${num}${oneLine(t.title, space(num, after, who, idea))}${after}${who}${idea}`);
|
|
535
570
|
});
|
|
536
571
|
print(C.faint(` ${plan.tasks.length} task(s), ${Math.min(startable, workers.length)} of ${workers.length} agents can start right away`));
|
|
537
|
-
// The council's agreements, which every agent can huddle_recall mid-task.
|
|
572
|
+
// The council's agreements, which every agent can huddle_recall mid-task. For the
|
|
573
|
+
// person watching they're engineering detail: one line, the rest on request.
|
|
574
|
+
const d = plan.council?.decisions?.length ?? 0;
|
|
575
|
+
const r = plan.council?.risks?.length ?? 0;
|
|
576
|
+
if (verbose)
|
|
577
|
+
for (const l of planDetails(plan))
|
|
578
|
+
print(l);
|
|
579
|
+
else if (d || r)
|
|
580
|
+
print(` ${C.violet("◆")} ${C.faint(`the agents agreed ${[d ? plural(d, "technical decision") : "", r ? `${plural(r, "risk")} to watch` : ""].filter(Boolean).join(" and ")} · `)}${canType ? `type ${C.bold("details")}` : C.bold("huddle story")}${C.faint(canType ? " to read them" : " has them")}`);
|
|
581
|
+
}
|
|
582
|
+
/** " What you'll get: …" / " For example: …" — the plan for someone who isn't a programmer. */
|
|
583
|
+
export function plainLines(goal, plan) {
|
|
584
|
+
// No summary from the planner (a template plan, an older brain): the goal as you typed it.
|
|
585
|
+
if (!plan.plain?.what)
|
|
586
|
+
return [` ${C.faint("goal:")} ${oneLine(goal, space(" goal: "))}`];
|
|
587
|
+
const label = (s) => ` ${C.bold(pad(s, 15))} `;
|
|
588
|
+
const out = [`${label("What you'll get")}${oneLine(plan.plain.what, space(label("What you'll get")))}`];
|
|
589
|
+
if (plan.plain?.example)
|
|
590
|
+
out.push(`${label("For example")}${oneLine(plan.plain.example, space(label("For example")))}`);
|
|
591
|
+
return out;
|
|
592
|
+
}
|
|
593
|
+
/** The council's decisions and risks in full, for `details` (and HUDDLE_VERBOSE=1). */
|
|
594
|
+
export function planDetails(plan) {
|
|
595
|
+
const out = [];
|
|
538
596
|
for (const d of plan.council?.decisions ?? [])
|
|
539
|
-
|
|
540
|
-
for (const r of plan.council?.risks ?? [])
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
597
|
+
out.push(` ${C.violet("◆")} ${C.faint("decided:")} ${d}`);
|
|
598
|
+
for (const r of plan.council?.risks ?? [])
|
|
599
|
+
out.push(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${r.body}`);
|
|
600
|
+
return out;
|
|
544
601
|
}
|
|
545
602
|
/** Show a spinner line while a promise settles (TTY only). */
|
|
546
603
|
async function withSpinner(label, p) {
|
|
@@ -556,7 +613,24 @@ async function withSpinner(label, p) {
|
|
|
556
613
|
process.stdout.write("\x1b[1A\x1b[2K"); // remove the spinner line
|
|
557
614
|
}
|
|
558
615
|
}
|
|
616
|
+
/** A run, and the brief's pointers put back however it ended (plan mode, a stop, a crash). */
|
|
617
|
+
/** The current run's typing line and open questions, closed however the run ends (a throw included). */
|
|
618
|
+
let endTalkNow = null;
|
|
619
|
+
let hubNow = null;
|
|
559
620
|
export async function runGoal(goal, opts) {
|
|
621
|
+
try {
|
|
622
|
+
await runGoalOnce(goal, opts);
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
endTalkNow?.();
|
|
626
|
+
endTalkNow = null;
|
|
627
|
+
hubNow?.close();
|
|
628
|
+
hubNow = null;
|
|
629
|
+
if (isGitRepo(process.cwd()))
|
|
630
|
+
placePointers(repoRoot(), { enabled: readPrefs().brief !== false });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function runGoalOnce(goal, opts) {
|
|
560
634
|
// Every line fits the terminal (logs keep everything). In a one-shot run on a
|
|
561
635
|
// terminal, the per-agent status lines sit under the output (the Dock).
|
|
562
636
|
let dock = null;
|
|
@@ -567,6 +641,13 @@ export async function runGoal(goal, opts) {
|
|
|
567
641
|
if (flagIn)
|
|
568
642
|
goal = goal.replace(flagIn[0], " ").replace(/\s+/g, " ").trim();
|
|
569
643
|
const councilFlag = opts.council ?? (flagIn ? !flagIn[2] : undefined);
|
|
644
|
+
// --budget=5M, typed at the prompt too. Past 90% no new work starts; past 120% the agents stop (budget.ts).
|
|
645
|
+
const typedBudget = budgetFlag(goal);
|
|
646
|
+
goal = typedBudget.goal;
|
|
647
|
+
const budgetAsked = opts.budget ?? typedBudget.budget ?? readPrefs().budget;
|
|
648
|
+
const budget = new Budget(budgetAsked === undefined ? null : parseTokens(budgetAsked));
|
|
649
|
+
if (budgetAsked !== undefined && !budget.limit)
|
|
650
|
+
print(C.yellow(` ! --budget=${budgetAsked} isn't a token count (try 5M or 800k) — no budget this run`));
|
|
570
651
|
const workers = workersFrom(opts.results);
|
|
571
652
|
// One-shot `huddle join` (no rl): a failure exits 1, and the hints name CLI commands.
|
|
572
653
|
if (!workers.length && !opts.room?.server.remote.workers().length) {
|
|
@@ -583,9 +664,240 @@ export async function runGoal(goal, opts) {
|
|
|
583
664
|
}
|
|
584
665
|
const root = repoRoot();
|
|
585
666
|
const rl = opts.rl;
|
|
667
|
+
// `huddle build #12`: the GitHub issue, verbatim, is the goal (connectors/github.ts).
|
|
668
|
+
const issueNo = issueRef(goal);
|
|
669
|
+
if (issueNo !== null) {
|
|
670
|
+
const r = await runAsync(["gh", "issue", "view", String(issueNo), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 30_000 });
|
|
671
|
+
if (r.code !== 0) {
|
|
672
|
+
print(C.red(`✗ couldn't read GitHub issue #${issueNo}: `) + ((r.stderr || r.stdout).trim().split("\n")[0] || "gh failed") + C.faint(r.code === 127 ? " (install the GitHub CLI: https://cli.github.com)" : ""));
|
|
673
|
+
if (!opts.rl)
|
|
674
|
+
process.exitCode = 1;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const issue = JSON.parse(r.stdout);
|
|
678
|
+
goal = issueGoal(issue);
|
|
679
|
+
print(` ${C.violet("◆")} ${C.faint("goal from GitHub issue")} #${issue.number}: ${issue.title}`);
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
// "fix #12 and add a test": the goal stays yours, the issue comes along (when gh can read it).
|
|
683
|
+
for (const n of issueMentions(goal)) {
|
|
684
|
+
const r = await runAsync(["gh", "issue", "view", String(n), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 20_000 });
|
|
685
|
+
if (r.code !== 0)
|
|
686
|
+
continue;
|
|
687
|
+
try {
|
|
688
|
+
const issue = JSON.parse(r.stdout);
|
|
689
|
+
goal += `\n\n--- ${issueGoal(issue).replace(/^Resolve /, "")}`;
|
|
690
|
+
print(` ${C.violet("◆")} ${C.faint("with GitHub issue")} #${issue.number}: ${issue.title}`);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* not an issue: leave the goal as typed */
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// GitHub, Jira, … for every agent this run, through the Plug (connectors/hub.ts).
|
|
698
|
+
// Started now, so they connect while the plan is made, and the planner knows what agents can read.
|
|
699
|
+
const hub = startHub({ root });
|
|
700
|
+
hubNow = hub;
|
|
701
|
+
let saidTools = false;
|
|
702
|
+
const outside = async () => {
|
|
703
|
+
await Promise.race([hub.ready, new Promise((r) => setTimeout(r, 30_000))]);
|
|
704
|
+
if (!saidTools && hub.status().length)
|
|
705
|
+
print(C.faint(` connectors: ${hub.status().map(statusLine).join(" · ")}`));
|
|
706
|
+
saidTools = true;
|
|
707
|
+
return toolsNote(hub);
|
|
708
|
+
};
|
|
586
709
|
// Teammates' agents (from `huddle join` on their machines) work alongside yours.
|
|
587
710
|
const bridge = opts.room?.server.remote;
|
|
588
711
|
const remoteNow = () => (bridge?.workers() ?? []).map((r) => ({ id: r.id, remote: r }));
|
|
712
|
+
// The run's live numbers from its first step: the per-agent status lines and the overall bar.
|
|
713
|
+
const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
|
|
714
|
+
let board = null; // this run's Room, once its board is seeded: the bar counts its tasks
|
|
715
|
+
let logged = ""; // the last progress line a log got
|
|
716
|
+
const progress = (p) => {
|
|
717
|
+
let counts = {};
|
|
718
|
+
try {
|
|
719
|
+
if (board)
|
|
720
|
+
counts = taskCounts(board.listTasks());
|
|
721
|
+
}
|
|
722
|
+
catch {
|
|
723
|
+
/* a closed db: keep the last counts */
|
|
724
|
+
}
|
|
725
|
+
stats.run = { ...(stats.run ?? { phase: "PLAN", done: 0, total: 0, failed: 0 }), ...p, ...counts };
|
|
726
|
+
// A log (piped, no dock) gets one plain line each time the board's numbers move — never redraws.
|
|
727
|
+
const r = stats.run;
|
|
728
|
+
if (!opts.print && !process.stdout.isTTY && board && (r.phase === "BUILD" || r.phase === "QA")) {
|
|
729
|
+
const key = progressLine(r, null, 200, { on: false });
|
|
730
|
+
if (key !== logged)
|
|
731
|
+
print(C.faint(` ${(logged = key)} · ${fmtDuration(Date.now() - stats.startedAt)}`));
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
// You, during the run: agents' questions for you and what you type to them. `huddle join`
|
|
735
|
+
// on a terminal gets a typing line under the pinned block (KeyLine); the app has its prompt.
|
|
736
|
+
const oneShotTty = !opts.rl && !opts.print && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
737
|
+
// Every run has one: with no terminal to type in, the dashboard (huddle app) answers and talks.
|
|
738
|
+
const talk = opts.talk ?? new Talk();
|
|
739
|
+
let keys = null;
|
|
740
|
+
let interrupted = 0;
|
|
741
|
+
let stopNow = null; // set once the build can be stopped cleanly
|
|
742
|
+
let liveTick = null;
|
|
743
|
+
let sessionId;
|
|
744
|
+
let planNow = null;
|
|
745
|
+
const userNotes = []; // typed before the build starts: delivered when it does
|
|
746
|
+
// The whole run's off switch: the council, the plan, the build and QA all stop on it.
|
|
747
|
+
const life = new AbortController();
|
|
748
|
+
if (opts.signal?.aborted)
|
|
749
|
+
life.abort();
|
|
750
|
+
opts.signal?.addEventListener("abort", () => life.abort());
|
|
751
|
+
/** You asked it to stop (ctrl-c, `stop` in the dashboard): the build stops cleanly and still reports; before the build, everything stops. */
|
|
752
|
+
const requestStop = () => {
|
|
753
|
+
if (life.signal.aborted)
|
|
754
|
+
return;
|
|
755
|
+
print(C.yellow(" stopping agents…"));
|
|
756
|
+
if (stopNow)
|
|
757
|
+
stopNow();
|
|
758
|
+
else
|
|
759
|
+
life.abort();
|
|
760
|
+
};
|
|
761
|
+
if (talk) {
|
|
762
|
+
talk.onShow = (q, until) => {
|
|
763
|
+
stats.ask = { from: q.from, until };
|
|
764
|
+
print();
|
|
765
|
+
for (const l of questionLines(q, until))
|
|
766
|
+
print(l);
|
|
767
|
+
if (process.stdout.isTTY)
|
|
768
|
+
process.stdout.write("\x07"); // a bell: you may be in another window
|
|
769
|
+
act(q.from, `${C.yellow("⚑ asks you:")} ${q.question}`);
|
|
770
|
+
};
|
|
771
|
+
talk.onSettled = (q, a) => {
|
|
772
|
+
const next = talk.current();
|
|
773
|
+
stats.ask = next ? { from: next.q.from, until: next.until } : undefined;
|
|
774
|
+
print(settledLine(q, a));
|
|
775
|
+
};
|
|
776
|
+
talk.details = () => {
|
|
777
|
+
if (!planNow)
|
|
778
|
+
return print(C.faint(" no plan yet — the agents are still designing it"));
|
|
779
|
+
const d = planDetails(planNow);
|
|
780
|
+
if (!d.length)
|
|
781
|
+
return print(C.faint(" this plan has no separate decisions or risks"));
|
|
782
|
+
for (const l of d)
|
|
783
|
+
print(l);
|
|
784
|
+
};
|
|
785
|
+
talk.say = (line) => {
|
|
786
|
+
const { to, text } = addressee(line);
|
|
787
|
+
if (!text)
|
|
788
|
+
return;
|
|
789
|
+
if (!board) {
|
|
790
|
+
userNotes.push(text);
|
|
791
|
+
return print(` ${C.violet("✉")} ${C.bold("you")}${C.faint(":")} ${oneLine(text, space(" ✉ you: ", " · noted, every agent gets it when building starts"))} ${C.faint("· noted, every agent gets it when building starts")}`);
|
|
792
|
+
}
|
|
793
|
+
const r = sayToAgents(board, text, to);
|
|
794
|
+
if (to && !r.now.length)
|
|
795
|
+
return print(C.yellow(` ! ${to} isn't working on anything right now — say it without @ and every agent that starts next gets it`));
|
|
796
|
+
const who = r.now.length ? r.now.join(", ") : "every agent that starts next";
|
|
797
|
+
const head = ` ${C.violet("✉")} ${C.bold("you")} ${C.faint("→")} ${who}${C.faint(":")} `;
|
|
798
|
+
print(`${head}${oneLine(text, space(head))}`);
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
802
|
+
/** The run is over: open questions go with option 1, the typing line goes away. */
|
|
803
|
+
const endTalk = () => {
|
|
804
|
+
if (liveTick)
|
|
805
|
+
clearInterval(liveTick);
|
|
806
|
+
liveTick = null;
|
|
807
|
+
writeLive(root, snapshot(true));
|
|
808
|
+
talk?.close();
|
|
809
|
+
if (talk)
|
|
810
|
+
talk.say = talk.details = null;
|
|
811
|
+
stats.ask = undefined;
|
|
812
|
+
keys?.stop();
|
|
813
|
+
keys = null;
|
|
814
|
+
};
|
|
815
|
+
endTalkNow = endTalk;
|
|
816
|
+
// The dashboard's view of this run (.huddle/live.json) and what it sends back (.huddle/inbox/): app/live.ts.
|
|
817
|
+
const councilSoFar = [];
|
|
818
|
+
const snapshot = (done = false) => {
|
|
819
|
+
const cur = talk.current();
|
|
820
|
+
return {
|
|
821
|
+
pid: process.pid,
|
|
822
|
+
goal,
|
|
823
|
+
startedAt: stats.startedAt,
|
|
824
|
+
updatedAt: Date.now(),
|
|
825
|
+
...(sessionId ? { session: sessionId } : {}),
|
|
826
|
+
...(planNow?.plain ? { plain: planNow.plain } : {}),
|
|
827
|
+
narrator: narration(stats),
|
|
828
|
+
...(stats.run ? { run: stats.run } : {}),
|
|
829
|
+
agents: [...stats.agents].map(([id, a]) => ({
|
|
830
|
+
id,
|
|
831
|
+
busy: !!a.busySince,
|
|
832
|
+
...(a.doing ? { doing: a.doing } : {}),
|
|
833
|
+
...(a.pct !== undefined ? { pct: a.pct } : {}),
|
|
834
|
+
tokens: a.tokens + Math.max(0, a.liveTokens),
|
|
835
|
+
tasks: a.tasks,
|
|
836
|
+
reviews: a.reviews,
|
|
837
|
+
})),
|
|
838
|
+
council: councilSoFar,
|
|
839
|
+
...(cur ? { ask: { id: cur.q.id, from: cur.q.from, question: cur.q.question, options: cur.q.options, ...(cur.q.why ? { why: cur.q.why } : {}), until: cur.until } } : {}),
|
|
840
|
+
tokens: [...stats.agents.values()].reduce((n, a) => n + a.tokens + Math.max(0, a.liveTokens), 0),
|
|
841
|
+
...(budget.limit ? { budget: { limit: budget.limit, used: budget.tokens } } : {}),
|
|
842
|
+
...(done ? { done } : {}),
|
|
843
|
+
};
|
|
844
|
+
};
|
|
845
|
+
const command = (c) => {
|
|
846
|
+
if (c.type === "stop")
|
|
847
|
+
return requestStop();
|
|
848
|
+
if (c.type === "say")
|
|
849
|
+
return talk.say?.(c.text);
|
|
850
|
+
const cur = talk.current();
|
|
851
|
+
if (cur && cur.q.id === c.id)
|
|
852
|
+
talk.answer(c.line);
|
|
853
|
+
};
|
|
854
|
+
clearCommands(root);
|
|
855
|
+
writeLive(root, snapshot());
|
|
856
|
+
liveTick = setInterval(() => {
|
|
857
|
+
for (const c of takeCommands(root))
|
|
858
|
+
command(c);
|
|
859
|
+
writeLive(root, snapshot());
|
|
860
|
+
}, 1000);
|
|
861
|
+
// A one-shot run on a terminal (huddle join): the overall bar and who's on what, pinned under
|
|
862
|
+
// the output (the prompt app draws the same lines in its footer). Up for the council, then the build.
|
|
863
|
+
const startDock = () => {
|
|
864
|
+
if (dock || opts.print || !process.stdout.isTTY)
|
|
865
|
+
return;
|
|
866
|
+
if (talk && oneShotTty && !keys)
|
|
867
|
+
keys = new KeyLine((l) => {
|
|
868
|
+
if (talk.answer(l))
|
|
869
|
+
return;
|
|
870
|
+
const t = l.trim();
|
|
871
|
+
if (/^(details|decisions|risks)$/i.test(t))
|
|
872
|
+
talk.details?.();
|
|
873
|
+
else if (t)
|
|
874
|
+
talk.say?.(t);
|
|
875
|
+
}, () => dock?.redraw(), () => {
|
|
876
|
+
// First ctrl-c: stop the agents and still print what got done. Second: leave now.
|
|
877
|
+
if (interrupted++) {
|
|
878
|
+
endTalk();
|
|
879
|
+
stopDock();
|
|
880
|
+
// What the terminal does on ctrl-c without raw mode: SIGINT to the whole
|
|
881
|
+
// foreground group, agents included. Windows has no process groups.
|
|
882
|
+
try {
|
|
883
|
+
if (process.platform === "win32")
|
|
884
|
+
throw new Error("no process groups");
|
|
885
|
+
process.kill(0, "SIGINT");
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
process.exit(130); // the scheduler's exit hook stops any running agents
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
requestStop();
|
|
893
|
+
print(C.faint(" (ctrl-c again to quit right away)"));
|
|
894
|
+
}).start();
|
|
895
|
+
dock = new Dock(() => [...liveLines(stats, (process.stdout.columns || 80) - 1), ...(keys ? [keys.line()] : [])]).start();
|
|
896
|
+
};
|
|
897
|
+
const stopDock = () => {
|
|
898
|
+
dock?.stop();
|
|
899
|
+
dock = null;
|
|
900
|
+
};
|
|
589
901
|
const brain = resolveBrain(opts.results, opts.probed);
|
|
590
902
|
const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
|
|
591
903
|
const members = councilMembers(workers);
|
|
@@ -597,6 +909,9 @@ export async function runGoal(goal, opts) {
|
|
|
597
909
|
let plan;
|
|
598
910
|
if (wantCouncil.yes) {
|
|
599
911
|
phase(print, "COUNCIL", `${members.map((m) => m.id).join(", ")} brainstorm before anyone codes · ${wantCouncil.why} (--no-council skips)`);
|
|
912
|
+
progress({ phase: "COUNCIL", step: 1 });
|
|
913
|
+
opts.onStats?.(stats);
|
|
914
|
+
startDock();
|
|
600
915
|
/** " ● codex adopts claude's: <body, cut to fit> · 14s" */
|
|
601
916
|
const say = (agent, label, body = "", took = "") => {
|
|
602
917
|
const head = ` ${dot(agent)} ${pad(agent, 12)} ${label}${body ? " " : ""}`;
|
|
@@ -605,6 +920,20 @@ export async function runGoal(goal, opts) {
|
|
|
605
920
|
opts.onActivity?.({ agent, text });
|
|
606
921
|
};
|
|
607
922
|
const onCouncil = (e) => {
|
|
923
|
+
if (e.type === "ratified")
|
|
924
|
+
councilSoFar.push({ agent: e.entry.agent, kind: e.entry.verdict === "object" ? "object" : e.entry.verdict === "skipped" ? "skipped" : "ok", text: e.entry.objections.join("\n") });
|
|
925
|
+
if (e.type === "entry" && e.entry.ok) {
|
|
926
|
+
const x = e.entry;
|
|
927
|
+
if (x.round === "proposal")
|
|
928
|
+
councilSoFar.push({ agent: x.agent, kind: "proposal", text: x.text.slice(0, 2000) });
|
|
929
|
+
for (const a of x.adopts ?? [])
|
|
930
|
+
councilSoFar.push({ agent: x.agent, kind: "adopt", of: a.of, text: a.text });
|
|
931
|
+
for (const f of x.flaws ?? [])
|
|
932
|
+
councilSoFar.push({ agent: x.agent, kind: "flaw", of: f.of, text: f.text });
|
|
933
|
+
}
|
|
934
|
+
const step = e.type === "round" ? (e.round === "proposal" ? 1 : 2) : e.type === "synthesizing" ? 3 : e.type === "ratify" ? 4 : 0;
|
|
935
|
+
if (step)
|
|
936
|
+
progress({ phase: "COUNCIL", step });
|
|
608
937
|
if (e.type === "round")
|
|
609
938
|
print(C.faint(` ${e.round === "proposal" ? "1/4 each agent proposes a design…" : "2/4 each agent reviews the others: what to adopt, what's flawed…"}`));
|
|
610
939
|
if (e.type === "synthesizing") {
|
|
@@ -660,20 +989,46 @@ export async function runGoal(goal, opts) {
|
|
|
660
989
|
catch {
|
|
661
990
|
/* no history: roster order */
|
|
662
991
|
}
|
|
663
|
-
|
|
992
|
+
try {
|
|
993
|
+
plan = await runCouncil(goal, crew, members, root, { brain, signal: life.signal, onEvent: onCouncil, pastSyntheses: past, outside: await outside() });
|
|
994
|
+
}
|
|
995
|
+
finally {
|
|
996
|
+
stopDock(); // the plan prints in full; the dock comes back for the build
|
|
997
|
+
}
|
|
664
998
|
}
|
|
665
999
|
else {
|
|
1000
|
+
progress({ phase: "PLAN" });
|
|
1001
|
+
opts.onStats?.(stats);
|
|
666
1002
|
// A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
|
|
667
1003
|
const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
|
|
668
|
-
plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal:
|
|
1004
|
+
plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: life.signal, project, outside: await outside() }));
|
|
1005
|
+
}
|
|
1006
|
+
if (life.signal.aborted)
|
|
1007
|
+
return endTalk();
|
|
1008
|
+
planNow = plan;
|
|
1009
|
+
progress({ phase: "PLAN", step: undefined, total: plan.tasks.length });
|
|
1010
|
+
printPlan(print, goal, plan, workers, undefined, !!talk);
|
|
1011
|
+
{
|
|
1012
|
+
let history = runHistory(null);
|
|
1013
|
+
try {
|
|
1014
|
+
if (opts.room?.db)
|
|
1015
|
+
history = runHistory(opts.room.db);
|
|
1016
|
+
else if (fs.existsSync(dbPath())) {
|
|
1017
|
+
const d = new RoomDB(dbPath());
|
|
1018
|
+
history = runHistory(d);
|
|
1019
|
+
d.close();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
catch {
|
|
1023
|
+
/* no history: the defaults */
|
|
1024
|
+
}
|
|
1025
|
+
const qaRounds = shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles }) ? QA_ROUNDS : 0;
|
|
1026
|
+
print(C.faint(` ${costPreview({ tasks: plan.tasks.length, agents: workers.length + remoteNow().length, qaRounds, council: plan.council, history, budget })}`));
|
|
669
1027
|
}
|
|
670
|
-
if (opts.signal?.aborted)
|
|
671
|
-
return;
|
|
672
|
-
printPlan(print, goal, plan, workers);
|
|
673
1028
|
if (opts.mode === "plan") {
|
|
674
1029
|
print(C.faint("\n plan mode — nothing created or spawned. " +
|
|
675
1030
|
(rl ? "shift+tab to switch mode and run it." : `Run it: huddle join ${JSON.stringify(goal)}`)));
|
|
676
|
-
return;
|
|
1031
|
+
return endTalk();
|
|
677
1032
|
}
|
|
678
1033
|
if (opts.mode === "manual" && rl && !(await yes(rl, `\nStart with ${workers.map((w) => w.id).join(", ")}?`)))
|
|
679
1034
|
return;
|
|
@@ -701,9 +1056,10 @@ export async function runGoal(goal, opts) {
|
|
|
701
1056
|
const seen = [];
|
|
702
1057
|
db.clearBoard();
|
|
703
1058
|
const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
|
|
1059
|
+
room.connectors = hub;
|
|
704
1060
|
const taskIds = seedBoard(db, goal, plan.tasks);
|
|
705
1061
|
// Who planned it, for `huddle story` (the tasks themselves are task.added events).
|
|
706
|
-
db.publish("plan.made", { payload: { by: plan.by, note: plan.note ?? null, ms: plan.ms ?? null, tokens: plan.tokens ?? null, tasks: plan.tasks.length } });
|
|
1062
|
+
db.publish("plan.made", { payload: { by: plan.by, note: plan.note ?? null, ms: plan.ms ?? null, tokens: plan.tokens ?? null, tasks: plan.tasks.length, plain: plan.plain ?? null } });
|
|
707
1063
|
// Before the live listener below, so the council's memories aren't printed twice.
|
|
708
1064
|
if (plan.council)
|
|
709
1065
|
recordCouncil(db, plan.council, plan.tasks, taskIds);
|
|
@@ -715,19 +1071,20 @@ export async function runGoal(goal, opts) {
|
|
|
715
1071
|
print(` ${C.violet("◆")} ${C.faint("contract:")} ${ok ? "HUDDLE_CONTRACT.md on main" : "in the Room (couldn't commit HUDDLE_CONTRACT.md)"} ${C.faint(`· by ${by}` + (objected ? ` · ${plan.council.objections.filter((o) => o.taken).length}/${objected} objection(s) taken` : ""))}`);
|
|
716
1072
|
}
|
|
717
1073
|
const t0 = Date.now();
|
|
718
|
-
|
|
1074
|
+
board = db;
|
|
1075
|
+
// What you said while they designed it, and their questions for you from here on.
|
|
1076
|
+
for (const t of userNotes.splice(0))
|
|
1077
|
+
sayToAgents(db, t);
|
|
1078
|
+
sessionId = db.currentSession()?.id;
|
|
1079
|
+
// Someone to ask: a terminal you type in, or the dashboard open on this repo. Nobody: the agent's own pick, at once.
|
|
1080
|
+
room.askUser = (q) => (oneShotTty || opts.rl || openApp(root) ? talk.ask(q) : Promise.resolve({ choice: 1, text: "", by: "default" }));
|
|
1081
|
+
progress({ phase: "BUILD" });
|
|
719
1082
|
opts.onStats?.(stats);
|
|
720
1083
|
phase(print, "BUILD", `${plan.tasks.length} task(s) · ${workers.length + remoteNow().length} agent(s) · each in its own worktree, merged when tests pass`);
|
|
721
1084
|
print(C.faint(` room ${room.url} · tests: ${testCmd ?? "none yet"} · approvals: ${opts.approvals}`) +
|
|
722
1085
|
(rl ? C.faint(" · ctrl-t live view · ctrl-c stops") : ""));
|
|
723
1086
|
print();
|
|
724
|
-
|
|
725
|
-
// (The prompt app draws the same lines in its footer.)
|
|
726
|
-
if (!opts.print && process.stdout.isTTY)
|
|
727
|
-
dock = new Dock(() => [
|
|
728
|
-
C.faint(` ⏱ ${fmtDuration(Date.now() - t0)} · ${[...stats.agents.values()].reduce((n, a) => n + a.tasks, 0)} merged so far`),
|
|
729
|
-
...statusLines(stats, (process.stdout.columns || 80) - 1),
|
|
730
|
-
]).start();
|
|
1087
|
+
startDock();
|
|
731
1088
|
const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
|
|
732
1089
|
/** " ● codex <label> <body, cut to fit><suffix>" */
|
|
733
1090
|
const line = (agent, label, body = "", suffix = "") => {
|
|
@@ -750,8 +1107,7 @@ export async function runGoal(goal, opts) {
|
|
|
750
1107
|
for (const l of notes.split("\n").map((x) => x.replace(/^\s*[-*•]\s*/, "").trim()).filter(Boolean).slice(0, n))
|
|
751
1108
|
print(C.faint(` ${oneLine(l, space(" "))}`));
|
|
752
1109
|
};
|
|
753
|
-
let
|
|
754
|
-
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
1110
|
+
let talk_ = 0; // agent-to-agent messages this run, for the teamwork summary
|
|
755
1111
|
const unsub = db.onEvent((e) => {
|
|
756
1112
|
const p = JSON.parse(e.payload || "{}");
|
|
757
1113
|
if (e.type === "task.progress" && p.note) {
|
|
@@ -762,7 +1118,8 @@ export async function runGoal(goal, opts) {
|
|
|
762
1118
|
sayOne(agent, C.faint(String(p.pct).padStart(3) + "%"), p.note);
|
|
763
1119
|
act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
|
|
764
1120
|
}
|
|
765
|
-
else if (e.type === "memory.added" && p.author !== "huddle") {
|
|
1121
|
+
else if (e.type === "memory.added" && p.author !== "huddle" && p.author !== USER_ID) {
|
|
1122
|
+
// (Your own words are on screen already, as the ✉ or → line.)
|
|
766
1123
|
act(undefined, `${C.violet("◆")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
|
|
767
1124
|
// Already on screen as its own line: a task's done note (✓ merged), a QA bug (✗ found), a message (a → b).
|
|
768
1125
|
if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ → \S+:/.test(String(p.body)))
|
|
@@ -770,6 +1127,12 @@ export async function runGoal(goal, opts) {
|
|
|
770
1127
|
const head = ` ${C.violet("◆")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
|
|
771
1128
|
print(`${head}${oneLine(p.body, space(head))}`);
|
|
772
1129
|
}
|
|
1130
|
+
else if (e.type === "connector.call" && e.agent_id) {
|
|
1131
|
+
// "codex github: read issue #12"
|
|
1132
|
+
const line = connectorLine(p);
|
|
1133
|
+
sayOne(e.agent_id, C.faint(line.head), line.body);
|
|
1134
|
+
act(e.agent_id, `${C.faint(line.head)} ${line.body}`);
|
|
1135
|
+
}
|
|
773
1136
|
else if (e.type === "plan.announced" && e.agent_id) {
|
|
774
1137
|
sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
|
|
775
1138
|
act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
|
|
@@ -780,13 +1143,29 @@ export async function runGoal(goal, opts) {
|
|
|
780
1143
|
if (p.from === "huddle" && /^buddy check from/i.test(String(p.body)))
|
|
781
1144
|
return;
|
|
782
1145
|
if (p.from !== "huddle")
|
|
783
|
-
|
|
1146
|
+
talk_++;
|
|
784
1147
|
const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}${C.faint(":")} `;
|
|
785
1148
|
const kind = p.kind === "answer" ? C.faint(" (answer)") : "";
|
|
786
1149
|
print(` ${arrow}${oneLine(p.body, space(" ", arrow, kind))}${kind}`);
|
|
787
1150
|
act(undefined, `${arrow}${oneLine(p.body, 200)}`);
|
|
788
1151
|
}
|
|
789
1152
|
});
|
|
1153
|
+
// The budget's hard line stops the run as Ctrl-C does: one signal for the board and QA.
|
|
1154
|
+
const stopRun = new AbortController();
|
|
1155
|
+
stopNow = () => stopRun.abort();
|
|
1156
|
+
if (life.signal.aborted)
|
|
1157
|
+
stopRun.abort();
|
|
1158
|
+
life.signal.addEventListener("abort", () => stopRun.abort());
|
|
1159
|
+
budget.add(plan.tokens ?? 0);
|
|
1160
|
+
budget.onLevel((level, b) => {
|
|
1161
|
+
if (level === "soft")
|
|
1162
|
+
print(C.yellow(` ! budget: ${budgetUse(b)}${b.used < SOFT_AT ? ", about 90% with the work still in flight" : ""} — no new tasks, QA rounds, buddy checks or pairings; running tasks finish`));
|
|
1163
|
+
else {
|
|
1164
|
+
print(C.yellow(` ! budget: ${budgetUse(b)} — past ${Math.round(HARD_AT * 100)}%, stopping the running agents`));
|
|
1165
|
+
stopRun.abort();
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
const unwatchBudget = budget.watch();
|
|
790
1169
|
const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
|
|
791
1170
|
const cost = (ms, tokens, cached) => C.faint(` · ${fmtDuration(ms)} · ${tok(tokens, cached)}`);
|
|
792
1171
|
if (remoteNow().length && !(remote && sync))
|
|
@@ -808,7 +1187,8 @@ export async function runGoal(goal, opts) {
|
|
|
808
1187
|
workers,
|
|
809
1188
|
testCmd: (worktree) => detectTestCmd(worktree),
|
|
810
1189
|
approvals: opts.approvals,
|
|
811
|
-
signal:
|
|
1190
|
+
signal: stopRun.signal,
|
|
1191
|
+
budget,
|
|
812
1192
|
approveMerge: opts.mode === "manual" && rl
|
|
813
1193
|
? async ({ agent, title, diffstat }) => {
|
|
814
1194
|
print(`\n ${tag(agent)} ${C.violet("◎ ready")} ${title}`);
|
|
@@ -826,7 +1206,7 @@ export async function runGoal(goal, opts) {
|
|
|
826
1206
|
stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
|
|
827
1207
|
const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
|
|
828
1208
|
if (e.type === "start" && a)
|
|
829
|
-
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined }); // -1 = not reported yet
|
|
1209
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1, doing: e.title, pct: undefined, noteAt: undefined, est: db.getTask(e.task)?.est_seconds ?? undefined }); // -1 = not reported yet
|
|
830
1210
|
if (e.type === "tokens" && a)
|
|
831
1211
|
a.liveTokens = e.tokens;
|
|
832
1212
|
if ((e.type === "merged" || e.type === "failed") && a)
|
|
@@ -931,6 +1311,13 @@ export async function runGoal(goal, opts) {
|
|
|
931
1311
|
act(e.agent, `${C.violet("⇉")} pairs with ${e.with}: ${e.part}`);
|
|
932
1312
|
sayOne(e.agent, `${C.violet("⇉ pairs with " + e.with)} on “${oneLine(e.title, 40)}”: takes`, e.part, C.faint(" · " + oneLine(e.reason, 50)));
|
|
933
1313
|
}
|
|
1314
|
+
// Quiet past the silence window, but working (a long test run): not taken over (busy.ts).
|
|
1315
|
+
if (e.type === "busy") {
|
|
1316
|
+
act(e.agent, `${C.faint("⧗ running a long command")} ${C.faint("· " + e.why)}`);
|
|
1317
|
+
sayOne(e.agent, C.faint("⧗ running a long command ·"), `quiet ${fmtDuration(e.quietMs)}, but busy: ${e.why}`);
|
|
1318
|
+
}
|
|
1319
|
+
if (e.type === "start" || e.type === "merged" || e.type === "failed")
|
|
1320
|
+
progress({});
|
|
934
1321
|
if (e.type === "stuck")
|
|
935
1322
|
print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" · ")));
|
|
936
1323
|
},
|
|
@@ -940,11 +1327,17 @@ export async function runGoal(goal, opts) {
|
|
|
940
1327
|
// they file become routed fix tasks, run through this same board.
|
|
941
1328
|
let qaNote = "";
|
|
942
1329
|
let qaSum = ""; // the RESULT's checks line
|
|
1330
|
+
let qaCut = false; // QA left undone by the budget
|
|
943
1331
|
if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
|
|
944
|
-
if (
|
|
1332
|
+
if (budget.stopping) {
|
|
1333
|
+
qaCut = true;
|
|
1334
|
+
print(C.faint("\n QA skipped — the token budget is nearly spent"));
|
|
1335
|
+
}
|
|
1336
|
+
else if (res.failed)
|
|
945
1337
|
print(C.faint(`\n QA skipped — ${res.failed} task(s) didn't merge; it checks finished builds only`));
|
|
946
1338
|
else {
|
|
947
1339
|
phase(print, "QA", "agents that didn't write it check the build · --no-qa skips");
|
|
1340
|
+
progress({ phase: "QA" });
|
|
948
1341
|
const qa = await runQa({
|
|
949
1342
|
db,
|
|
950
1343
|
room,
|
|
@@ -954,18 +1347,24 @@ export async function runGoal(goal, opts) {
|
|
|
954
1347
|
workers,
|
|
955
1348
|
approvals: opts.approvals,
|
|
956
1349
|
testCmd: (wt) => detectTestCmd(wt),
|
|
957
|
-
signal:
|
|
1350
|
+
signal: stopRun.signal,
|
|
1351
|
+
budget,
|
|
958
1352
|
runFixes: () => runBoard(boardOpts),
|
|
959
1353
|
onEvent: (e) => {
|
|
960
1354
|
seen.push(e);
|
|
961
1355
|
if (process.env.HUDDLE_EVENTS_FILE)
|
|
962
1356
|
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
|
|
963
1357
|
qaLine(e, tag, print, act, stats);
|
|
1358
|
+
if (e.type === "round")
|
|
1359
|
+
progress({ round: e.round });
|
|
1360
|
+
else if (e.type === "bug" || e.type === "fixes")
|
|
1361
|
+
progress({});
|
|
964
1362
|
},
|
|
965
1363
|
});
|
|
966
1364
|
res.merged += qa.fixed;
|
|
967
1365
|
res.failed += qa.failed;
|
|
968
1366
|
res.stopped ||= qa.stopped;
|
|
1367
|
+
qaCut = !!qa.budget;
|
|
969
1368
|
qaSum = `${qa.rounds} round(s) · ${qa.bugs} bug(s) found, ${qa.fixed} fixed · ` + (qa.clean ? C.green("last round clean") : C.yellow("last round's fixes not re-checked"));
|
|
970
1369
|
qaNote = qa.clean
|
|
971
1370
|
? `QA: ${qa.rounds} round(s) by agents re-checking the integrated build; ${qa.bugs} bug(s) filed, ${qa.fixed} fix(es) merged, the last round came back clean.`
|
|
@@ -973,6 +1372,13 @@ export async function runGoal(goal, opts) {
|
|
|
973
1372
|
}
|
|
974
1373
|
}
|
|
975
1374
|
unsub();
|
|
1375
|
+
unwatchBudget();
|
|
1376
|
+
room.askUser = undefined;
|
|
1377
|
+
room.connectors = undefined;
|
|
1378
|
+
hub.close();
|
|
1379
|
+
hubNow = null;
|
|
1380
|
+
endTalk();
|
|
1381
|
+
const undone = budget.level === "ok" ? [] : db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
|
|
976
1382
|
const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
|
|
977
1383
|
const parked = db.listTasks().filter((t) => t.status === "needs_you");
|
|
978
1384
|
const takeovers = new Map(); // task id -> the branch a rescuer continued on
|
|
@@ -981,14 +1387,26 @@ export async function runGoal(goal, opts) {
|
|
|
981
1387
|
if (ev.task_id && p.kind === "rescue" && p.branch)
|
|
982
1388
|
takeovers.set(ev.task_id, String(p.branch));
|
|
983
1389
|
}
|
|
1390
|
+
// What `huddle undo` takes back: every commit this run put on the branch.
|
|
1391
|
+
db.publish("git.range", { payload: { start: startHead, end: git(["rev-parse", "HEAD"], root).out, branch: git(["symbolic-ref", "--short", "HEAD"], root).out } });
|
|
984
1392
|
const learned = learnFromRun({ db, root, since: runStart, events: seen });
|
|
1393
|
+
progress({ phase: "DONE" });
|
|
1394
|
+
board = null;
|
|
1395
|
+
// The hand-off brief for whoever works here next, solo (ui/brief.ts).
|
|
1396
|
+
const brief = writeBrief({
|
|
1397
|
+
root,
|
|
1398
|
+
events: db.allEvents(),
|
|
1399
|
+
getHandoff: (id) => db.getHandoff(id),
|
|
1400
|
+
testCmd: detectTestCmd(root),
|
|
1401
|
+
parked: parked.map((t) => ({ title: t.title, branch: takeovers.get(t.id) ?? worktreeFor(root, t.claimed_by ?? "", t.id).branch })),
|
|
1402
|
+
enabled: readPrefs().brief !== false,
|
|
1403
|
+
});
|
|
985
1404
|
if (ownRoom) {
|
|
986
1405
|
room.close();
|
|
987
1406
|
db.close();
|
|
988
1407
|
}
|
|
989
1408
|
opts.onStats?.(stats);
|
|
990
|
-
|
|
991
|
-
dock = null;
|
|
1409
|
+
stopDock();
|
|
992
1410
|
// RESULT: what was built and how it was checked, then how the agents worked together, then what it cost.
|
|
993
1411
|
phase(print, "RESULT", "what got built, how it was checked, who did what");
|
|
994
1412
|
const head = res.stopped ? C.yellow("■ stopped") : res.failed ? C.yellow("◎") : C.green("✓");
|
|
@@ -1007,9 +1425,12 @@ export async function runGoal(goal, opts) {
|
|
|
1007
1425
|
print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` — ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
|
|
1008
1426
|
print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
|
|
1009
1427
|
}
|
|
1010
|
-
if (res.merged)
|
|
1011
|
-
await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
|
|
1012
|
-
|
|
1428
|
+
if (res.merged) {
|
|
1429
|
+
const report = await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: testsNow, qaNote }));
|
|
1430
|
+
if (sessionId && report.trim())
|
|
1431
|
+
saveReport(root, sessionId, report);
|
|
1432
|
+
}
|
|
1433
|
+
const team = teamworkLines(plan.council, seen, talk_);
|
|
1013
1434
|
if (team.length) {
|
|
1014
1435
|
print(`\n${C.violet("TEAMWORK")} ${C.faint("· how the agents worked together · huddle story for all of it")}`);
|
|
1015
1436
|
for (const t of team) {
|
|
@@ -1017,7 +1438,9 @@ export async function runGoal(goal, opts) {
|
|
|
1017
1438
|
print(`${label}${oneLine(t.text, space(label))}`);
|
|
1018
1439
|
}
|
|
1019
1440
|
}
|
|
1020
|
-
print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
|
|
1441
|
+
print(`\n${C.violet("COST")} ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}${budget.limit ? ` · ${budgetUse(budget)}` : ""}`)}`);
|
|
1442
|
+
for (const l of budgetNotes(budget, { left: undone, qaCut }))
|
|
1443
|
+
print(C.yellow(` ! ${l}`));
|
|
1021
1444
|
for (const [id, a] of stats.agents) {
|
|
1022
1445
|
if (!a.tasks && !a.reviews && !a.qa) {
|
|
1023
1446
|
print(` ${tag(id)} ${C.faint("idle this run")}`);
|
|
@@ -1034,6 +1457,9 @@ export async function runGoal(goal, opts) {
|
|
|
1034
1457
|
}
|
|
1035
1458
|
if (learned.length)
|
|
1036
1459
|
print(C.faint(` ◆ learned ${learned.length} lesson(s) for next time · huddle lessons to see them`));
|
|
1460
|
+
if (brief)
|
|
1461
|
+
for (const l of briefLines(brief))
|
|
1462
|
+
print(l);
|
|
1037
1463
|
if (res.merged && remote && sync) {
|
|
1038
1464
|
const p = pushMerged(root);
|
|
1039
1465
|
print(p.ok ? ` ${C.green("↑")} git push · all in one go ${C.faint("→ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
|
|
@@ -1185,6 +1611,7 @@ You run read-only because Huddle only asks you for this write-up; the run itself
|
|
|
1185
1611
|
* built, how to run it, what's left. Written read-only by one agent from the
|
|
1186
1612
|
* merged diff and the agents' done notes; falls back to the raw notes.
|
|
1187
1613
|
*/
|
|
1614
|
+
/** WHAT YOU GOT: the brain's write-up of the run (or the agents' notes). Returns the text, for the dashboard. */
|
|
1188
1615
|
async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
1189
1616
|
const stat = git(["diff", "--stat", `${startHead}..HEAD`], root).out;
|
|
1190
1617
|
let summary = "";
|
|
@@ -1212,6 +1639,7 @@ async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
|
1212
1639
|
say(n);
|
|
1213
1640
|
if (stat)
|
|
1214
1641
|
print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" · see it all: git log")));
|
|
1642
|
+
return summary || notes.join("\n");
|
|
1215
1643
|
}
|
|
1216
1644
|
/** Answer a question about the repo, read-only. */
|
|
1217
1645
|
async function answer(print, question, results, signal) {
|
|
@@ -1243,11 +1671,8 @@ export function printBoard(print = (l = "") => console.log(l)) {
|
|
|
1243
1671
|
const s = db.currentSession();
|
|
1244
1672
|
print(`\n${C.violet("BOARD")} ${C.faint("· " + (s?.goal ?? "(no goal yet)"))}`);
|
|
1245
1673
|
for (const t of db.listTasks()) {
|
|
1246
|
-
const w = 16;
|
|
1247
|
-
const filled = Math.round((t.progress_pct / 100) * w);
|
|
1248
|
-
const bar = C.violet("█".repeat(filled)) + C.faint("░".repeat(w - filled));
|
|
1249
1674
|
const who = t.claimed_by ? `${dot(t.claimed_by)} ${t.claimed_by}` : C.faint("unclaimed");
|
|
1250
|
-
print(` ${statusLabel(t.status)} ${
|
|
1675
|
+
print(` ${statusLabel(t.status)} ${taskMeter(t.progress_pct, 16, t.status)} ${t.title} ${who}`);
|
|
1251
1676
|
}
|
|
1252
1677
|
const mem = db.recall();
|
|
1253
1678
|
if (mem.length) {
|
|
@@ -1304,7 +1729,8 @@ const HELP = `
|
|
|
1304
1729
|
${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
|
|
1305
1730
|
${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
|
|
1306
1731
|
|
|
1307
|
-
${C.faint("While agents work you
|
|
1732
|
+
${C.faint("While agents work, what you type goes to them (@claude … for one); a question for you shows with numbered options.")}
|
|
1733
|
+
${C.faint("Questions? end with ? · details: the plan's technical decisions · next <goal>: queue another run.")}
|
|
1308
1734
|
`;
|
|
1309
1735
|
// Things people type out of habit that are shell commands, not goals.
|
|
1310
1736
|
const SHELL_WORDS = new Set(["mkdir", "rm", "mv", "cp", "touch", "echo", "git", "ls", "pwd", "cat", "npm", "npx", "node", "pnpm", "yarn", "python", "python3", "code", "tree", "head", "tail"]);
|
|
@@ -1336,20 +1762,21 @@ export async function runApp() {
|
|
|
1336
1762
|
const queue = [];
|
|
1337
1763
|
let stats = null;
|
|
1338
1764
|
let ticker = null;
|
|
1339
|
-
|
|
1340
|
-
|
|
1765
|
+
const talk = new Talk(); // agents' questions for you, your lines to them, during a run
|
|
1766
|
+
// Footer under the prompt, like Claude Code's mode line; during a run, the overall bar
|
|
1767
|
+
// ("BUILD ███░░ 6/9 tasks · 12m") and then one line per agent (runLines).
|
|
1768
|
+
const footer = () => {
|
|
1769
|
+
if (!run)
|
|
1770
|
+
return idleFooter();
|
|
1771
|
+
const W = (out.columns || 80) - 1;
|
|
1772
|
+
const chip = ` ${MODE_INFO[state.mode].chip} `;
|
|
1773
|
+
const queued = queue.length ? C.faint(` · ${queue.length} queued`) : "";
|
|
1341
1774
|
if (!stats)
|
|
1342
|
-
return C.violet("● planning…");
|
|
1343
|
-
const
|
|
1344
|
-
return `${
|
|
1775
|
+
return [`${chip}${C.violet("● planning…")}${queued}`];
|
|
1776
|
+
const [top] = runLines(stats, W - width(chip) - width(queued) + 2);
|
|
1777
|
+
return [narratorLine(stats, W), `${chip}${top.trimStart()}${queued}`, ...runLines(stats, W).slice(1)];
|
|
1345
1778
|
};
|
|
1346
|
-
|
|
1347
|
-
const footer = () => run
|
|
1348
|
-
? [
|
|
1349
|
-
` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` · ${queue.length} queued`) : ""}`,
|
|
1350
|
-
...(stats ? statusLines(stats, (out.columns || 80) - 1) : []),
|
|
1351
|
-
]
|
|
1352
|
-
: [` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
|
|
1779
|
+
const idleFooter = () => [` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` + `${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`];
|
|
1353
1780
|
let reserved = 1; // footer lines reserved under the prompt
|
|
1354
1781
|
const drawFooter = () => {
|
|
1355
1782
|
if (!promptShown || asking || !out.isTTY)
|
|
@@ -1771,6 +2198,7 @@ export async function runApp() {
|
|
|
1771
2198
|
room: r ?? undefined,
|
|
1772
2199
|
onActivity: (a) => dash.push(a),
|
|
1773
2200
|
qa,
|
|
2201
|
+
talk,
|
|
1774
2202
|
});
|
|
1775
2203
|
}
|
|
1776
2204
|
catch (e) {
|
|
@@ -1797,6 +2225,11 @@ export async function runApp() {
|
|
|
1797
2225
|
const line = raw.trim();
|
|
1798
2226
|
promptShown = false;
|
|
1799
2227
|
out.write("\x1b[2K"); // clear the footer line we just moved onto
|
|
2228
|
+
// An agent asked you something: this line is the answer (Enter = the recommended option).
|
|
2229
|
+
if (run && talk.current() && !/^(stop|exit|quit|view|help|\?|details)$/i.test(line)) {
|
|
2230
|
+
talk.answer(line);
|
|
2231
|
+
return showPrompt();
|
|
2232
|
+
}
|
|
1800
2233
|
if (!line)
|
|
1801
2234
|
return showPrompt();
|
|
1802
2235
|
const [cmd, ...rest] = line.split(/\s+/);
|
|
@@ -1925,11 +2358,20 @@ export async function runApp() {
|
|
|
1925
2358
|
shell(line);
|
|
1926
2359
|
print(C.faint(` (ran as a shell command — say "build ${line}" if you meant a goal)`));
|
|
1927
2360
|
}
|
|
1928
|
-
else if (cmd === "
|
|
2361
|
+
else if (cmd === "details" && run)
|
|
2362
|
+
talk.details?.();
|
|
2363
|
+
else if (cmd === "ask" || (cmd !== "build" && cmd !== "join" && cmd !== "next" && isQuestion(line))) {
|
|
1929
2364
|
await answer(print, cmd === "ask" ? arg : line, results);
|
|
1930
2365
|
}
|
|
2366
|
+
else if (run && cmd !== "build" && cmd !== "join" && cmd !== "next") {
|
|
2367
|
+
// During a run, what you type goes to the agents (room/user.ts). A new goal waits: "next <goal>".
|
|
2368
|
+
if (talk.say)
|
|
2369
|
+
talk.say(line);
|
|
2370
|
+
else
|
|
2371
|
+
print(C.faint(" the agents are wrapping up — say it again in a moment, or `next <goal>` for a new run"));
|
|
2372
|
+
}
|
|
1931
2373
|
else {
|
|
1932
|
-
const goal = cmd === "build" || cmd === "join" ? arg : line;
|
|
2374
|
+
const goal = cmd === "build" || cmd === "join" || cmd === "next" ? arg : line;
|
|
1933
2375
|
if (!goal)
|
|
1934
2376
|
print(C.faint(" usage: build <goal>"));
|
|
1935
2377
|
else if (run) {
|