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