huddle-cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
package/dist/ui/app.js
ADDED
|
@@ -0,0 +1,1534 @@
|
|
|
1
|
+
// The Huddle app: what you get when you type `huddle`.
|
|
2
|
+
//
|
|
3
|
+
// ╭──────────────────────────────────────────╮
|
|
4
|
+
// │ (logo) Welcome to Huddle │
|
|
5
|
+
// │ try join "<goal>" · agents · help│
|
|
6
|
+
// │ cwd ~/shop │
|
|
7
|
+
// ╰──────────────────────────────────────────╯
|
|
8
|
+
// Connecting your agents…
|
|
9
|
+
// huddle › _
|
|
10
|
+
//
|
|
11
|
+
// Everything happens here. Claude, Codex, Gemini and Cursor run in the
|
|
12
|
+
// background as Huddle's workers; you watch the board, never their terminals.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import readline from "node:readline/promises";
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { ADAPTERS, probeAsync } from "../adapters/index.js";
|
|
19
|
+
import { RoomDB } from "../room/db.js";
|
|
20
|
+
import { startRoom } from "../room/server.js";
|
|
21
|
+
import { openFunnel, openTunnel, tailscaleIp } from "../room/tunnel.js";
|
|
22
|
+
import { openRelayLink } from "../room/relaylink.js";
|
|
23
|
+
import { canonicalRemote, pullLatest, pushMerged, readRepoConfig, remoteInfo, writeRepoConfig } from "../sandbox/sync.js";
|
|
24
|
+
import { githubRepo, githubUser, isCollaborator } from "../room/identity.js";
|
|
25
|
+
import { isBigGoal, listsOwnTasks, planGoal, seedBoard } from "../orchestrator/planner.js";
|
|
26
|
+
import { councilMembers, councilWanted, headline, recordCouncil, runCouncil } from "../orchestrator/council.js";
|
|
27
|
+
import { pickAsker } from "../orchestrator/ask.js";
|
|
28
|
+
import { brainFrom, brainOptions, ollamaModels } from "../orchestrator/brain.js";
|
|
29
|
+
import { fmtDuration, fmtTokens } from "../adapters/usage.js";
|
|
30
|
+
import { runBoard } from "../orchestrator/scheduler.js";
|
|
31
|
+
import { runQa, shouldQa } from "../orchestrator/qa.js";
|
|
32
|
+
import { isGitRepo, listWorktrees, removeWorktree, worktreeFor } from "../sandbox/worktree.js";
|
|
33
|
+
import { C, LiveLines, box, dot, pad, truncate } from "./term.js";
|
|
34
|
+
import { AGENTS_FILE_ERRORS, LEGACY } from "../adapters/index.js";
|
|
35
|
+
import { logoLines } from "./logo.js";
|
|
36
|
+
import { Dashboard } from "./dashboard.js";
|
|
37
|
+
// 0 = pick a free port per run, so sessions never collide.
|
|
38
|
+
export const ROOM_PORT = Number(process.env.HUDDLE_ROOM_PORT ?? 0);
|
|
39
|
+
// ---- paths & repo ----------------------------------------------------------
|
|
40
|
+
export function repoRoot() {
|
|
41
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
42
|
+
return r.status === 0 ? r.stdout.trim() : process.cwd();
|
|
43
|
+
}
|
|
44
|
+
export const huddleDir = () => path.join(repoRoot(), ".huddle");
|
|
45
|
+
export const dbPath = () => path.join(huddleDir(), "huddle.db");
|
|
46
|
+
/** Create .huddle/ + the Room db. Idempotent. Returns false if not a git repo. */
|
|
47
|
+
export function ensureInit() {
|
|
48
|
+
const root = repoRoot();
|
|
49
|
+
if (!isGitRepo(root))
|
|
50
|
+
return false;
|
|
51
|
+
fs.mkdirSync(huddleDir(), { recursive: true });
|
|
52
|
+
// keep worktrees, logs & db out of the user's commits
|
|
53
|
+
fs.writeFileSync(path.join(huddleDir(), ".gitignore"), "*\n");
|
|
54
|
+
new RoomDB(dbPath()).close();
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
/** The repo's test command, if we can find one. The merge engine's referee. */
|
|
58
|
+
export function detectTestCmd(root) {
|
|
59
|
+
try {
|
|
60
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
|
61
|
+
const t = pkg.scripts?.test;
|
|
62
|
+
if (t && !/no test specified/.test(t))
|
|
63
|
+
return "npm test --silent";
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* no package.json */
|
|
67
|
+
}
|
|
68
|
+
if (fs.existsSync(path.join(root, "Makefile")) && /^test:/m.test(fs.readFileSync(path.join(root, "Makefile"), "utf8")))
|
|
69
|
+
return "make test";
|
|
70
|
+
if (fs.existsSync(path.join(root, "Cargo.toml")))
|
|
71
|
+
return "cargo test";
|
|
72
|
+
if (fs.existsSync(path.join(root, "go.mod")))
|
|
73
|
+
return "go test ./...";
|
|
74
|
+
if (fs.existsSync(path.join(root, "pyproject.toml")) || fs.existsSync(path.join(root, "pytest.ini")))
|
|
75
|
+
return "pytest -q";
|
|
76
|
+
// Plain node:test files with no package.json — Node's built-in runner finds them.
|
|
77
|
+
const files = spawnSync("git", ["ls-files", "--cached", "--others", "--exclude-standard"], { cwd: root, encoding: "utf8" }).stdout ?? "";
|
|
78
|
+
if (/(^|\/)[^/]+\.test\.(c|m)?js$|(^|\/)test\/[^/]+\.(c|m)?js$/m.test(files))
|
|
79
|
+
return "node --test";
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The brain to use: your pick if it's usable, else Sonnet (fast, strong plans,
|
|
84
|
+
* a fraction of Opus's tokens), else whichever agent can answer read-only.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveBrain(results, probed = results) {
|
|
87
|
+
const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
|
|
88
|
+
const want = readPrefs().brain;
|
|
89
|
+
if (want) {
|
|
90
|
+
const kind = want.split(":")[0];
|
|
91
|
+
const b = brainFrom(want);
|
|
92
|
+
// Your explicit brain pick holds even when --agents leaves that CLI off this
|
|
93
|
+
// run's roster: the brain plans and reports, it doesn't take tasks.
|
|
94
|
+
if (b && (kind === "ollama" || probed.some((r) => r.state === "ready" && r.adapter.id === kind)))
|
|
95
|
+
return b;
|
|
96
|
+
}
|
|
97
|
+
if (ready.includes("claude"))
|
|
98
|
+
return brainFrom("claude:sonnet");
|
|
99
|
+
const asker = pickAsker(results.filter((r) => r.state === "ready").map((r) => r.adapter));
|
|
100
|
+
return asker ? brainFrom(asker.id) : null;
|
|
101
|
+
}
|
|
102
|
+
// HUDDLE_HOME lets two Huddle identities share one machine (tests, demos).
|
|
103
|
+
const PREFS = path.join(process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle"), "prefs.json");
|
|
104
|
+
export function readPrefs() {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(fs.readFileSync(PREFS, "utf8"));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function writePrefs(p) {
|
|
113
|
+
fs.mkdirSync(path.dirname(PREFS), { recursive: true });
|
|
114
|
+
fs.writeFileSync(PREFS, JSON.stringify(p, null, 2));
|
|
115
|
+
}
|
|
116
|
+
// ---- welcome ---------------------------------------------------------------
|
|
117
|
+
function tildify(p) {
|
|
118
|
+
const h = os.homedir();
|
|
119
|
+
return p.startsWith(h) ? "~" + p.slice(h.length) : p;
|
|
120
|
+
}
|
|
121
|
+
/** First line of a note, capped — the full text lives in `board`. */
|
|
122
|
+
function oneLine(s, n) {
|
|
123
|
+
const l = s.replace(/\s+/g, " ").trim();
|
|
124
|
+
return l.length <= n ? l : l.slice(0, n - 1) + "…";
|
|
125
|
+
}
|
|
126
|
+
/** Keep the end of a long path: "…/scratchpad/demo". */
|
|
127
|
+
function clip(s, n) {
|
|
128
|
+
return s.length <= n ? s : "…" + s.slice(s.length - n + 1);
|
|
129
|
+
}
|
|
130
|
+
export function printWelcome(gitOk) {
|
|
131
|
+
const logo = logoLines();
|
|
132
|
+
const text = [
|
|
133
|
+
"",
|
|
134
|
+
"",
|
|
135
|
+
C.bold("Welcome to Huddle"),
|
|
136
|
+
C.faint(`try <a goal> · <a question>? · help`),
|
|
137
|
+
C.faint(`cwd ${clip(tildify(process.cwd()), 48)}`),
|
|
138
|
+
gitOk
|
|
139
|
+
? ""
|
|
140
|
+
: childRepos().length === 1
|
|
141
|
+
? C.faint(`project repo found in ${childRepos()[0]}/`)
|
|
142
|
+
: C.faint("not a git repo yet — Huddle will set it up when you give it a goal"),
|
|
143
|
+
];
|
|
144
|
+
const cols = Math.min(process.stdout.columns || 80, 84);
|
|
145
|
+
const lines = logo.map((l, i) => ` ${C.bold(l)} ${text[i] ?? ""}`);
|
|
146
|
+
console.log(box(lines, { width: cols - 4 }));
|
|
147
|
+
}
|
|
148
|
+
// ---- agents: connect, install, sign in ----------------------------------------
|
|
149
|
+
const PROVIDER = {
|
|
150
|
+
claude: "anthropic",
|
|
151
|
+
codex: "openai",
|
|
152
|
+
antigravity: "google",
|
|
153
|
+
gemini: "google",
|
|
154
|
+
cursor: "cursor",
|
|
155
|
+
};
|
|
156
|
+
/** Probe every adapter in parallel with a live, in-place status list. */
|
|
157
|
+
export async function connectAgents() {
|
|
158
|
+
console.log("\nConnecting your agents…");
|
|
159
|
+
const results = new Map();
|
|
160
|
+
const shown = (a) => !LEGACY.has(a.id) || results.get(a.id)?.state === "ready";
|
|
161
|
+
const row = (a, spin) => {
|
|
162
|
+
const r = results.get(a.id);
|
|
163
|
+
const head = `${pad(dot(a.id) + " " + C.bold(a.id), 14)} ${pad(C.faint("· " + (PROVIDER[a.id] ?? a.vendor)), 12)}`;
|
|
164
|
+
if (!r)
|
|
165
|
+
return ` ${spin} ${head} ${C.faint("connecting…")}`;
|
|
166
|
+
if (r.state === "ready")
|
|
167
|
+
return ` ${C.green("✓")} ${head} ready`;
|
|
168
|
+
if (r.state === "not_installed")
|
|
169
|
+
return ` ${C.faint("○")} ${head} ${C.faint("not installed")}`;
|
|
170
|
+
return ` ${C.yellow("!")} ${head} ${C.yellow("not signed in")} ${C.faint("— " + r.detail)}`;
|
|
171
|
+
};
|
|
172
|
+
// Probe legacy CLIs first (silently) so they only appear when installed.
|
|
173
|
+
for (const a of ADAPTERS.filter((a) => LEGACY.has(a.id)))
|
|
174
|
+
results.set(a.id, await probeAsync(a));
|
|
175
|
+
const visible = ADAPTERS.filter(shown);
|
|
176
|
+
const live = new LiveLines((spin) => visible.map((a) => row(a, spin))).start();
|
|
177
|
+
await Promise.all(visible.map(async (a) => {
|
|
178
|
+
if (!results.has(a.id))
|
|
179
|
+
results.set(a.id, await probeAsync(a));
|
|
180
|
+
live.draw();
|
|
181
|
+
}));
|
|
182
|
+
live.stop();
|
|
183
|
+
// Connectors declared in agents.json: say where they came from, and what's wrong with any that didn't load.
|
|
184
|
+
for (const a of visible.filter((a) => a.source))
|
|
185
|
+
console.log(C.faint(` ${a.id} is declared in ${a.source.replace(os.homedir(), "~")}`));
|
|
186
|
+
for (const e of AGENTS_FILE_ERRORS)
|
|
187
|
+
console.log(` ${C.yellow("!")} ${C.faint(e)}`);
|
|
188
|
+
return visible.map((a) => results.get(a.id));
|
|
189
|
+
}
|
|
190
|
+
/** Hooks the live prompt installs so y/n questions don't collide with output. */
|
|
191
|
+
const questionHooks = { before: () => { }, after: () => { } };
|
|
192
|
+
async function yes(rl, q, def = true) {
|
|
193
|
+
questionHooks.before();
|
|
194
|
+
try {
|
|
195
|
+
const a = (await rl.question(`${q} ${C.faint(def ? "[Y/n]" : "[y/N]")} `)).trim().toLowerCase();
|
|
196
|
+
return a === "" ? def : a.startsWith("y");
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
questionHooks.after();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** Run a shell command with the terminal handed over (installers, logins). */
|
|
203
|
+
function runInteractive(rl, cmd) {
|
|
204
|
+
rl.pause();
|
|
205
|
+
const r = typeof cmd === "string"
|
|
206
|
+
? spawnSync("sh", ["-c", cmd], { stdio: "inherit" })
|
|
207
|
+
: spawnSync(cmd[0], cmd.slice(1), { stdio: "inherit" });
|
|
208
|
+
rl.resume();
|
|
209
|
+
return r.status === 0;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Offer to install missing agents and sign in to logged-out ones — always with
|
|
213
|
+
* the user's OK. A "no" is remembered so we don't nag on every launch; the
|
|
214
|
+
* `agents` command asks again.
|
|
215
|
+
*/
|
|
216
|
+
export async function offerSetup(rl, results, force = false) {
|
|
217
|
+
const prefs = readPrefs();
|
|
218
|
+
const declined = new Set(force ? [] : (prefs.declinedInstall ?? []));
|
|
219
|
+
const missing = results.filter((r) => r.state === "not_installed" && !declined.has(r.adapter.id) && !LEGACY.has(r.adapter.id));
|
|
220
|
+
const loggedOut = results.filter((r) => r.state === "not_authed" && !declined.has(r.adapter.id));
|
|
221
|
+
let changed = false;
|
|
222
|
+
if (missing.length) {
|
|
223
|
+
console.log();
|
|
224
|
+
for (const r of missing)
|
|
225
|
+
console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id)} ${C.faint("$ " + r.adapter.install.cmd)}`);
|
|
226
|
+
const names = missing.map((r) => r.adapter.id).join(", ");
|
|
227
|
+
if (await yes(rl, `\nInstall ${names}?`)) {
|
|
228
|
+
for (const r of missing) {
|
|
229
|
+
console.log(C.violet(`\n↓ installing ${r.adapter.id}…`));
|
|
230
|
+
const ok = runInteractive(rl, r.adapter.install.cmd);
|
|
231
|
+
console.log(ok ? C.green(`✓ ${r.adapter.id} installed`) : C.red(`✗ ${r.adapter.id} install failed — see output above`));
|
|
232
|
+
changed ||= ok;
|
|
233
|
+
if (ok && r.adapter.install.login && (await yes(rl, `Sign in to ${r.adapter.id} now?`))) {
|
|
234
|
+
if (r.adapter.install.loginNote)
|
|
235
|
+
console.log(C.faint(` ${r.adapter.install.loginNote}`));
|
|
236
|
+
runInteractive(rl, r.adapter.install.login);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
writePrefs({ ...prefs, declinedInstall: [...new Set([...(prefs.declinedInstall ?? []), ...missing.map((r) => r.adapter.id)])] });
|
|
242
|
+
console.log(C.faint(" ok — type `agents` any time to set them up."));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
for (const r of loggedOut) {
|
|
246
|
+
const login = r.adapter.install.login;
|
|
247
|
+
if (!login)
|
|
248
|
+
continue;
|
|
249
|
+
if (await yes(rl, `\n${r.adapter.id} isn't signed in. Sign in now?`)) {
|
|
250
|
+
if (r.adapter.install.loginNote)
|
|
251
|
+
console.log(C.faint(` ${r.adapter.install.loginNote}`));
|
|
252
|
+
runInteractive(rl, login);
|
|
253
|
+
changed = true;
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
const p = readPrefs();
|
|
257
|
+
writePrefs({ ...p, declinedInstall: [...new Set([...(p.declinedInstall ?? []), r.adapter.id])] });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return changed;
|
|
261
|
+
}
|
|
262
|
+
export function newStats(workers) {
|
|
263
|
+
return {
|
|
264
|
+
startedAt: Date.now(),
|
|
265
|
+
agents: new Map(workers.map((w) => [w, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 }])),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/** Ready agents × crew counts → workers: claude, claude#2, codex, … */
|
|
269
|
+
const prefersOf = (w) => w.adapter?.prefers ?? w.remote?.prefers ?? [];
|
|
270
|
+
export function workersFrom(results) {
|
|
271
|
+
const crew = readPrefs().crew ?? {};
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const r of results.filter((r) => r.state === "ready")) {
|
|
274
|
+
const n = crew[r.adapter.id] ?? 1;
|
|
275
|
+
for (let i = 1; i <= n; i++)
|
|
276
|
+
out.push({ id: i === 1 ? r.adapter.id : `${r.adapter.id}#${i}`, adapter: r.adapter });
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function git(args, cwd = process.cwd()) {
|
|
281
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
282
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
|
|
283
|
+
}
|
|
284
|
+
const DEFAULT_GITIGNORE = "node_modules/\n.env\n.env.*\n.DS_Store\n__pycache__/\n.venv/\ndist/\nbuild/\n";
|
|
285
|
+
/** Git repos one level down — e.g. ~/Loomin/LoominPhysicsSandbox/.git. */
|
|
286
|
+
function childRepos(dir = process.cwd()) {
|
|
287
|
+
try {
|
|
288
|
+
return fs
|
|
289
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
290
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith(".") && fs.existsSync(path.join(dir, d.name, ".git")))
|
|
291
|
+
.map((d) => d.name);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* You're in a folder that isn't a repo, but the project repo is inside it.
|
|
299
|
+
* Offer to move there instead of creating a repo around another repo.
|
|
300
|
+
* Returns true if we switched.
|
|
301
|
+
*/
|
|
302
|
+
async function offerChildRepo(print, rl) {
|
|
303
|
+
if (isGitRepo(process.cwd()))
|
|
304
|
+
return false;
|
|
305
|
+
const kids = childRepos();
|
|
306
|
+
if (kids.length === 1) {
|
|
307
|
+
print(C.yellow(`\n The project's git repo is in ${kids[0]}/`) + C.faint(" — this folder is just around it."));
|
|
308
|
+
if (await yes(rl, ` Switch to ${kids[0]}/?`)) {
|
|
309
|
+
process.chdir(path.join(process.cwd(), kids[0]));
|
|
310
|
+
ensureInit();
|
|
311
|
+
print(` ${C.faint("cwd")} ${tildify(process.cwd())}`);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
else if (kids.length > 1) {
|
|
316
|
+
print(C.yellow(`\n This folder holds ${kids.length} git repos: `) + kids.map((k) => C.bold(k)).join(", ") + C.faint(" — `cd` into the one you want."));
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Make sure agents can work here: a git repo with everything committed.
|
|
322
|
+
* Agents branch off the last commit, so uncommitted work would be invisible
|
|
323
|
+
* to them. Always asks first; returns false if we can't proceed.
|
|
324
|
+
*/
|
|
325
|
+
async function prepareRepo(print, rl, goal = "") {
|
|
326
|
+
if (!isGitRepo(process.cwd()) && childRepos().length) {
|
|
327
|
+
// Never wrap an existing repo in a new one.
|
|
328
|
+
if (!rl || !(await offerChildRepo(print, rl))) {
|
|
329
|
+
if (!rl)
|
|
330
|
+
print(C.red("✗ Not a git repo.") + ` The project is in: ${childRepos().join(", ")} — cd there.`);
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (!isGitRepo(process.cwd())) {
|
|
335
|
+
if (!rl) {
|
|
336
|
+
print(C.red("✗ Not a git repo.") + " Run `git init` and commit your files first — or run `huddle` here and it sets the repo up (asks first).");
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
print(C.yellow("\n This folder isn't a git repo yet.") + C.faint(" Huddle gives each agent its own copy through git."));
|
|
340
|
+
if (!(await yes(rl, " Set it up? (git init + commit your current files)")))
|
|
341
|
+
return false;
|
|
342
|
+
if (spawnSync("git", ["--version"]).status !== 0) {
|
|
343
|
+
print(C.red(" ✗ git isn't installed.") + C.faint(" macOS: xcode-select --install · Linux: your package manager · https://git-scm.com"));
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
const gi = path.join(process.cwd(), ".gitignore");
|
|
347
|
+
if (!fs.existsSync(gi)) {
|
|
348
|
+
fs.writeFileSync(gi, DEFAULT_GITIGNORE);
|
|
349
|
+
print(C.faint(" added a .gitignore (node_modules, .env, build output) so secrets and junk aren't committed"));
|
|
350
|
+
}
|
|
351
|
+
git(["init", "-q"]);
|
|
352
|
+
git(["add", "-A"]);
|
|
353
|
+
const c = git(["commit", "-q", "--allow-empty", "-m", "huddle: starting point"]);
|
|
354
|
+
if (!c.ok) {
|
|
355
|
+
print(C.red(" ✗ couldn't make the first commit.") + C.faint(" Is git's user.name / user.email set?"));
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
print(` ${C.green("✓")} git repo ready`);
|
|
359
|
+
}
|
|
360
|
+
ensureInit();
|
|
361
|
+
const dirty = git(["status", "--porcelain"]).out.split("\n").filter((l) => l && !l.includes(".huddle/"));
|
|
362
|
+
if (dirty.length) {
|
|
363
|
+
if (!rl) {
|
|
364
|
+
print(C.yellow(`! ${dirty.length} uncommitted change(s) — agents only see committed files.`));
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
print(C.yellow(`\n You have ${dirty.length} uncommitted change(s).`) + C.faint(" Agents only see committed files."));
|
|
368
|
+
if (await yes(rl, " Commit them as a checkpoint first?")) {
|
|
369
|
+
git(["add", "-A"]);
|
|
370
|
+
git(["commit", "-q", "-m", `huddle: checkpoint before "${goal.slice(0, 60)}"`]);
|
|
371
|
+
print(` ${C.green("✓")} checkpoint committed`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
/** Remove worktrees left behind by a crashed or stopped run. */
|
|
377
|
+
function sweepWorktrees(root) {
|
|
378
|
+
const mine = path.join(root, ".huddle", "worktrees");
|
|
379
|
+
for (const wt of listWorktrees(root))
|
|
380
|
+
if (wt.startsWith(mine))
|
|
381
|
+
removeWorktree(root, wt);
|
|
382
|
+
}
|
|
383
|
+
/** A goal for a header line: long goals (a whole spec) don't fill the screen. */
|
|
384
|
+
function shortGoal(goal, n = 100) {
|
|
385
|
+
const one = goal.replace(/\s+/g, " ").trim();
|
|
386
|
+
return one.length > n ? one.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : one;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* The plan's "→ likely <agent>" guess per task. It replays the scheduler's
|
|
390
|
+
* hand-out in waves: each wave, the free agents in fairness order (fewest
|
|
391
|
+
* tasks so far, roster order breaks ties) each take the ready task that best
|
|
392
|
+
* matches their tags, earliest first on a tie, exactly as fill() and
|
|
393
|
+
* nextBacklogTask do; a task is ready once every task it depends on was handed
|
|
394
|
+
* out in an earlier wave. Found live (capstone round 2): the old per-task guess
|
|
395
|
+
* said "likely codex" for task 1, which claude took, because the scheduler
|
|
396
|
+
* lets each agent pick, not each task. (Before that, a prefers-first sort said
|
|
397
|
+
* "likely codex" on all 5 tasks.)
|
|
398
|
+
*/
|
|
399
|
+
export function likelyAgents(tasks, workers) {
|
|
400
|
+
const out = tasks.map(() => undefined);
|
|
401
|
+
if (!workers.length)
|
|
402
|
+
return out;
|
|
403
|
+
const given = new Map();
|
|
404
|
+
const wave = new Map(); // task index -> the wave it was handed out in
|
|
405
|
+
for (let n = 0; wave.size < tasks.length; n++) {
|
|
406
|
+
const order = workers.map((w, i) => ({ w, i })).sort((a, b) => (given.get(a.w.id) ?? 0) - (given.get(b.w.id) ?? 0) || a.i - b.i);
|
|
407
|
+
let any = false;
|
|
408
|
+
for (const { w } of order) {
|
|
409
|
+
const ready = tasks
|
|
410
|
+
.map((t, i) => ({ t, i }))
|
|
411
|
+
.filter(({ t, i }) => !wave.has(i) && (t.depends_on ?? []).every((d) => (wave.get(d) ?? n) < n));
|
|
412
|
+
if (!ready.length)
|
|
413
|
+
break;
|
|
414
|
+
const score = (t) => t.prefers.filter((p) => prefersOf(w).includes(p)).length;
|
|
415
|
+
const pick = ready.sort((a, b) => score(b.t) - score(a.t) || a.i - b.i)[0];
|
|
416
|
+
wave.set(pick.i, n);
|
|
417
|
+
out[pick.i] = w.id;
|
|
418
|
+
given.set(w.id, (given.get(w.id) ?? 0) + 1);
|
|
419
|
+
any = true;
|
|
420
|
+
}
|
|
421
|
+
if (!any)
|
|
422
|
+
break; // a dependency that can never be met (a cycle): leave the rest unguessed
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
function printPlan(print, goal, plan, workers) {
|
|
427
|
+
const took = plan.ms ? ` in ${fmtDuration(plan.ms)}` : "";
|
|
428
|
+
print(`\n${C.violet("PLAN")} ${C.faint("· " + shortGoal(goal) + " · by " + plan.by + took + (plan.note ? " — " + plan.note : ""))}`);
|
|
429
|
+
const startable = plan.tasks.filter((t) => !t.depends_on?.length).length;
|
|
430
|
+
const likely = likelyAgents(plan.tasks, workers);
|
|
431
|
+
plan.tasks.forEach((t, i) => {
|
|
432
|
+
const after = t.depends_on?.length ? C.faint(` after ${t.depends_on.map((d) => d + 1).join(", ")}`) : "";
|
|
433
|
+
const fit = likely[i];
|
|
434
|
+
const idea = t.from?.length ? C.faint(` · idea: ${t.from.join(", ")}`) : "";
|
|
435
|
+
print(` ${C.faint(String(i + 1) + ".")} ${t.title}${after} ${fit ? C.faint("→ likely ") + dot(fit) + " " + fit : ""}${idea}`);
|
|
436
|
+
});
|
|
437
|
+
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.
|
|
439
|
+
for (const d of plan.council?.decisions ?? [])
|
|
440
|
+
print(` ${C.violet("◆")} ${C.faint("decided:")} ${oneLine(d, 110)}`);
|
|
441
|
+
for (const r of plan.council?.risks ?? [])
|
|
442
|
+
print(` ${C.yellow("!")} ${C.faint("risk" + (r.by ? ` (${r.by})` : "") + ":")} ${oneLine(r.body, 105)}`);
|
|
443
|
+
}
|
|
444
|
+
/** Show a spinner line while a promise settles (TTY only). */
|
|
445
|
+
async function withSpinner(label, p) {
|
|
446
|
+
if (!process.stdout.isTTY)
|
|
447
|
+
return p;
|
|
448
|
+
const t0 = Date.now();
|
|
449
|
+
const live = new LiveLines((spin) => [` ${spin} ${C.faint(label)} ${C.faint(fmtDuration(Date.now() - t0))}`]).start();
|
|
450
|
+
try {
|
|
451
|
+
return await p;
|
|
452
|
+
}
|
|
453
|
+
finally {
|
|
454
|
+
live.stop();
|
|
455
|
+
process.stdout.write("\x1b[1A\x1b[2K"); // remove the spinner line
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
export async function runGoal(goal, opts) {
|
|
459
|
+
const print = opts.print ?? ((l = "") => console.log(l));
|
|
460
|
+
// Typed at the prompt too: `build a todo app from scratch --no-council`.
|
|
461
|
+
const flagIn = goal.match(/(^|\s)--(no-)?council(?=\s|$)/);
|
|
462
|
+
if (flagIn)
|
|
463
|
+
goal = goal.replace(flagIn[0], " ").replace(/\s+/g, " ").trim();
|
|
464
|
+
const councilFlag = opts.council ?? (flagIn ? !flagIn[2] : undefined);
|
|
465
|
+
const workers = workersFrom(opts.results);
|
|
466
|
+
// One-shot `huddle join` (no rl): a failure exits 1, and the hints name CLI commands.
|
|
467
|
+
if (!workers.length && !opts.room?.server.remote.workers().length) {
|
|
468
|
+
print(C.red("\n✗ No agents ready.") +
|
|
469
|
+
(opts.rl ? " Type `agents` to install or sign in." : " Run `huddle doctor` to see what's missing, or `huddle` to set agents up (it asks first)."));
|
|
470
|
+
if (!opts.rl)
|
|
471
|
+
process.exitCode = 1;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (!(await prepareRepo(print, opts.rl, goal))) {
|
|
475
|
+
if (!opts.rl)
|
|
476
|
+
process.exitCode = 1;
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const root = repoRoot();
|
|
480
|
+
const rl = opts.rl;
|
|
481
|
+
// Teammates' agents (from `huddle join` on their machines) work alongside yours.
|
|
482
|
+
const bridge = opts.room?.server.remote;
|
|
483
|
+
const remoteNow = () => (bridge?.workers() ?? []).map((r) => ({ id: r.id, remote: r }));
|
|
484
|
+
const brain = resolveBrain(opts.results, opts.probed);
|
|
485
|
+
const crew = [...workers, ...remoteNow()].map((w) => ({ id: w.id, prefers: prefersOf(w) })); // one entry per agent, teammates' included
|
|
486
|
+
const members = councilMembers(workers);
|
|
487
|
+
// Files in the repo before the run: an (almost) empty repo makes a goal a project (goalSize in planner.ts).
|
|
488
|
+
const repoFiles = git(["ls-files"], root).out.split("\n").filter(Boolean).length;
|
|
489
|
+
const wantCouncil = councilWanted(goal, { flag: councilFlag, members: members.length, repoFiles });
|
|
490
|
+
if (councilFlag === true && !wantCouncil.yes)
|
|
491
|
+
print(C.yellow(` ! no design council: ${wantCouncil.why}`));
|
|
492
|
+
let plan;
|
|
493
|
+
if (wantCouncil.yes) {
|
|
494
|
+
print(`\n${C.violet("COUNCIL")} ${C.faint(`· ${members.map((m) => m.id).join(", ")} brainstorm before anyone codes · ${wantCouncil.why} (--no-council skips)`)}`);
|
|
495
|
+
const say = (agent, text) => {
|
|
496
|
+
print(` ${dot(agent)} ${pad(agent, 12)} ${text}`);
|
|
497
|
+
opts.onActivity?.({ agent, text });
|
|
498
|
+
};
|
|
499
|
+
const onCouncil = (e) => {
|
|
500
|
+
if (e.type === "round")
|
|
501
|
+
print(C.faint(` ${e.round === "proposal" ? "1/3 each agent proposes a design…" : "2/3 each agent critiques the others…"}`));
|
|
502
|
+
if (e.type === "synthesizing")
|
|
503
|
+
print(C.faint(` 3/3 ${e.brain} merges the best ideas into one plan…`));
|
|
504
|
+
if (e.type !== "entry")
|
|
505
|
+
return;
|
|
506
|
+
const x = e.entry;
|
|
507
|
+
const took = C.faint(` · ${fmtDuration(x.ms)}`);
|
|
508
|
+
if (!x.ok)
|
|
509
|
+
return say(x.agent, C.faint(`${x.round} skipped (${x.skipped})`) + took);
|
|
510
|
+
if (x.round === "proposal")
|
|
511
|
+
return say(x.agent, `${C.faint("proposes:")} ${oneLine(headline(x.text, "ARCHITECTURE"), 100)}` + took);
|
|
512
|
+
const flaw = headline(x.text, "FLAWS");
|
|
513
|
+
const next = headline(x.text, "NEXT");
|
|
514
|
+
say(x.agent, `${C.faint("flags:")} ${oneLine(flaw, 100)}` + took);
|
|
515
|
+
if (next && next !== flaw)
|
|
516
|
+
print(` ${" ".repeat(15)}${C.faint("next:")} ${oneLine(next, 100)}`);
|
|
517
|
+
};
|
|
518
|
+
plan = await runCouncil(goal, crew, members, root, { brain, signal: opts.signal, onEvent: onCouncil });
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
// A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
|
|
522
|
+
const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
|
|
523
|
+
plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: opts.signal, project }));
|
|
524
|
+
}
|
|
525
|
+
if (opts.signal?.aborted)
|
|
526
|
+
return;
|
|
527
|
+
printPlan(print, goal, plan, workers);
|
|
528
|
+
if (opts.mode === "plan") {
|
|
529
|
+
print(C.faint("\n plan mode — nothing created or spawned. " +
|
|
530
|
+
(rl ? "shift+tab to switch mode and run it." : `Run it: huddle join ${JSON.stringify(goal)}`)));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (opts.mode === "manual" && rl && !(await yes(rl, `\nStart with ${workers.map((w) => w.id).join(", ")}?`)))
|
|
534
|
+
return;
|
|
535
|
+
sweepWorktrees(root);
|
|
536
|
+
// Code layer: start from the latest shared main, and (once, per repo) ask
|
|
537
|
+
// whether merged work should be pushed back.
|
|
538
|
+
const remote = remoteInfo(root);
|
|
539
|
+
let sync = readRepoConfig(root).sync;
|
|
540
|
+
if (remote && sync === undefined && rl) {
|
|
541
|
+
print(C.faint(`\n This repo shares code through ${remote.name} (${remote.url}).`));
|
|
542
|
+
sync = await yes(rl, ` Push merged work to ${remote.name}/${remote.branch} automatically after each run?`);
|
|
543
|
+
writeRepoConfig(root, { sync });
|
|
544
|
+
print(C.faint(sync ? " ✓ auto-push on — change with `sync off`" : " ok — merges stay local; `sync on` to change"));
|
|
545
|
+
}
|
|
546
|
+
if (remote && sync) {
|
|
547
|
+
const p = pullLatest(root);
|
|
548
|
+
print(C.faint(` ${p.ok ? "↓" : "!"} ${remote.name}: ${p.note}`));
|
|
549
|
+
}
|
|
550
|
+
const startHead = git(["rev-parse", "HEAD"], root).out;
|
|
551
|
+
const testCmd = detectTestCmd(root);
|
|
552
|
+
const ownRoom = !opts.room;
|
|
553
|
+
const db = opts.room?.db ?? new RoomDB(dbPath());
|
|
554
|
+
db.clearBoard();
|
|
555
|
+
const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
|
|
556
|
+
const taskIds = seedBoard(db, goal, plan.tasks);
|
|
557
|
+
// 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 } });
|
|
559
|
+
// Before the live listener below, so the council's memories aren't printed twice.
|
|
560
|
+
if (plan.council)
|
|
561
|
+
recordCouncil(db, plan.council, plan.tasks, taskIds);
|
|
562
|
+
print(`\n${C.violet("●")} room live ${C.faint("· " + room.url)} ${C.faint("· tests: " + (testCmd ?? "none yet"))}` +
|
|
563
|
+
` ${C.faint("· approvals: " + opts.approvals)}` +
|
|
564
|
+
(rl ? C.faint(" · ctrl-t live view · keep typing — board, questions · ctrl-c stops") : "") +
|
|
565
|
+
"\n");
|
|
566
|
+
const tag = (agent) => `${dot(agent)} ${pad(agent, 12)}`;
|
|
567
|
+
const act = (agent, text) => opts.onActivity?.({ agent, text });
|
|
568
|
+
const unsub = db.onEvent((e) => {
|
|
569
|
+
const p = JSON.parse(e.payload || "{}");
|
|
570
|
+
if (e.type === "task.progress" && p.note) {
|
|
571
|
+
const agent = (e.task_id ? db.getTask(e.task_id)?.claimed_by : "") ?? "";
|
|
572
|
+
print(` ${tag(agent)} ${C.faint(String(p.pct).padStart(3) + "%")} ${oneLine(p.note, 100)}`);
|
|
573
|
+
act(agent, `${C.faint(String(p.pct).padStart(3) + "%")} ${p.note}`);
|
|
574
|
+
}
|
|
575
|
+
else if (e.type === "memory.added" && p.author !== "huddle") {
|
|
576
|
+
print(` ${C.violet("◆")} ${pad(C.faint("memory"), 12)} ${oneLine(p.body, 100)}`);
|
|
577
|
+
act(undefined, `${C.violet("◆")} ${C.faint(p.author + " remembered:")} ${oneLine(p.body, 200)}`);
|
|
578
|
+
}
|
|
579
|
+
else if (e.type === "plan.announced" && e.agent_id) {
|
|
580
|
+
print(` ${tag(e.agent_id)} ${C.faint("plans:")} ${oneLine(p.intent ?? "", 100)}`);
|
|
581
|
+
act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
|
|
582
|
+
}
|
|
583
|
+
else if (e.type === "agent.message" && p.kind !== "question") {
|
|
584
|
+
// Agent to agent, live: "codex → claude: renamed greet() to hello()". (Questions show as asks.)
|
|
585
|
+
const arrow = `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${dot(p.to)} ${C.bold(p.to)}`;
|
|
586
|
+
print(` ${arrow}${C.faint(":")} ${oneLine(p.body, 110)}${p.kind === "answer" ? C.faint(" (answer)") : ""}`);
|
|
587
|
+
act(undefined, `${arrow}${C.faint(":")} ${oneLine(p.body, 200)}`);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
const t0 = Date.now();
|
|
591
|
+
const stats = newStats([...workers, ...remoteNow()].map((w) => w.id));
|
|
592
|
+
opts.onStats?.(stats);
|
|
593
|
+
const tok = (tokens, cached) => `${fmtTokens(tokens)} tok` + (cached > tokens / 2 ? ` (${fmtTokens(cached)} cached)` : "");
|
|
594
|
+
const cost = (ms, tokens, cached) => C.faint(` · ${fmtDuration(ms)} · ${tok(tokens, cached)}`);
|
|
595
|
+
if (remoteNow().length && !(remote && sync))
|
|
596
|
+
print(C.yellow(" ! teammates' agents need the code on a shared remote — `sync on` (and a GitHub remote) to hand them tasks"));
|
|
597
|
+
const boardOpts = {
|
|
598
|
+
db,
|
|
599
|
+
repoRoot: root,
|
|
600
|
+
bridge,
|
|
601
|
+
remoteWorkers: remoteNow,
|
|
602
|
+
// Teammates branch off what's pushed, so push main before each hand-off.
|
|
603
|
+
publish: () => {
|
|
604
|
+
if (!remote || !sync)
|
|
605
|
+
return null;
|
|
606
|
+
const p = pushMerged(root);
|
|
607
|
+
return p.ok ? git(["rev-parse", "HEAD"], root).out : null;
|
|
608
|
+
},
|
|
609
|
+
roomUrl: room.url,
|
|
610
|
+
roomSecret: room.secret,
|
|
611
|
+
workers,
|
|
612
|
+
testCmd: (worktree) => detectTestCmd(worktree),
|
|
613
|
+
approvals: opts.approvals,
|
|
614
|
+
signal: opts.signal,
|
|
615
|
+
approveMerge: opts.mode === "manual" && rl
|
|
616
|
+
? async ({ agent, title, diffstat }) => {
|
|
617
|
+
print(`\n ${tag(agent)} ${C.violet("◎ ready")} ${title}`);
|
|
618
|
+
for (const l of (diffstat || "(no changes)").split("\n"))
|
|
619
|
+
print(C.faint(" " + l.trim()));
|
|
620
|
+
return yes(rl, " Merge it?");
|
|
621
|
+
}
|
|
622
|
+
: undefined,
|
|
623
|
+
onEvent: (e) => {
|
|
624
|
+
// HUDDLE_EVENTS_FILE=<path>: every scheduler event as a JSON line (evals, post-mortems).
|
|
625
|
+
if (process.env.HUDDLE_EVENTS_FILE)
|
|
626
|
+
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), ...e }) + "\n");
|
|
627
|
+
if ("agent" in e && !stats.agents.has(e.agent))
|
|
628
|
+
stats.agents.set(e.agent, { busySince: null, liveTokens: 0, tokens: 0, cached: 0, ms: 0, tasks: 0, reviews: 0 });
|
|
629
|
+
const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
|
|
630
|
+
if (e.type === "start" && a)
|
|
631
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: 0 });
|
|
632
|
+
if (e.type === "tokens" && a)
|
|
633
|
+
a.liveTokens = e.tokens;
|
|
634
|
+
if (e.type === "start" && a)
|
|
635
|
+
a.liveTokens = -1; // -1 = not reported yet
|
|
636
|
+
if ((e.type === "merged" || e.type === "failed") && a)
|
|
637
|
+
Object.assign(a, {
|
|
638
|
+
busySince: null,
|
|
639
|
+
liveTokens: 0,
|
|
640
|
+
tokens: a.tokens + e.tokens,
|
|
641
|
+
cached: a.cached + e.cached,
|
|
642
|
+
ms: a.ms + e.ms,
|
|
643
|
+
tasks: a.tasks + 1,
|
|
644
|
+
});
|
|
645
|
+
if (e.type === "review" && a)
|
|
646
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
647
|
+
if (e.type === "reviewed" && a)
|
|
648
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms, reviews: a.reviews + 1 });
|
|
649
|
+
if (e.type === "takeover") {
|
|
650
|
+
// The author's rounds end here (no merged/failed event for it): count them.
|
|
651
|
+
const f = e.kind === "rescue" ? stats.agents.get(e.from) : undefined;
|
|
652
|
+
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 });
|
|
654
|
+
const what = e.kind === "rescue" ? `took over ${e.from}'s` : `took part of ${e.from}'s task:`;
|
|
655
|
+
act(e.agent, `${C.yellow("⤳")} ${what} ${e.title} ${C.faint("· " + e.reason)}`);
|
|
656
|
+
print(` ${tag(e.agent)} ${C.yellow("⤳ " + what)} “${e.title}” ${C.faint("· " + oneLine(e.reason, 110))}`);
|
|
657
|
+
}
|
|
658
|
+
if (e.type === "start")
|
|
659
|
+
act(e.agent, `${C.violet("▸")} ${e.takeover ? `continuing ${e.takeover}'s work: ` : e.fixing ? "fixing after review: " : ""}${e.title}`);
|
|
660
|
+
if (e.type === "merged")
|
|
661
|
+
act(e.agent, `${C.green("✓ merged")} ${e.title}`);
|
|
662
|
+
if (e.type === "failed")
|
|
663
|
+
act(e.agent, `${C.red("✗ failed")} ${e.title} ${C.faint("· " + e.reason)}`);
|
|
664
|
+
if (e.type === "review")
|
|
665
|
+
act(e.agent, `${C.violet("◎")} reviewing ${e.author}'s work`);
|
|
666
|
+
if (e.type === "reviewed")
|
|
667
|
+
act(e.agent, e.approve ? C.green("✓ approved " + e.author) : C.yellow("✎ asked " + e.author + " for changes"));
|
|
668
|
+
if (e.type === "start")
|
|
669
|
+
print(` ${tag(e.agent)} ${C.violet("▸")} ${e.takeover ? C.yellow(`continuing ${e.takeover}'s work: `) : e.fixing ? C.yellow("fixing after review: ") : ""}${e.title}` +
|
|
670
|
+
(e.remote ? C.faint(` · offered to ${e.remote}`) : ""));
|
|
671
|
+
if (e.type === "handoff")
|
|
672
|
+
print(` ${tag(e.agent)} ${C.violet("⇢")} handoff from ${C.bold(e.from)} ${C.faint(`· ${e.files} file(s), ${e.symbols} export(s) · “${e.title}”`)}`);
|
|
673
|
+
if (e.type === "declined")
|
|
674
|
+
print(` ${tag(e.agent)} ${C.faint("✗ declined · back on the board")} ${C.faint(e.title)}`);
|
|
675
|
+
if (e.type === "review")
|
|
676
|
+
print(` ${tag(e.agent)} ${C.violet("◎")} reviewing ${e.author}'s ${C.faint("“" + e.title + "”")}`);
|
|
677
|
+
if (e.type === "reviewed") {
|
|
678
|
+
if (e.skipped)
|
|
679
|
+
print(` ${tag(e.agent)} ${C.faint("○ review skipped")} ${C.faint("· " + e.notes + " · " + fmtDuration(e.ms) + " · merging on tests alone")}`);
|
|
680
|
+
else if (e.approve)
|
|
681
|
+
print(` ${tag(e.agent)} ${C.green("✓ approved")} ${e.author}'s work ${C.faint("· " + fmtDuration(e.ms))}`);
|
|
682
|
+
else {
|
|
683
|
+
print(` ${tag(e.agent)} ${C.yellow("✎ asked for changes")} ${C.faint("· " + fmtDuration(e.ms) + " · back to " + e.author)}`);
|
|
684
|
+
for (const l of e.notes.split("\n").slice(0, 4))
|
|
685
|
+
print(C.faint(` ${oneLine(l, 110)}`));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
if (e.type === "merged")
|
|
689
|
+
print(` ${tag(e.agent)} ${C.green("✓ merged")} ${e.title} ${C.faint("· " + e.reason)}${cost(e.ms, e.tokens, e.cached)}`);
|
|
690
|
+
if (e.type === "failed")
|
|
691
|
+
print(` ${tag(e.agent)} ${e.needsYou ? C.yellow("! needs you") : C.red("✗ failed")} ${e.title} ${C.faint("· " + e.reason)}${cost(e.ms, e.tokens, e.cached)} ${C.faint("· log: " + tildify(e.log))}`);
|
|
692
|
+
if (e.type === "conflict" && a)
|
|
693
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
694
|
+
if (e.type === "resolved" && a)
|
|
695
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms });
|
|
696
|
+
if (e.type === "conflict") {
|
|
697
|
+
act(e.agent, `${C.yellow("⇄")} resolving a conflict in ${e.author}'s work`);
|
|
698
|
+
print(` ${tag(e.agent)} ${C.yellow("⇄ resolving conflict")} ${C.faint("in " + e.files.join(", "))} ${e.title}` +
|
|
699
|
+
C.faint(` · ${e.agent === e.author ? "its own work" : e.author + "'s work"} vs main · try ${e.attempt}`));
|
|
700
|
+
}
|
|
701
|
+
if (e.type === "resolved")
|
|
702
|
+
print(` ${tag(e.agent)} ${e.ok ? C.green("✓ resolved") : C.yellow("✗ resolution rejected")} ${C.faint("· " + e.reason + " · " + fmtDuration(e.ms))}`);
|
|
703
|
+
if (e.type === "stuck")
|
|
704
|
+
print(C.yellow(` ! ${e.blocked.length} task(s) blocked by failures: `) + C.faint(e.blocked.join(" · ")));
|
|
705
|
+
},
|
|
706
|
+
};
|
|
707
|
+
const res = await runBoard(boardOpts);
|
|
708
|
+
// QA: agents that didn't write the code re-check the integrated main; bugs
|
|
709
|
+
// they file become routed fix tasks, run through this same board.
|
|
710
|
+
let qaNote = "";
|
|
711
|
+
if (!res.stopped && res.merged && shouldQa(goal, { flag: opts.qa, tasks: plan.tasks.length, repoFiles })) {
|
|
712
|
+
if (res.failed)
|
|
713
|
+
print(C.faint(`\n QA skipped — ${res.failed} task(s) didn't merge; it checks finished builds only`));
|
|
714
|
+
else {
|
|
715
|
+
print(`\n${C.violet("QA")} ${C.faint("· agents that didn't write it check the build · --no-qa to skip")}`);
|
|
716
|
+
const qa = await runQa({
|
|
717
|
+
db,
|
|
718
|
+
room,
|
|
719
|
+
repoRoot: root,
|
|
720
|
+
goal,
|
|
721
|
+
startHead,
|
|
722
|
+
workers,
|
|
723
|
+
approvals: opts.approvals,
|
|
724
|
+
testCmd: (wt) => detectTestCmd(wt),
|
|
725
|
+
signal: opts.signal,
|
|
726
|
+
runFixes: () => runBoard(boardOpts),
|
|
727
|
+
onEvent: (e) => {
|
|
728
|
+
if (process.env.HUDDLE_EVENTS_FILE)
|
|
729
|
+
fs.appendFileSync(process.env.HUDDLE_EVENTS_FILE, JSON.stringify({ ts: Date.now(), qa: true, ...e }) + "\n");
|
|
730
|
+
qaLine(e, tag, print, act, stats);
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
res.merged += qa.fixed;
|
|
734
|
+
res.failed += qa.failed;
|
|
735
|
+
res.stopped ||= qa.stopped;
|
|
736
|
+
qaNote = qa.clean
|
|
737
|
+
? `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
|
+
: `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
|
+
}
|
|
740
|
+
}
|
|
741
|
+
unsub();
|
|
742
|
+
const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
|
|
743
|
+
const parked = db.listTasks().filter((t) => t.status === "needs_you");
|
|
744
|
+
const takeovers = new Map(); // task id -> the branch a rescuer continued on
|
|
745
|
+
for (const ev of db.eventsOfType(["task.takeover"], t0 - 1, 500)) {
|
|
746
|
+
const p = JSON.parse(ev.payload || "{}");
|
|
747
|
+
if (ev.task_id && p.kind === "rescue" && p.branch)
|
|
748
|
+
takeovers.set(ev.task_id, String(p.branch));
|
|
749
|
+
}
|
|
750
|
+
if (ownRoom) {
|
|
751
|
+
room.close();
|
|
752
|
+
db.close();
|
|
753
|
+
}
|
|
754
|
+
opts.onStats?.(stats);
|
|
755
|
+
const head = res.stopped ? C.yellow("■ stopped") : res.failed ? C.yellow("◎") : C.green("✓");
|
|
756
|
+
const total = [...stats.agents.values()].reduce((n, a) => n + a.tokens, 0);
|
|
757
|
+
const totalCached = [...stats.agents.values()].reduce((n, a) => n + a.cached, 0);
|
|
758
|
+
print(`\n${head} ${res.merged} merged, ${res.failed} failed ${C.faint(`· ${fmtDuration(Date.now() - t0)} · ${tok(total, totalCached)}`)}`);
|
|
759
|
+
for (const [id, a] of stats.agents) {
|
|
760
|
+
if (!a.tasks && !a.reviews && !a.qa) {
|
|
761
|
+
print(` ${tag(id)} ${C.faint("idle this run")}`);
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const did = [
|
|
765
|
+
a.tasks ? `${a.tasks} task${a.tasks > 1 ? "s" : ""}` : "",
|
|
766
|
+
a.reviews ? `${a.reviews} review${a.reviews > 1 ? "s" : ""}` : "",
|
|
767
|
+
a.qa ? `${a.qa} QA` : "",
|
|
768
|
+
]
|
|
769
|
+
.filter(Boolean)
|
|
770
|
+
.join(" + ");
|
|
771
|
+
print(` ${tag(id)} ${pad(did, 18)} ${pad(fmtDuration(a.ms), 7)} ${a.tasks ? tok(a.tokens, a.cached) : ""}`);
|
|
772
|
+
}
|
|
773
|
+
for (const t of parked) {
|
|
774
|
+
// Never resolved by force: the branch holds the agent's work exactly as it left it.
|
|
775
|
+
// A taken-over task still lives on its first agent's branch.
|
|
776
|
+
const tk = takeovers.get(t.id);
|
|
777
|
+
const wt = tk ? { branch: tk } : worktreeFor(root, t.claimed_by ?? "", t.id);
|
|
778
|
+
print(C.yellow(` ! needs you: ${t.title}`) + C.faint(` — ${tk ? "two agents couldn't land it" : "conflicts with main"}; work kept on ${wt.branch}`));
|
|
779
|
+
print(C.faint(` to take it yourself: git merge ${wt.branch} # resolve the markers, then commit`));
|
|
780
|
+
}
|
|
781
|
+
if (res.merged && remote && sync) {
|
|
782
|
+
const p = pushMerged(root);
|
|
783
|
+
print(p.ok ? ` ${C.green("↑")} git push · all in one go ${C.faint("→ " + p.note)}` : C.yellow(` ! not pushed: ${p.note}`));
|
|
784
|
+
}
|
|
785
|
+
if (res.merged)
|
|
786
|
+
await deliver(print, goal, root, startHead, doneNotes, brain, reportFacts(root, startHead, { ...res, testCmd: detectTestCmd(root), qaNote }));
|
|
787
|
+
}
|
|
788
|
+
/** QA's readable lines: "cursor (QA) found: …", "→ fix assigned to codex". */
|
|
789
|
+
function qaLine(e, tag, print, act, stats) {
|
|
790
|
+
const a = "agent" in e ? stats.agents.get(e.agent) : undefined;
|
|
791
|
+
if (e.type === "round")
|
|
792
|
+
print(` ${C.violet("◎")} ${C.bold(`QA round ${e.round}`)} ${C.faint("· checked by " + e.agents.join(", "))}`);
|
|
793
|
+
if (e.type === "start") {
|
|
794
|
+
if (a)
|
|
795
|
+
Object.assign(a, { busySince: Date.now(), liveTokens: -1 });
|
|
796
|
+
print(` ${tag(e.agent)} ${C.violet("◎")} QA: tests, using it, reading the diff${e.author ? C.faint(" · (everyone wrote some — checking anyway)") : ""}`);
|
|
797
|
+
act(e.agent, `${C.violet("◎")} QA round ${e.round}`);
|
|
798
|
+
}
|
|
799
|
+
if (e.type === "bug") {
|
|
800
|
+
print(` ${tag(e.agent)} ${C.yellow("✗ (QA) found:")} ${oneLine(e.title, 90)}${e.files.length ? C.faint(" · " + e.files.join(", ")) : ""}${e.test ? C.faint(" · + regression test") : ""}`);
|
|
801
|
+
print(` ${pad("", 14)} ${C.faint("→ fix assigned to")} ${e.assignedTo ? `${dot(e.assignedTo)} ${e.assignedTo}` : C.faint("any free agent")}`);
|
|
802
|
+
act(e.agent, `${C.yellow("✗ found:")} ${e.title} ${C.faint("→ " + (e.assignedTo ?? "anyone"))}`);
|
|
803
|
+
}
|
|
804
|
+
if (e.type === "duplicate")
|
|
805
|
+
print(` ${tag(e.agent)} ${C.faint(`= duplicate of “${oneLine(e.of, 70)}” · not filed again`)}`);
|
|
806
|
+
if (e.type === "checked") {
|
|
807
|
+
if (a)
|
|
808
|
+
Object.assign(a, { busySince: null, ms: a.ms + e.ms, tokens: a.tokens + e.tokens, qa: (a.qa ?? 0) + 1 });
|
|
809
|
+
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
|
+
}
|
|
811
|
+
if (e.type === "fixes")
|
|
812
|
+
print(` ${C.violet("◎")} ${C.faint(`QA round ${e.round} fixes: ${e.merged} merged, ${e.failed} failed`)}`);
|
|
813
|
+
if (e.type === "clean")
|
|
814
|
+
print(` ${C.green("✓")} QA round ${e.round} clean ${C.faint("· nothing found")}`);
|
|
815
|
+
if (e.type === "unchecked")
|
|
816
|
+
print(C.yellow(` ! QA round ${e.round}: the build was NOT checked`) + C.faint(` · ${e.reason}`));
|
|
817
|
+
if (e.type === "capped")
|
|
818
|
+
print(C.yellow(` ! QA stopped after ${e.round} round(s)`) + C.faint(` · the last round's fixes weren't re-checked${e.open ? ` · ${e.open} still open` : ""}`));
|
|
819
|
+
if (e.type === "skipped")
|
|
820
|
+
print(C.faint(` QA skipped — ${e.reason}`));
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* What Huddle itself verified during the run, for the report: the brain only
|
|
824
|
+
* sees notes and a diffstat, so without these it hedges. Found live (capstone
|
|
825
|
+
* round 3): "I don't know whether the final state is committed" and "the agents
|
|
826
|
+
* reported 82/82, I did not re-run them" — after Huddle had merged all 10 tasks
|
|
827
|
+
* as commits, each through its test gate, and QA had come back clean. The same
|
|
828
|
+
* report blamed "plan mode" — the brain's own read-only flag — so the brief says
|
|
829
|
+
* that's Huddle's choice, not the run's.
|
|
830
|
+
*/
|
|
831
|
+
export function reportFacts(root, startHead, o) {
|
|
832
|
+
const subjects = git(["log", "--format=%s", `${startHead}..HEAD`], root).out.split("\n").filter(Boolean);
|
|
833
|
+
const branch = git(["rev-parse", "--abbrev-ref", "HEAD"], root).out.trim() || "the current branch";
|
|
834
|
+
const facts = [
|
|
835
|
+
`${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).`,
|
|
836
|
+
o.testCmd
|
|
837
|
+
? `Huddle ran \`${o.testCmd}\` before every merge, after rebasing onto the latest ${branch}; nothing merged with failing tests.`
|
|
838
|
+
: "No test command was detected, so merges were not gated on tests.",
|
|
839
|
+
];
|
|
840
|
+
if (o.qaNote)
|
|
841
|
+
facts.push(o.qaNote);
|
|
842
|
+
if (o.stopped)
|
|
843
|
+
facts.push("The run was stopped before every task finished.");
|
|
844
|
+
return facts;
|
|
845
|
+
}
|
|
846
|
+
/** The brain's prompt for the "what you got" report. */
|
|
847
|
+
export function reportPrompt(o) {
|
|
848
|
+
const facts = o.facts?.length
|
|
849
|
+
? `\nVERIFIED BY HUDDLE (facts — state them plainly; never say you don't know whether the work is committed or tested):\n${o.facts.map((f) => "- " + f).join("\n")}\n`
|
|
850
|
+
: "";
|
|
851
|
+
return `Several AI agents just finished this goal in the repo in your current directory: "${o.goal}".
|
|
852
|
+
Their notes:\n${o.notes.join("\n")}\n\nFiles changed:\n${o.stat}\n${facts}
|
|
853
|
+
Write the final report for the user, like the last message of a coding session. Plain text, no markdown headings, under 15 lines:
|
|
854
|
+
1) one or two sentences on what now exists, 2) exactly how to run or try it (real commands for this repo),
|
|
855
|
+
3) anything unfinished or worth checking. ${o.readsRepo ? "Read the repo if you need to confirm commands. " : ""}Do not edit anything.
|
|
856
|
+
You run read-only because Huddle only asks you for this write-up; the run itself was not in plan mode, so don't mention plan mode or your own read-only limits.`;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* The "what you got" report — like the last message from Claude Code: what was
|
|
860
|
+
* built, how to run it, what's left. Written read-only by one agent from the
|
|
861
|
+
* merged diff and the agents' done notes; falls back to the raw notes.
|
|
862
|
+
*/
|
|
863
|
+
async function deliver(print, goal, root, startHead, notes, brain, facts = []) {
|
|
864
|
+
const stat = git(["diff", "--stat", `${startHead}..HEAD`], root).out;
|
|
865
|
+
let summary = "";
|
|
866
|
+
if (brain) {
|
|
867
|
+
const prompt = reportPrompt({ goal, notes, stat, facts, readsRepo: brain.readsRepo });
|
|
868
|
+
const r = await withSpinner(`${brain.label} is writing up what you got…`, brain.think(prompt, root, { timeoutMs: 3 * 60_000 }));
|
|
869
|
+
if (r.ok)
|
|
870
|
+
summary = r.text;
|
|
871
|
+
}
|
|
872
|
+
print(`\n${C.violet("WHAT YOU GOT")} ${C.faint("· " + shortGoal(goal))}`);
|
|
873
|
+
if (summary)
|
|
874
|
+
for (const l of summary.split("\n"))
|
|
875
|
+
print(" " + l);
|
|
876
|
+
else
|
|
877
|
+
for (const n of notes)
|
|
878
|
+
print(" " + oneLine(n, 160));
|
|
879
|
+
if (stat)
|
|
880
|
+
print(C.faint("\n " + stat.split("\n").pop().trim() + C.faint(" · see it all: git log")));
|
|
881
|
+
}
|
|
882
|
+
/** Answer a question about the repo, read-only. */
|
|
883
|
+
async function answer(print, question, results, signal) {
|
|
884
|
+
// Questions need to look around the repo, so a local brain (which can't read
|
|
885
|
+
// files) hands them to the best agent instead.
|
|
886
|
+
let brain = resolveBrain(results);
|
|
887
|
+
if (brain && !brain.readsRepo) {
|
|
888
|
+
const asker = pickAsker(workersFrom(results).map((w) => w.adapter));
|
|
889
|
+
brain = asker ? brainFrom(asker.id === "claude" ? "claude:sonnet" : asker.id) : null;
|
|
890
|
+
}
|
|
891
|
+
if (!brain) {
|
|
892
|
+
print(C.red(" ✗ no agent ready to answer.") + " Type `agents`.");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
const prompt = `${question}\n\n(Answer about the project in your current directory. You are read-only: look around, don't edit. Be concise and concrete — plain text, short lines, no markdown headings.)`;
|
|
896
|
+
const r = await withSpinner(`${brain.label} is looking…`, brain.think(prompt, process.cwd(), { signal }));
|
|
897
|
+
const who = brain.spec.split(":")[0];
|
|
898
|
+
print(`\n${dot(who)} ${C.bold(brain.label)} ${C.faint("· " + fmtDuration(r.ms))}`);
|
|
899
|
+
for (const l of (r.text || "(no answer)").split("\n"))
|
|
900
|
+
print(" " + l);
|
|
901
|
+
}
|
|
902
|
+
// ---- board -------------------------------------------------------------------
|
|
903
|
+
export function printBoard(print = (l = "") => console.log(l)) {
|
|
904
|
+
if (!fs.existsSync(dbPath())) {
|
|
905
|
+
print(C.faint(" no board yet — type a goal to start."));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const db = new RoomDB(dbPath());
|
|
909
|
+
const s = db.currentSession();
|
|
910
|
+
print(`\n${C.violet("BOARD")} ${C.faint("· " + (s?.goal ?? "(no goal yet)"))}`);
|
|
911
|
+
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
|
+
const who = t.claimed_by ? `${dot(t.claimed_by)} ${t.claimed_by}` : C.faint("unclaimed");
|
|
916
|
+
print(` ${statusLabel(t.status)} ${bar} ${String(t.progress_pct).padStart(3)}% ${t.title} ${who}`);
|
|
917
|
+
}
|
|
918
|
+
const mem = db.recall();
|
|
919
|
+
if (mem.length) {
|
|
920
|
+
print(`\n${C.violet("SHARED MEMORY")}`);
|
|
921
|
+
for (const m of mem.slice(0, 8))
|
|
922
|
+
print(` ${C.faint("•")} ${m.body}${m.why ? C.faint(" — why: " + m.why) : ""} ${C.faint("(" + m.author + ")")}`);
|
|
923
|
+
}
|
|
924
|
+
print();
|
|
925
|
+
db.close();
|
|
926
|
+
}
|
|
927
|
+
function statusLabel(s) {
|
|
928
|
+
const map = {
|
|
929
|
+
merged: C.green("✓ merged "),
|
|
930
|
+
failed: C.red("✗ failed "),
|
|
931
|
+
in_progress: C.yellow("▸ working"),
|
|
932
|
+
review: C.yellow("◎ review "),
|
|
933
|
+
claimed: C.faint("◐ claimed"),
|
|
934
|
+
};
|
|
935
|
+
return map[s] ?? C.faint("○ backlog");
|
|
936
|
+
}
|
|
937
|
+
// ---- the REPL ------------------------------------------------------------------
|
|
938
|
+
const MODES = ["manual", "auto", "plan"];
|
|
939
|
+
const MODE_INFO = {
|
|
940
|
+
manual: { chip: C.faint("◇ manual"), desc: "you approve the plan and every merge" },
|
|
941
|
+
auto: { chip: C.violet("⏵⏵ auto"), desc: "runs and merges on green, no questions" },
|
|
942
|
+
plan: { chip: C.hex("#4AA3A2")("⏸ plan"), desc: "shows the plan only, runs nothing" },
|
|
943
|
+
};
|
|
944
|
+
const APPROVALS = {
|
|
945
|
+
safe: "file edits only — commands are refused",
|
|
946
|
+
auto: "skip most approvals — commands run sandboxed",
|
|
947
|
+
yolo: "approve everything — no sandbox",
|
|
948
|
+
};
|
|
949
|
+
const HELP = `
|
|
950
|
+
${C.bold("<goal>")} just type what you want built
|
|
951
|
+
${C.bold("<question>?")} ask about the project — answered read-only, nothing changes
|
|
952
|
+
${C.bold("build <goal>")} force a build ${C.faint("·")} ${C.bold("ask <question>")} force a question
|
|
953
|
+
${C.bold("<goal> --qa")} ${C.faint("/")} ${C.bold("--no-qa")} agents re-check the build and fix what they find ${C.faint("(default: big goals)")}
|
|
954
|
+
${C.bold("shift+tab")} switch mode: ${MODES.map((m) => MODE_INFO[m].chip).join(C.faint(" → "))}
|
|
955
|
+
${C.bold("approvals")} ${C.faint("[level]")} what agents may do without asking: safe · auto · yolo
|
|
956
|
+
${C.bold("brain")} ${C.faint("[model]")} what Huddle plans & reviews with — e.g. a free local model
|
|
957
|
+
${C.bold("crew")} ${C.faint("[agent=n …]")} how many of each agent: crew claude=2 codex=1 cursor=0
|
|
958
|
+
${C.bold("invite")} ${C.faint("@user [guest]")} let a GitHub user into this room — one-time code, 10 min
|
|
959
|
+
${C.bold("members")} who's in the room ${C.faint("·")} ${C.bold("remove <name>")} take someone out, instantly
|
|
960
|
+
${C.bold("sync")} ${C.faint("[on|off]")} push merged work to GitHub after each run (asked once per repo)
|
|
961
|
+
${C.bold("relay")} ${C.faint("[url|off]")} a relay so invites work across any networks
|
|
962
|
+
${C.bold("funnel")} ${C.faint("[on|off]")} public https invites through Tailscale Funnel
|
|
963
|
+
${C.bold("ctrl-t")} ${C.faint("or")} ${C.bold("view")} the live view — 1 agents · 2 plan · 3 board · q to come back
|
|
964
|
+
${C.bold("board")} the task board and shared memory
|
|
965
|
+
${C.bold("agents")} reconnect agents; install or sign in to missing ones
|
|
966
|
+
${C.bold("cd <dir>")} move to another project ${C.faint("·")} ${C.bold("!<cmd>")} run a shell command
|
|
967
|
+
${C.bold("ctrl-c")} stop a run ${C.faint("(or clear the line)")}
|
|
968
|
+
${C.bold("exit")} leave ${C.faint("(or ctrl-d)")}
|
|
969
|
+
|
|
970
|
+
${C.faint("While agents work you can keep typing: board, approvals and questions work live; a new goal is queued.")}
|
|
971
|
+
`;
|
|
972
|
+
// Things people type out of habit that are shell commands, not goals.
|
|
973
|
+
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"]);
|
|
974
|
+
const QUESTION = /^(what|what's|where|why|how|who|when|which|is|are|does|do|did|can|could|should|would|will|explain|tell me|show me|summari[sz]e|describe|walk me through)\b/i;
|
|
975
|
+
function isQuestion(line) {
|
|
976
|
+
return line.trim().endsWith("?") || QUESTION.test(line.trim());
|
|
977
|
+
}
|
|
978
|
+
export async function runApp() {
|
|
979
|
+
const gitOk = isGitRepo(process.cwd());
|
|
980
|
+
if (gitOk)
|
|
981
|
+
ensureInit();
|
|
982
|
+
printWelcome(gitOk);
|
|
983
|
+
let results = await connectAgents();
|
|
984
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
985
|
+
if (await offerSetup(rl, results))
|
|
986
|
+
results = await connectAgents();
|
|
987
|
+
const prefs = readPrefs();
|
|
988
|
+
const state = { mode: prefs.mode ?? "manual", approvals: prefs.approvals ?? "auto" };
|
|
989
|
+
const save = () => writePrefs({ ...readPrefs(), mode: state.mode, approvals: state.approvals });
|
|
990
|
+
await offerChildRepo((l = "") => console.log(l), rl);
|
|
991
|
+
const ready = results.filter((r) => r.state === "ready").length;
|
|
992
|
+
console.log(C.faint(`\n${ready} of ${results.length} agents in the room. Type a goal, a question, or \`help\`.`));
|
|
993
|
+
const out = process.stdout;
|
|
994
|
+
const prompt = C.violet("huddle ›") + " ";
|
|
995
|
+
let promptShown = false; // prompt + footer are on screen and live
|
|
996
|
+
let asking = false; // a y/n question owns the line
|
|
997
|
+
const held = []; // output that arrived during a question
|
|
998
|
+
let run = null;
|
|
999
|
+
const queue = [];
|
|
1000
|
+
let stats = null;
|
|
1001
|
+
let ticker = null;
|
|
1002
|
+
/** "⏱ 1m12s · ● claude 40s 31k · ● codex 22s 15k · ○ cursor" */
|
|
1003
|
+
const liveStats = () => {
|
|
1004
|
+
if (!stats)
|
|
1005
|
+
return C.violet("● planning…");
|
|
1006
|
+
const now = Date.now();
|
|
1007
|
+
const parts = [...stats.agents].map(([id, a]) => a.busySince
|
|
1008
|
+
? `${dot(id)} ${id} ${fmtDuration(now - a.busySince)} ${C.faint(a.liveTokens < 0 ? "…" : fmtTokens(a.liveTokens) + " tok")}`
|
|
1009
|
+
: C.faint(`○ ${id}`));
|
|
1010
|
+
return `${C.faint("⏱")} ${fmtDuration(now - stats.startedAt)} ${parts.join(" ")}`;
|
|
1011
|
+
};
|
|
1012
|
+
// Footer under the prompt, like Claude Code's mode line.
|
|
1013
|
+
const footer = () => run
|
|
1014
|
+
? ` ${MODE_INFO[state.mode].chip} ${liveStats()}${queue.length ? C.faint(` · ${queue.length} queued`) : ""}`
|
|
1015
|
+
: ` ${MODE_INFO[state.mode].chip} ${C.faint("· " + MODE_INFO[state.mode].desc)} ` +
|
|
1016
|
+
`${C.faint("approvals:")} ${state.approvals} ${C.faint("shift+tab to switch")}`;
|
|
1017
|
+
const drawFooter = () => {
|
|
1018
|
+
if (!promptShown || asking || !out.isTTY)
|
|
1019
|
+
return;
|
|
1020
|
+
// Never wrap: a wrapped footer would break the cursor save/restore.
|
|
1021
|
+
out.write("\x1b7\x1b[1B\r\x1b[2K" + truncate(footer(), (out.columns || 80) - 1) + "\x1b8");
|
|
1022
|
+
};
|
|
1023
|
+
const showPrompt = () => {
|
|
1024
|
+
out.write("\n\x1b[1A"); // reserve the footer line below
|
|
1025
|
+
rl.setPrompt(prompt);
|
|
1026
|
+
rl.prompt(true);
|
|
1027
|
+
promptShown = true;
|
|
1028
|
+
drawFooter();
|
|
1029
|
+
};
|
|
1030
|
+
// The live view (ctrl-t / `view`). While it's up, prompt output waits.
|
|
1031
|
+
const dash = new Dashboard({
|
|
1032
|
+
db: () => live?.db ?? null,
|
|
1033
|
+
stats: () => stats,
|
|
1034
|
+
modeChip: () => MODE_INFO[state.mode].chip,
|
|
1035
|
+
});
|
|
1036
|
+
const openView = () => {
|
|
1037
|
+
if (asking || !out.isTTY)
|
|
1038
|
+
return;
|
|
1039
|
+
promptShown = false;
|
|
1040
|
+
dash.show(undefined, () => {
|
|
1041
|
+
rl.write(null, { ctrl: true, name: "u" }); // drop keys typed into the view
|
|
1042
|
+
for (const l of held.splice(0))
|
|
1043
|
+
console.log(l);
|
|
1044
|
+
showPrompt();
|
|
1045
|
+
});
|
|
1046
|
+
};
|
|
1047
|
+
// Print above the live prompt, then redraw it with whatever was being typed.
|
|
1048
|
+
const print = (line = "") => {
|
|
1049
|
+
if (asking || dash.isOpen())
|
|
1050
|
+
return void held.push(line);
|
|
1051
|
+
if (!promptShown || !out.isTTY)
|
|
1052
|
+
return void console.log(line);
|
|
1053
|
+
out.write("\r\x1b[J"); // clear prompt + footer
|
|
1054
|
+
console.log(line);
|
|
1055
|
+
showPrompt();
|
|
1056
|
+
};
|
|
1057
|
+
questionHooks.before = () => {
|
|
1058
|
+
if (dash.isOpen()) {
|
|
1059
|
+
dash.hide();
|
|
1060
|
+
for (const l of held.splice(0))
|
|
1061
|
+
console.log(l);
|
|
1062
|
+
}
|
|
1063
|
+
if (promptShown && out.isTTY)
|
|
1064
|
+
out.write("\r\x1b[J");
|
|
1065
|
+
promptShown = false;
|
|
1066
|
+
asking = true;
|
|
1067
|
+
};
|
|
1068
|
+
questionHooks.after = () => {
|
|
1069
|
+
asking = false;
|
|
1070
|
+
for (const l of held.splice(0))
|
|
1071
|
+
console.log(l);
|
|
1072
|
+
if (run)
|
|
1073
|
+
showPrompt(); // back to the live prompt mid-run
|
|
1074
|
+
};
|
|
1075
|
+
process.stdin.on("keypress", (_s, key) => {
|
|
1076
|
+
if (!key)
|
|
1077
|
+
return;
|
|
1078
|
+
if (key.ctrl && key.name === "t" && !asking && !dash.isOpen())
|
|
1079
|
+
return void setImmediate(openView);
|
|
1080
|
+
if (dash.isOpen())
|
|
1081
|
+
return;
|
|
1082
|
+
if (key.name === "tab" && key.shift && !asking) {
|
|
1083
|
+
state.mode = MODES[(MODES.indexOf(state.mode) + 1) % MODES.length];
|
|
1084
|
+
save();
|
|
1085
|
+
}
|
|
1086
|
+
setImmediate(drawFooter); // readline may have cleared below the prompt
|
|
1087
|
+
});
|
|
1088
|
+
rl.on("SIGINT", () => {
|
|
1089
|
+
if (run) {
|
|
1090
|
+
print(C.yellow(" stopping agents…"));
|
|
1091
|
+
queue.length = 0;
|
|
1092
|
+
run.abort();
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (asking)
|
|
1096
|
+
return;
|
|
1097
|
+
if (rl.line) {
|
|
1098
|
+
rl.write(null, { ctrl: true, name: "u" }); // clear the line, like a shell
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
print(C.faint(" (type exit or press ctrl-d to leave)"));
|
|
1102
|
+
});
|
|
1103
|
+
rl.on("close", () => {
|
|
1104
|
+
out.write("\r\x1b[J");
|
|
1105
|
+
console.log(C.faint("bye 👋"));
|
|
1106
|
+
process.exit(0); // the scheduler's exit hook stops any running agents
|
|
1107
|
+
});
|
|
1108
|
+
const setApprovals = async (arg) => {
|
|
1109
|
+
const level = arg;
|
|
1110
|
+
if (!(level in APPROVALS)) {
|
|
1111
|
+
print();
|
|
1112
|
+
for (const [k, v] of Object.entries(APPROVALS))
|
|
1113
|
+
print(` ${k === state.approvals ? C.violet("●") : C.faint("○")} ${pad(C.bold(k), 6)} ${C.faint(v)}`);
|
|
1114
|
+
print(C.faint("\n set with: approvals safe | auto | yolo" + (run ? " — applies to agents that start after this" : "")));
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (level === "yolo" && state.approvals !== "yolo") {
|
|
1118
|
+
print(C.yellow("\n yolo lets agents run any command on this machine without a sandbox."));
|
|
1119
|
+
if (!(await yes(rl, " Turn it on?", false)))
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
state.approvals = level;
|
|
1123
|
+
save();
|
|
1124
|
+
print(` ${C.green("✓")} approvals: ${level} ${C.faint("· " + APPROVALS[level] + (run ? " (from the next agent that starts)" : ""))}`);
|
|
1125
|
+
};
|
|
1126
|
+
// ---- the long-lived Room + multiplayer --------------------------------
|
|
1127
|
+
let live = null;
|
|
1128
|
+
let tunnel = null;
|
|
1129
|
+
let relayLink = null;
|
|
1130
|
+
let funnel = null;
|
|
1131
|
+
/** The Room for the current repo, opened once and kept for the session. */
|
|
1132
|
+
const openRoom = async () => {
|
|
1133
|
+
if (!isGitRepo(process.cwd()))
|
|
1134
|
+
return null;
|
|
1135
|
+
const root = repoRoot();
|
|
1136
|
+
if (live?.root === root)
|
|
1137
|
+
return live;
|
|
1138
|
+
closeRoom();
|
|
1139
|
+
ensureInit();
|
|
1140
|
+
const db = new RoomDB(dbPath());
|
|
1141
|
+
const r0 = remoteInfo(root);
|
|
1142
|
+
const server = await startRoom(db, ROOM_PORT, {
|
|
1143
|
+
remote: r0 ? canonicalRemote(r0.url) : undefined,
|
|
1144
|
+
branch: r0?.branch,
|
|
1145
|
+
host: githubUser() ?? os.userInfo().username,
|
|
1146
|
+
});
|
|
1147
|
+
// huddle_ask: answer agents' questions from shared memory, with the brain.
|
|
1148
|
+
server.answerer = async (question) => {
|
|
1149
|
+
const mem = db.recall().slice(0, 60);
|
|
1150
|
+
if (!mem.length)
|
|
1151
|
+
return "";
|
|
1152
|
+
const brain = resolveBrain(results);
|
|
1153
|
+
if (!brain)
|
|
1154
|
+
return mem.find((m) => question.split(/\W+/).some((w) => w.length > 3 && m.body.includes(w)))?.body ?? "";
|
|
1155
|
+
const r = await brain.think(`An agent on the team asked: "${question}"\nAnswer ONLY from this shared memory (decisions and facts other agents recorded). ` +
|
|
1156
|
+
`One or two sentences, quote exact names/shapes. If the memory doesn't say, reply exactly: UNKNOWN\n\n` +
|
|
1157
|
+
mem.map((m) => `- (${m.author}) ${m.body}${m.why ? " — why: " + m.why : ""}`).join("\n"), root, { selfContained: true, timeoutMs: 60_000 });
|
|
1158
|
+
return r.ok && !/^UNKNOWN/i.test(r.text.trim()) ? r.text.trim() : "";
|
|
1159
|
+
};
|
|
1160
|
+
const unsub = db.onEvent((e) => {
|
|
1161
|
+
const p = JSON.parse(e.payload || "{}");
|
|
1162
|
+
if (e.type === "member.joined")
|
|
1163
|
+
print(` ${C.green("●")} ${C.bold(p.name)} joined the room ${C.faint("· " + p.role)}`);
|
|
1164
|
+
if (e.type === "member.left")
|
|
1165
|
+
print(` ${C.faint("○")} ${C.bold(p.name)} ${p.reason === "removed" ? "was removed" : "left"}`);
|
|
1166
|
+
if (e.type === "message")
|
|
1167
|
+
dash.push({ text: `${C.violet("💬")} ${C.bold(p.from)} ${C.faint("›")} ${p.body}` });
|
|
1168
|
+
if (e.type === "ask")
|
|
1169
|
+
dash.push({ text: `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${p.to ?? "room"} ${p.question}` });
|
|
1170
|
+
if (e.type === "answer" && p.source !== "teammate")
|
|
1171
|
+
dash.push({ text: `${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${p.to} ${p.answer} ${C.faint("— from shared memory")}` });
|
|
1172
|
+
if (e.type === "member.joined")
|
|
1173
|
+
dash.push({ text: `${C.green("●")} ${p.github ? "@" + p.github : p.name} joined` });
|
|
1174
|
+
if (e.type === "message")
|
|
1175
|
+
print(` ${C.violet("💬")} ${C.bold(p.from)} ${C.faint("›")} ${p.body}`);
|
|
1176
|
+
if (e.type === "crew.updated")
|
|
1177
|
+
print(` ${C.faint("⚡")} ${C.bold(p.member)}'s crew: ${p.agents.map((a) => dot(a) + " " + a).join(" ")}`);
|
|
1178
|
+
if (e.type === "ask")
|
|
1179
|
+
print(` ${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${p.to ? dot(p.to) + " " + C.bold(p.to) : C.faint("room")} ${p.question}`);
|
|
1180
|
+
// A teammate's own answer already showed as their message.
|
|
1181
|
+
if (e.type === "answer" && p.source !== "teammate")
|
|
1182
|
+
print(` ${dot(p.from)} ${C.bold(p.from)} ${C.faint("→")} ${C.bold(p.to)} ${p.answer} ${C.faint("— from shared memory")}`);
|
|
1183
|
+
});
|
|
1184
|
+
live = { db, server, root, unsub };
|
|
1185
|
+
return live;
|
|
1186
|
+
};
|
|
1187
|
+
const closeRoom = () => {
|
|
1188
|
+
if (!live)
|
|
1189
|
+
return;
|
|
1190
|
+
live.unsub();
|
|
1191
|
+
live.server.close();
|
|
1192
|
+
live.db.close();
|
|
1193
|
+
live = null;
|
|
1194
|
+
tunnel?.close();
|
|
1195
|
+
tunnel = null;
|
|
1196
|
+
relayLink?.close();
|
|
1197
|
+
relayLink = null;
|
|
1198
|
+
funnel?.close();
|
|
1199
|
+
funnel = null;
|
|
1200
|
+
};
|
|
1201
|
+
process.once("exit", closeRoom);
|
|
1202
|
+
const invite = async (arg) => {
|
|
1203
|
+
const r = await openRoom();
|
|
1204
|
+
if (!r)
|
|
1205
|
+
return print(C.red(" ✗ open a git repo first") + C.faint(" — a room belongs to a project"));
|
|
1206
|
+
// `invite @harsh` = only GitHub user harsh can use it (proved with their SSH key).
|
|
1207
|
+
// `invite @harsh guest` = they can watch and message, not work.
|
|
1208
|
+
const words = arg.split(/\s+/).filter(Boolean);
|
|
1209
|
+
const role = words.includes("guest") ? "guest" : "member";
|
|
1210
|
+
const handle = words.find((w) => w.startsWith("@"))?.slice(1);
|
|
1211
|
+
const name = handle ?? words.find((w) => w !== "guest") ?? "";
|
|
1212
|
+
if (handle) {
|
|
1213
|
+
const repo = r.server.info.remote ? githubRepo(r.server.info.remote) : null;
|
|
1214
|
+
if (repo) {
|
|
1215
|
+
const collab = isCollaborator(repo, handle);
|
|
1216
|
+
if (collab === false) {
|
|
1217
|
+
print(C.yellow(`\n @${handle} can't access ${repo} on GitHub, so they couldn't pull or push the code.`));
|
|
1218
|
+
if (await yes(rl, ` Add @${handle} as a collaborator on ${repo}? (sends them a GitHub invite)`, false)) {
|
|
1219
|
+
const add = spawnSync("gh", ["api", "-X", "PUT", `repos/${repo}/collaborators/${handle}`, "-f", "permission=push"], {
|
|
1220
|
+
encoding: "utf8",
|
|
1221
|
+
});
|
|
1222
|
+
print(add.status === 0 ? ` ${C.green("✓")} GitHub invite sent to @${handle}` : C.red(` ✗ ${add.stderr.trim().split("\n")[0]}`));
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
else if (collab)
|
|
1226
|
+
print(C.faint(` @${handle} has access to ${repo} ✓`));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
// Relay first (works across any networks); direct door as a fallback.
|
|
1230
|
+
const relayUrl = readPrefs().relay;
|
|
1231
|
+
if (relayUrl && !relayLink)
|
|
1232
|
+
relayLink = await openRelayLink(r.server, relayUrl, print);
|
|
1233
|
+
const { port, lanUrls } = await r.server.share();
|
|
1234
|
+
// Tailscale Funnel: a public https address, no server. Asked once.
|
|
1235
|
+
let fPref = readPrefs().funnel;
|
|
1236
|
+
if (!relayLink && !funnel && fPref !== false) {
|
|
1237
|
+
if (fPref === undefined) {
|
|
1238
|
+
print(C.faint("\n Tailscale Funnel can give this room a public https address, so teammates on any network can join."));
|
|
1239
|
+
print(C.faint(" Only the shared door is exposed; it still needs an invite code (and their GitHub key for @invites)."));
|
|
1240
|
+
fPref = await yes(rl, " Use Funnel for invites?");
|
|
1241
|
+
writePrefs({ ...readPrefs(), funnel: fPref });
|
|
1242
|
+
}
|
|
1243
|
+
if (fPref) {
|
|
1244
|
+
const f = openFunnel(port);
|
|
1245
|
+
if (f.ok)
|
|
1246
|
+
funnel = f;
|
|
1247
|
+
else
|
|
1248
|
+
print(C.yellow(` ! Funnel: ${f.reason}`) + C.faint(" — using the other routes for now"));
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (!tunnel && !relayLink && !funnel) {
|
|
1252
|
+
tunnel = await openTunnel(port, print);
|
|
1253
|
+
}
|
|
1254
|
+
const { code, expiresAt } = r.db.createInvite({ forName: name || undefined, github: handle, role });
|
|
1255
|
+
// Best reachable address first: public tunnel → your tailnet → this Wi-Fi.
|
|
1256
|
+
const ts = tailscaleIp();
|
|
1257
|
+
const routes = [];
|
|
1258
|
+
if (relayLink)
|
|
1259
|
+
routes.push([relayLink.address, "from anywhere, via your relay"]);
|
|
1260
|
+
if (funnel)
|
|
1261
|
+
routes.push([funnel.url, "from anywhere, via Tailscale Funnel"]);
|
|
1262
|
+
if (tunnel)
|
|
1263
|
+
routes.push([tunnel.url, "from anywhere"]);
|
|
1264
|
+
if (ts)
|
|
1265
|
+
routes.push([`http://${ts}:${port}`, "anyone on your Tailscale network"]);
|
|
1266
|
+
for (const u of lanUrls)
|
|
1267
|
+
if (!ts || !u.includes(ts))
|
|
1268
|
+
routes.push([u, "same Wi-Fi only"]);
|
|
1269
|
+
if (!routes.length)
|
|
1270
|
+
routes.push([`http://127.0.0.1:${port}`, "this machine only"]);
|
|
1271
|
+
const who = handle ? `for @${handle} (GitHub-verified) ` : name ? `for ${name} ` : "";
|
|
1272
|
+
print(`\n ${C.violet("invite")} ${C.faint(who + "· " + role + " · one use · expires " + new Date(expiresAt).toLocaleTimeString())}`);
|
|
1273
|
+
print(`\n ${C.bold(`huddle join ${routes[0][0]} ${code}`)} ${C.faint(routes[0][1])}`);
|
|
1274
|
+
for (const [u, why] of routes.slice(1))
|
|
1275
|
+
print(C.faint(` or: huddle join ${u} ${code} ${why}`));
|
|
1276
|
+
print();
|
|
1277
|
+
if (!tunnel && !ts && !relayLink && !funnel)
|
|
1278
|
+
print(C.faint(" tip: for teammates on other networks, set a relay (`relay <url>`) or run Tailscale on both machines"));
|
|
1279
|
+
print(C.faint(" macOS may ask to let node accept incoming connections — allow it for invites to work"));
|
|
1280
|
+
if (!handle)
|
|
1281
|
+
print(C.faint(" tip: `invite @github-user` so only that person can use it"));
|
|
1282
|
+
print(C.faint(role === "guest"
|
|
1283
|
+
? " guests watch the board and message the room"
|
|
1284
|
+
: " members bring their crew: their agents take tasks, work in their own clone, and push branches you merge"));
|
|
1285
|
+
print(C.faint(" nobody can run anything on your machine"));
|
|
1286
|
+
};
|
|
1287
|
+
const members = () => {
|
|
1288
|
+
const list = live?.db.listMembers() ?? [];
|
|
1289
|
+
if (!list.length)
|
|
1290
|
+
return print(C.faint(" just you — `invite` someone"));
|
|
1291
|
+
for (const m of list)
|
|
1292
|
+
print(` ${C.green("●")} ${pad(C.bold(m.name), 16)} ${C.faint(m.role + " · joined " + new Date(m.joined_at).toLocaleTimeString())}`);
|
|
1293
|
+
};
|
|
1294
|
+
const removeMember = (name) => {
|
|
1295
|
+
const m = live?.db.listMembers().find((x) => x.name.toLowerCase() === name.toLowerCase() || x.id === name);
|
|
1296
|
+
if (!m)
|
|
1297
|
+
return print(C.faint(` no member called ${name}`));
|
|
1298
|
+
live.db.removeMember(m.id, "removed");
|
|
1299
|
+
};
|
|
1300
|
+
const setBrain = async (arg) => {
|
|
1301
|
+
const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
|
|
1302
|
+
if (!arg) {
|
|
1303
|
+
const current = resolveBrain(results);
|
|
1304
|
+
print(`\n ${C.faint("Huddle's own thinking (planning, reports, answers) runs on:")} ${C.bold(current?.label ?? "nothing ready")}`);
|
|
1305
|
+
print(C.faint(" Agents still write the code with their own models. Options:\n"));
|
|
1306
|
+
for (const o of await brainOptions(ready))
|
|
1307
|
+
print(` ${o.spec === current?.spec ? C.violet("●") : C.faint("○")} ${pad(C.bold(o.spec), 28)} ${C.faint(o.note)}`);
|
|
1308
|
+
print(C.faint("\n set with: brain <spec> e.g. brain ollama:qwencoder · brain cursor:grok-4.7-low-fast · brain claude:haiku"));
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
const kind = arg.split(":")[0];
|
|
1312
|
+
const b = brainFrom(arg);
|
|
1313
|
+
if (!b)
|
|
1314
|
+
return print(C.red(` ✗ don't know "${arg}"`) + C.faint(" — try `brain` for options"));
|
|
1315
|
+
if (kind === "ollama" && !(await ollamaModels()).includes(arg.split(":").slice(1).join(":")))
|
|
1316
|
+
return print(C.red(` ✗ Ollama doesn't have ${arg.slice(7)}`) + C.faint(" — `ollama pull <model>` first"));
|
|
1317
|
+
if (kind !== "ollama" && !ready.includes(kind))
|
|
1318
|
+
return print(C.red(` ✗ ${kind} isn't ready`) + C.faint(" — type `agents`"));
|
|
1319
|
+
writePrefs({ ...readPrefs(), brain: arg });
|
|
1320
|
+
print(` ${C.green("✓")} brain: ${b.label}`);
|
|
1321
|
+
};
|
|
1322
|
+
const setCrew = (arg) => {
|
|
1323
|
+
const ready = results.filter((r) => r.state === "ready").map((r) => r.adapter.id);
|
|
1324
|
+
const crew = { ...(readPrefs().crew ?? {}) };
|
|
1325
|
+
if (arg) {
|
|
1326
|
+
for (const part of arg.split(/[\s,]+/)) {
|
|
1327
|
+
const m = /^([a-z]+)\s*[=x×:]\s*(\d+)$/i.exec(part);
|
|
1328
|
+
if (!m || !ready.includes(m[1].toLowerCase()))
|
|
1329
|
+
return print(C.red(` ✗ didn't understand "${part}"`) + C.faint(" — e.g. crew claude=2 codex=1"));
|
|
1330
|
+
crew[m[1].toLowerCase()] = Math.min(4, Number(m[2]));
|
|
1331
|
+
}
|
|
1332
|
+
writePrefs({ ...readPrefs(), crew });
|
|
1333
|
+
}
|
|
1334
|
+
const workers = workersFrom(results);
|
|
1335
|
+
print(`\n ${C.faint("crew:")} ${ready.map((id) => `${dot(id)} ${id} ×${crew[id] ?? 1}`).join(" ")}`);
|
|
1336
|
+
print(C.faint(` ${workers.length} agent${workers.length === 1 ? "" : "s"} per run · max 4 of each · 0 benches an agent`));
|
|
1337
|
+
};
|
|
1338
|
+
const shell = (cmd) => {
|
|
1339
|
+
promptShown = false;
|
|
1340
|
+
out.write("\r\x1b[J");
|
|
1341
|
+
rl.pause();
|
|
1342
|
+
spawnSync("sh", ["-c", cmd], { stdio: "inherit" });
|
|
1343
|
+
rl.resume();
|
|
1344
|
+
};
|
|
1345
|
+
const startGoal = async (typed) => {
|
|
1346
|
+
// "--qa" / "--no-qa" anywhere in a typed goal, like on `huddle join`.
|
|
1347
|
+
const qa = /(^|\s)--no-qa(\s|$)/.test(typed) ? false : /(^|\s)--qa(\s|$)/.test(typed) ? true : undefined;
|
|
1348
|
+
const goal = typed.replace(/(^|\s)--(no-)?qa(?=\s|$)/g, " ").trim();
|
|
1349
|
+
run = new AbortController();
|
|
1350
|
+
stats = null;
|
|
1351
|
+
ticker = setInterval(drawFooter, 1000); // keep the timers ticking
|
|
1352
|
+
drawFooter();
|
|
1353
|
+
try {
|
|
1354
|
+
const r = await openRoom();
|
|
1355
|
+
await runGoal(goal, {
|
|
1356
|
+
results,
|
|
1357
|
+
mode: state.mode,
|
|
1358
|
+
approvals: state.approvals,
|
|
1359
|
+
rl,
|
|
1360
|
+
signal: run.signal,
|
|
1361
|
+
print,
|
|
1362
|
+
onStats: (s) => (stats = s),
|
|
1363
|
+
room: r ?? undefined,
|
|
1364
|
+
onActivity: (a) => dash.push(a),
|
|
1365
|
+
qa,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
catch (e) {
|
|
1369
|
+
print(C.red(" error: ") + (e?.message ?? e));
|
|
1370
|
+
}
|
|
1371
|
+
finally {
|
|
1372
|
+
run = null;
|
|
1373
|
+
stats = null;
|
|
1374
|
+
if (ticker)
|
|
1375
|
+
clearInterval(ticker);
|
|
1376
|
+
ticker = null;
|
|
1377
|
+
const next = queue.shift();
|
|
1378
|
+
if (next) {
|
|
1379
|
+
print(C.violet(`\n▸ next in queue: `) + next);
|
|
1380
|
+
void startGoal(next);
|
|
1381
|
+
}
|
|
1382
|
+
else
|
|
1383
|
+
drawFooter();
|
|
1384
|
+
}
|
|
1385
|
+
};
|
|
1386
|
+
rl.on("line", async (raw) => {
|
|
1387
|
+
if (dash.isOpen())
|
|
1388
|
+
return; // keys typed into the live view aren't commands
|
|
1389
|
+
const line = raw.trim();
|
|
1390
|
+
promptShown = false;
|
|
1391
|
+
out.write("\x1b[2K"); // clear the footer line we just moved onto
|
|
1392
|
+
if (!line)
|
|
1393
|
+
return showPrompt();
|
|
1394
|
+
const [cmd, ...rest] = line.split(/\s+/);
|
|
1395
|
+
const arg = rest.join(" ").replace(/^["']|["']$/g, "");
|
|
1396
|
+
let background = false;
|
|
1397
|
+
try {
|
|
1398
|
+
if (cmd === "exit" || cmd === "quit")
|
|
1399
|
+
return rl.close();
|
|
1400
|
+
if (cmd === "help" || cmd === "?")
|
|
1401
|
+
print(HELP);
|
|
1402
|
+
else if (cmd === "clear" || cmd === "cls") {
|
|
1403
|
+
out.write("\x1b[2J\x1b[3J\x1b[H");
|
|
1404
|
+
}
|
|
1405
|
+
else if (cmd === "approvals")
|
|
1406
|
+
await setApprovals(arg);
|
|
1407
|
+
else if (cmd === "board")
|
|
1408
|
+
printBoard(print);
|
|
1409
|
+
else if (cmd === "view") {
|
|
1410
|
+
setImmediate(openView);
|
|
1411
|
+
background = true;
|
|
1412
|
+
}
|
|
1413
|
+
else if (cmd === "brain")
|
|
1414
|
+
await setBrain(arg);
|
|
1415
|
+
else if (cmd === "invite")
|
|
1416
|
+
await invite(arg);
|
|
1417
|
+
else if (cmd === "funnel") {
|
|
1418
|
+
if (arg === "on" || arg === "off")
|
|
1419
|
+
writePrefs({ ...readPrefs(), funnel: arg === "on" });
|
|
1420
|
+
if (arg === "off") {
|
|
1421
|
+
funnel?.close();
|
|
1422
|
+
funnel = null;
|
|
1423
|
+
}
|
|
1424
|
+
const on = readPrefs().funnel;
|
|
1425
|
+
print(` funnel ${on ? C.green("on") : on === false ? C.faint("off") : C.faint("not set — asked on your first invite")}${funnel ? C.faint(" · live at " + funnel.url) : ""}`);
|
|
1426
|
+
}
|
|
1427
|
+
else if (cmd === "relay") {
|
|
1428
|
+
if (arg === "off")
|
|
1429
|
+
writePrefs({ ...readPrefs(), relay: undefined });
|
|
1430
|
+
else if (arg) {
|
|
1431
|
+
if (!/^(wss?|https?):\/\//.test(arg))
|
|
1432
|
+
print(C.red(" ✗ give a URL, e.g. relay wss://relay.example.com"));
|
|
1433
|
+
else
|
|
1434
|
+
writePrefs({ ...readPrefs(), relay: arg.replace(/^http/, "ws") });
|
|
1435
|
+
}
|
|
1436
|
+
const cur = readPrefs().relay;
|
|
1437
|
+
print(` relay ${cur ? C.bold(cur) : C.faint("off — invites use Tailscale / Wi-Fi / tunnel")}`);
|
|
1438
|
+
}
|
|
1439
|
+
else if (cmd === "sync") {
|
|
1440
|
+
if (!isGitRepo(process.cwd()))
|
|
1441
|
+
print(C.faint(" not a git repo"));
|
|
1442
|
+
else {
|
|
1443
|
+
const root = repoRoot();
|
|
1444
|
+
const r = remoteInfo(root);
|
|
1445
|
+
if (arg === "on" || arg === "off")
|
|
1446
|
+
writeRepoConfig(root, { sync: arg === "on" });
|
|
1447
|
+
const on = readRepoConfig(root).sync;
|
|
1448
|
+
print(` sync ${on ? C.green("on") : C.faint("off")} ${C.faint(r ? "· " + r.name + " " + r.url : "· no remote — add one with git remote add origin <url>")}`);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
else if (cmd === "members")
|
|
1452
|
+
members();
|
|
1453
|
+
else if (cmd === "remove" || cmd === "kick")
|
|
1454
|
+
removeMember(arg);
|
|
1455
|
+
else if (cmd === "crew") {
|
|
1456
|
+
if (run)
|
|
1457
|
+
print(C.faint(" applies to the next run"));
|
|
1458
|
+
setCrew(arg);
|
|
1459
|
+
}
|
|
1460
|
+
else if (cmd === "stop") {
|
|
1461
|
+
if (run) {
|
|
1462
|
+
queue.length = 0;
|
|
1463
|
+
run.abort();
|
|
1464
|
+
print(C.yellow(" stopping agents…"));
|
|
1465
|
+
}
|
|
1466
|
+
else
|
|
1467
|
+
print(C.faint(" nothing running"));
|
|
1468
|
+
}
|
|
1469
|
+
else if (cmd === "agents") {
|
|
1470
|
+
if (run)
|
|
1471
|
+
print(C.faint(" finish or stop the current run first"));
|
|
1472
|
+
else {
|
|
1473
|
+
results = await connectAgents();
|
|
1474
|
+
if (await offerSetup(rl, results, true))
|
|
1475
|
+
results = await connectAgents();
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
else if (cmd === "cd") {
|
|
1479
|
+
if (run)
|
|
1480
|
+
print(C.faint(" finish or stop the current run first"));
|
|
1481
|
+
else {
|
|
1482
|
+
const to = path.resolve(process.cwd(), arg.replace(/^~(?=$|\/)/, os.homedir()) || os.homedir());
|
|
1483
|
+
if (live?.db.listMembers().length)
|
|
1484
|
+
print(C.yellow(" note: people in this room are disconnected when you switch projects"));
|
|
1485
|
+
try {
|
|
1486
|
+
process.chdir(to);
|
|
1487
|
+
if (live && live.root !== repoRoot())
|
|
1488
|
+
closeRoom();
|
|
1489
|
+
const ok = isGitRepo(process.cwd());
|
|
1490
|
+
if (ok)
|
|
1491
|
+
ensureInit();
|
|
1492
|
+
print(` ${C.faint("cwd")} ${tildify(process.cwd())}${ok || childRepos().length ? "" : C.faint(" · not a git repo yet — Huddle will offer to set it up when you give it a goal")}`);
|
|
1493
|
+
if (!ok)
|
|
1494
|
+
await offerChildRepo(print, rl);
|
|
1495
|
+
}
|
|
1496
|
+
catch {
|
|
1497
|
+
print(C.red(` ✗ no such folder: ${arg}`));
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
else if (line.startsWith("!"))
|
|
1502
|
+
shell(line.slice(1));
|
|
1503
|
+
else if (SHELL_WORDS.has(cmd)) {
|
|
1504
|
+
shell(line);
|
|
1505
|
+
print(C.faint(` (ran as a shell command — say "build ${line}" if you meant a goal)`));
|
|
1506
|
+
}
|
|
1507
|
+
else if (cmd === "ask" || (cmd !== "build" && cmd !== "join" && isQuestion(line))) {
|
|
1508
|
+
await answer(print, cmd === "ask" ? arg : line, results);
|
|
1509
|
+
}
|
|
1510
|
+
else {
|
|
1511
|
+
const goal = cmd === "build" || cmd === "join" ? arg : line;
|
|
1512
|
+
if (!goal)
|
|
1513
|
+
print(C.faint(" usage: build <goal>"));
|
|
1514
|
+
else if (run) {
|
|
1515
|
+
queue.push(goal);
|
|
1516
|
+
print(` ${C.violet("+")} queued ${C.faint("· starts when the current run finishes (" + queue.length + " in queue)")}`);
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
background = true;
|
|
1520
|
+
void startGoal(goal);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
catch (e) {
|
|
1525
|
+
print(C.red(" error: ") + (e?.message ?? e));
|
|
1526
|
+
}
|
|
1527
|
+
finally {
|
|
1528
|
+
if (!background || !asking)
|
|
1529
|
+
showPrompt();
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
showPrompt();
|
|
1533
|
+
}
|
|
1534
|
+
//# sourceMappingURL=app.js.map
|