huddle-cli 0.1.1 → 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 +38 -0
- package/QUICKSTART.md +34 -1
- package/README.md +3 -0
- package/dist/adapters/codex.js +3 -0
- 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 +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +26 -2
- package/dist/mcp/server.js +19 -0
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +10 -4
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +31 -4
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +82 -16
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +53 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +452 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// `huddle app` — the dashboard: a local web page for a repo's runs.
|
|
2
|
+
//
|
|
3
|
+
// It shows the run going on now (from .huddle/live.json, whoever started
|
|
4
|
+
// it), every past run as a conversation (the same records `huddle export`
|
|
5
|
+
// reads), and lets you start a run, answer an agent's question, talk to the
|
|
6
|
+
// agents and stop them (through .huddle/inbox/, app/live.ts).
|
|
7
|
+
//
|
|
8
|
+
// Local only: it listens on 127.0.0.1, every API call needs the token from
|
|
9
|
+
// the URL it prints, and a Host other than localhost is refused (so a web
|
|
10
|
+
// page you visit can't reach it through DNS rebinding).
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { ADAPTERS, LEGACY, applySkips, probeAsync } from "../adapters/index.js";
|
|
18
|
+
import { RoomDB } from "../room/db.js";
|
|
19
|
+
import { splitRuns } from "../ui/story.js";
|
|
20
|
+
import { buildExport, findRun, renderExport, runRows, EXPORT_FORMATS, EXPORT_LEVELS } from "../ui/export.js";
|
|
21
|
+
import { packageInfo } from "../preflight.js";
|
|
22
|
+
import { clearAppInfo, postCommand, readLive, readReport, writeAppInfo } from "./live.js";
|
|
23
|
+
import { appPage } from "./page.js";
|
|
24
|
+
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
25
|
+
const json = (res, status, body) => {
|
|
26
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
27
|
+
res.end(JSON.stringify(body));
|
|
28
|
+
};
|
|
29
|
+
function readBody(req, max = 200_000) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let raw = "";
|
|
32
|
+
req.on("data", (d) => {
|
|
33
|
+
raw += d;
|
|
34
|
+
if (raw.length > max)
|
|
35
|
+
reject(new Error("too large"));
|
|
36
|
+
});
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
try {
|
|
39
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
reject(new Error("bad json"));
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
req.on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** The default way a run starts from the dashboard: its own process, so closing the page (or the app) doesn't stop it. */
|
|
49
|
+
function spawnRun(root, goal) {
|
|
50
|
+
const logs = path.join(root, ".huddle", "logs");
|
|
51
|
+
fs.mkdirSync(logs, { recursive: true });
|
|
52
|
+
const fd = fs.openSync(path.join(logs, `app-run-${new Date().toISOString().replace(/[:.]/g, "-")}.log`), "a");
|
|
53
|
+
const child = spawn(process.execPath, [CLI, "join", goal], {
|
|
54
|
+
cwd: root,
|
|
55
|
+
detached: true,
|
|
56
|
+
stdio: ["ignore", fd, fd],
|
|
57
|
+
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
|
|
58
|
+
windowsHide: true,
|
|
59
|
+
});
|
|
60
|
+
child.unref();
|
|
61
|
+
fs.closeSync(fd);
|
|
62
|
+
}
|
|
63
|
+
/** Every agent Huddle knows, probed quietly (no spinner rows): who's ready, signed out, skipped. */
|
|
64
|
+
async function probeAgents() {
|
|
65
|
+
const { skippedAgents } = await import("../ui/app.js");
|
|
66
|
+
const all = applySkips(await Promise.all(ADAPTERS.map((a) => probeAsync(a))), skippedAgents());
|
|
67
|
+
return all.filter((r) => !LEGACY.has(r.adapter.id) || r.state === "ready");
|
|
68
|
+
}
|
|
69
|
+
export async function startApp(opts) {
|
|
70
|
+
const root = opts.root;
|
|
71
|
+
const dbFile = path.join(root, ".huddle", "huddle.db");
|
|
72
|
+
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
73
|
+
const db = new RoomDB(dbFile);
|
|
74
|
+
const token = crypto.randomBytes(18).toString("base64url");
|
|
75
|
+
const startRun = opts.startRun ?? ((goal) => spawnRun(root, goal));
|
|
76
|
+
let agents = opts.agents ?? null;
|
|
77
|
+
let probing = null;
|
|
78
|
+
const probe = () => {
|
|
79
|
+
probing ??= probeAgents()
|
|
80
|
+
.then((r) => void (agents = r))
|
|
81
|
+
.catch(() => void (agents = []))
|
|
82
|
+
.finally(() => (probing = null));
|
|
83
|
+
return probing;
|
|
84
|
+
};
|
|
85
|
+
if (!agents)
|
|
86
|
+
void probe();
|
|
87
|
+
const runsNow = () => splitRuns(db.allEvents());
|
|
88
|
+
/** Changes whenever the run or the history does: the page refetches on it. */
|
|
89
|
+
const signature = () => {
|
|
90
|
+
let live = 0;
|
|
91
|
+
try {
|
|
92
|
+
live = fs.statSync(path.join(root, ".huddle", "live.json")).mtimeMs;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* no run yet */
|
|
96
|
+
}
|
|
97
|
+
const last = db.recentEvents(1)[0];
|
|
98
|
+
return `${live}:${last?.id ?? ""}`;
|
|
99
|
+
};
|
|
100
|
+
const state = () => {
|
|
101
|
+
const runs = runsNow();
|
|
102
|
+
const live = readLive(root);
|
|
103
|
+
return {
|
|
104
|
+
version: packageInfo().version,
|
|
105
|
+
repo: { name: path.basename(root), root },
|
|
106
|
+
agents: (agents ?? []).map((r) => ({ id: r.adapter.id, vendor: r.adapter.vendor, state: r.state, detail: r.detail ?? "" })),
|
|
107
|
+
probing: !agents,
|
|
108
|
+
live,
|
|
109
|
+
runs: runRows(runs).reverse(),
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
const runDoc = (which) => {
|
|
113
|
+
const runs = runsNow();
|
|
114
|
+
const run = findRun(runs, which ?? undefined);
|
|
115
|
+
if (!run)
|
|
116
|
+
return null;
|
|
117
|
+
const doc = buildExport(run, runs.length, { level: "chat", logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
|
|
118
|
+
const made = run.events.find((e) => e.type === "plan.made");
|
|
119
|
+
const plain = made?.p?.plain && typeof made.p.plain === "object" ? made.p.plain : null;
|
|
120
|
+
return { ...doc, plain, report: readReport(root, run.id) };
|
|
121
|
+
};
|
|
122
|
+
const allowedHost = (h, port) => h === `127.0.0.1:${port}` || h === `localhost:${port}`;
|
|
123
|
+
const server = http.createServer(async (req, res) => {
|
|
124
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
125
|
+
const port = server.address().port;
|
|
126
|
+
if (!allowedHost(req.headers.host, port))
|
|
127
|
+
return json(res, 403, { error: "local only" });
|
|
128
|
+
try {
|
|
129
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
130
|
+
if (url.searchParams.get("t") !== token) {
|
|
131
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
132
|
+
return res.end("Open the link `huddle app` printed (it carries a key for this session).");
|
|
133
|
+
}
|
|
134
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
135
|
+
return res.end(appPage(token));
|
|
136
|
+
}
|
|
137
|
+
if (!url.pathname.startsWith("/api/"))
|
|
138
|
+
return json(res, 404, { error: "not found" });
|
|
139
|
+
// The token: a header for fetches, ?t= for the event stream and downloads.
|
|
140
|
+
const given = req.headers["x-huddle-token"] ?? url.searchParams.get("t");
|
|
141
|
+
if (given !== token)
|
|
142
|
+
return json(res, 401, { error: "no key" });
|
|
143
|
+
const route = `${req.method} ${url.pathname}`;
|
|
144
|
+
if (route === "GET /api/state")
|
|
145
|
+
return json(res, 200, state());
|
|
146
|
+
if (route === "GET /api/run") {
|
|
147
|
+
const doc = runDoc(url.searchParams.get("n"));
|
|
148
|
+
return doc ? json(res, 200, doc) : json(res, 404, { error: "no such run" });
|
|
149
|
+
}
|
|
150
|
+
if (route === "GET /api/export") {
|
|
151
|
+
const runs = runsNow();
|
|
152
|
+
const run = findRun(runs, url.searchParams.get("n") ?? undefined);
|
|
153
|
+
if (!run)
|
|
154
|
+
return json(res, 404, { error: "no such run" });
|
|
155
|
+
const format = EXPORT_FORMATS.includes(url.searchParams.get("format") ?? "") ? url.searchParams.get("format") : "html";
|
|
156
|
+
const level = EXPORT_LEVELS.includes(url.searchParams.get("level") ?? "") ? url.searchParams.get("level") : "chat";
|
|
157
|
+
const doc = buildExport(run, runs.length, { level, logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
|
|
158
|
+
const type = format === "json" ? "application/json" : format === "html" ? "text/html" : "text/markdown";
|
|
159
|
+
res.writeHead(200, {
|
|
160
|
+
"content-type": `${type}; charset=utf-8`,
|
|
161
|
+
"content-disposition": `attachment; filename="huddle-run-${run.index}-${level}.${format}"`,
|
|
162
|
+
});
|
|
163
|
+
return res.end(renderExport(doc, format));
|
|
164
|
+
}
|
|
165
|
+
if (route === "GET /api/events") {
|
|
166
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
167
|
+
let last = "";
|
|
168
|
+
const tick = () => {
|
|
169
|
+
const sig = signature();
|
|
170
|
+
if (sig !== last)
|
|
171
|
+
res.write(`data: ${JSON.stringify({ sig: (last = sig) })}\n\n`);
|
|
172
|
+
else
|
|
173
|
+
res.write(": ping\n\n");
|
|
174
|
+
};
|
|
175
|
+
tick();
|
|
176
|
+
const t = setInterval(tick, 1000);
|
|
177
|
+
req.on("close", () => clearInterval(t));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (route === "POST /api/agents") {
|
|
181
|
+
agents = null;
|
|
182
|
+
await probe();
|
|
183
|
+
return json(res, 200, state());
|
|
184
|
+
}
|
|
185
|
+
if (route === "POST /api/goal") {
|
|
186
|
+
const body = await readBody(req);
|
|
187
|
+
const goal = String(body.goal ?? "").trim();
|
|
188
|
+
if (!goal)
|
|
189
|
+
return json(res, 400, { error: "say what to build" });
|
|
190
|
+
if (readLive(root))
|
|
191
|
+
return json(res, 409, { error: "a run is already going — talk to it, or stop it first" });
|
|
192
|
+
startRun(goal);
|
|
193
|
+
return json(res, 200, { ok: true });
|
|
194
|
+
}
|
|
195
|
+
if (route === "POST /api/say" || route === "POST /api/answer" || route === "POST /api/stop") {
|
|
196
|
+
const body = await readBody(req);
|
|
197
|
+
if (!readLive(root))
|
|
198
|
+
return json(res, 409, { error: "nothing is running" });
|
|
199
|
+
const c = route === "POST /api/stop"
|
|
200
|
+
? { type: "stop" }
|
|
201
|
+
: route === "POST /api/say"
|
|
202
|
+
? { type: "say", text: String(body.text ?? "").slice(0, 4000) }
|
|
203
|
+
: { type: "answer", id: String(body.id ?? ""), line: String(body.line ?? "").slice(0, 2000) };
|
|
204
|
+
if (c.type === "say" && !c.text.trim())
|
|
205
|
+
return json(res, 400, { error: "empty" });
|
|
206
|
+
postCommand(root, c);
|
|
207
|
+
return json(res, 200, { ok: true });
|
|
208
|
+
}
|
|
209
|
+
return json(res, 404, { error: "not found" });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
return json(res, 500, { error: e?.message ?? String(e) });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const port = await new Promise((resolve, reject) => {
|
|
216
|
+
server.once("error", reject);
|
|
217
|
+
server.listen(opts.port ?? 0, "127.0.0.1", () => resolve(server.address().port));
|
|
218
|
+
});
|
|
219
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
220
|
+
writeAppInfo(root, { pid: process.pid, url });
|
|
221
|
+
const close = () => {
|
|
222
|
+
server.closeAllConnections?.();
|
|
223
|
+
server.close();
|
|
224
|
+
clearAppInfo(root);
|
|
225
|
+
db.close();
|
|
226
|
+
};
|
|
227
|
+
return { url, port, token, close };
|
|
228
|
+
}
|
|
229
|
+
/** `huddle app [--port n] [--no-open]` */
|
|
230
|
+
export async function runApp(args) {
|
|
231
|
+
const { C } = await import("../ui/term.js");
|
|
232
|
+
const { isGitRepo } = await import("../sandbox/worktree.js");
|
|
233
|
+
const { ensureInit, repoRoot } = await import("../ui/app.js");
|
|
234
|
+
if (!isGitRepo(process.cwd()) || !ensureInit()) {
|
|
235
|
+
console.log(C.red("✗ Not a git repo.") + " Run `huddle app` inside a project (`git init` makes one).");
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const i = args.indexOf("--port");
|
|
240
|
+
const port = i >= 0 ? Number(args[i + 1]) : undefined;
|
|
241
|
+
const app = await startApp({ root: repoRoot(), port });
|
|
242
|
+
console.log(`${C.green("✓")} Huddle is open at ${C.bold(app.url)}`);
|
|
243
|
+
console.log(C.faint(" runs keep going if you close the page · ctrl-c closes the dashboard"));
|
|
244
|
+
if (!args.includes("--no-open"))
|
|
245
|
+
openBrowser(app.url);
|
|
246
|
+
const bye = () => {
|
|
247
|
+
app.close();
|
|
248
|
+
process.exit(0);
|
|
249
|
+
};
|
|
250
|
+
process.on("SIGINT", bye);
|
|
251
|
+
process.on("SIGTERM", bye);
|
|
252
|
+
}
|
|
253
|
+
function openBrowser(url) {
|
|
254
|
+
const [cmd, ...a] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
255
|
+
try {
|
|
256
|
+
spawn(cmd, a, { stdio: "ignore", detached: true, windowsHide: true }).on("error", () => { }).unref();
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
/* no browser: the URL is printed */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
//# sourceMappingURL=server.js.map
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// What `huddle <words>` means.
|
|
2
|
+
const COMMANDS = new Set("app dashboard plug init setup agents doctor join board view watch story brief runs export lessons up down peers add ctx context attach land connect disconnect guard mail relay version help".split(" "));
|
|
3
|
+
/**
|
|
4
|
+
* `huddle build a temperature converter` / `huddle "fix the login bug"`: a goal
|
|
5
|
+
* in plain words, without `join` or quotes. Found live (0.1.2 test): "as a
|
|
6
|
+
* normal user, I would never write it like that". A command name, a flag or a
|
|
7
|
+
* single unknown word stays what it was (an unknown command is still an error).
|
|
8
|
+
*/
|
|
9
|
+
export function asGoal(args) {
|
|
10
|
+
const [cmd, ...rest] = args;
|
|
11
|
+
if (!cmd || cmd.startsWith("-") || COMMANDS.has(cmd))
|
|
12
|
+
return null;
|
|
13
|
+
const words = [cmd, ...rest.filter((a) => !a.startsWith("--"))].join(" ").trim().split(/\s+/);
|
|
14
|
+
if (cmd === "build" || cmd === "make" || words.length >= 3 || /\s/.test(cmd))
|
|
15
|
+
return ["join", ...(cmd === "build" ? rest : args)];
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=args.js.map
|
package/dist/attach/connect.js
CHANGED
|
@@ -19,7 +19,7 @@ import readline from "node:readline";
|
|
|
19
19
|
import { ADAPTERS } from "../adapters/index.js";
|
|
20
20
|
import { C } from "../ui/term.js";
|
|
21
21
|
import { CLI_PATH, ensureLive, liveFiles, mainRoot } from "./live.js";
|
|
22
|
-
import {
|
|
22
|
+
import { liveInstructions } from "./plug.js";
|
|
23
23
|
const TOML_START = "# >>> huddle connect (remove with `huddle disconnect`) >>>";
|
|
24
24
|
const TOML_END = "# <<< huddle connect <<<";
|
|
25
25
|
// Codex's top-level keys must come before any [table], so the instructions get
|
|
@@ -73,7 +73,7 @@ export function wiresFor(adapter, repo, opts = {}) {
|
|
|
73
73
|
file: ".cursor/rules/huddle.mdc",
|
|
74
74
|
kind: "rule",
|
|
75
75
|
why: "tells Cursor to check in with Huddle before editing",
|
|
76
|
-
content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${
|
|
76
|
+
content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${liveInstructions(repo)}\n`,
|
|
77
77
|
});
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
@@ -152,7 +152,7 @@ export function applyWire(w, current, repo) {
|
|
|
152
152
|
// config-reference). A user's own top-level value wins (TOML forbids two).
|
|
153
153
|
const top = base.split(/^\s*\[/m)[0];
|
|
154
154
|
const own = /^\s*developer_instructions\s*=/m.test(top);
|
|
155
|
-
const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(
|
|
155
|
+
const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(liveInstructions(repo))}\n${TOML_TOP_END}\n\n`;
|
|
156
156
|
return { text: head + (base.trim() ? base.replace(/\n*$/, "\n\n") : "") + codexBlock(livePlug(repo, w.agent)) + "\n" };
|
|
157
157
|
}
|
|
158
158
|
case "claude-hook": {
|
package/dist/attach/plug.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// no task id: the session says hello to the repo's live room (starting it if
|
|
5
5
|
// needed), and its tools are about sharing the repo safely with the others.
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
10
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -20,6 +21,12 @@ Before you edit ANY file:
|
|
|
20
21
|
Notes from other sessions arrive in "notes_for_you" on any huddle_* reply — read them and answer with huddle_note. Record lasting decisions with huddle_remember.
|
|
21
22
|
The shared screen holds what your user put up for everyone (logs, docs, screenshots): huddle_context lists it, huddle_context_read / huddle_context_search open it, huddle_context_note pins what you found.
|
|
22
23
|
`.trim();
|
|
24
|
+
/**
|
|
25
|
+
* LIVE_INSTRUCTIONS plus where the last team run's hand-off brief is
|
|
26
|
+
* (ui/brief.ts). An attach session may sit in a worktree, so the path is the
|
|
27
|
+
* main checkout's, absolute.
|
|
28
|
+
*/
|
|
29
|
+
export const liveInstructions = (repo) => `${LIVE_INSTRUCTIONS}\nIf ${path.join(repo, ".huddle", "BRIEF.md")} exists, read it before changing code: the hand-off from the last Huddle team run in this repo (goal, decisions, contract, open issues).`;
|
|
23
30
|
const tools = [
|
|
24
31
|
{
|
|
25
32
|
name: "huddle_peers",
|
|
@@ -147,7 +154,7 @@ export async function runLivePlug() {
|
|
|
147
154
|
}
|
|
148
155
|
throw new Error(`unknown tool: ${name}`);
|
|
149
156
|
};
|
|
150
|
-
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions:
|
|
157
|
+
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: liveInstructions(repo) });
|
|
151
158
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
152
159
|
tools: [...tools.map((t) => ({ ...t })), ...screen.map(({ handler: _h, ...t }) => t)],
|
|
153
160
|
}));
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// --agents=claude,codex only these agents work on this run
|
|
9
9
|
// --council / --no-council every agent brainstorms the design first (default: big goals)
|
|
10
10
|
// --qa / --no-qa a QA round after the build (default: on for big goals)
|
|
11
|
+
// --budget=5M tokens this run may use: new work stops at 90%, agents at 120%
|
|
11
12
|
// --with <file|dir|glob> put files on the shared screen first (repeatable); piped stdin too
|
|
12
13
|
// huddle setup install + sign in to the agents you pick (asks first)
|
|
13
14
|
// huddle doctor is this machine ready: node, git, room db, your agents
|
|
@@ -19,6 +20,9 @@
|
|
|
19
20
|
// huddle board print the board + shared memory
|
|
20
21
|
// huddle view [repo] the live view (ctrl-t) from another terminal, read-only
|
|
21
22
|
// huddle story [--html f] replay the latest run: who built on whose idea, who said what to whom, merges
|
|
23
|
+
// huddle brief [--run n] the hand-off brief a solo agent reads after a run (off|on: the CLAUDE.md/AGENTS.md pointer)
|
|
24
|
+
// huddle runs every run in this repo: goal, agents, result, cost
|
|
25
|
+
// huddle export [run] a run as summary / chat / full transcript (md, html, json)
|
|
22
26
|
// huddle lessons what Huddle learned across runs (--forget <id>, --clear)
|
|
23
27
|
// huddle init set up .huddle/ in this repo (automatic on first run)
|
|
24
28
|
// huddle up | down attach mode: start/stop this repo's live room
|
|
@@ -33,6 +37,7 @@
|
|
|
33
37
|
// huddle mail --hook (internal) Claude's post-tool hook: teammates' new messages
|
|
34
38
|
import { packageInfo } from "./preflight.js"; // first: refuses old Node before anything else loads
|
|
35
39
|
import { C, useAsciiOutput } from "./ui/term.js";
|
|
40
|
+
import { asGoal } from "./args.js";
|
|
36
41
|
import { SqliteUnavailableError } from "./room/sqlite.js";
|
|
37
42
|
// Everything heavier loads per command: `huddle mail --hook` runs after every
|
|
38
43
|
// one of Claude's tool calls and has to start in well under 300ms.
|
|
@@ -49,14 +54,22 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
|
|
|
49
54
|
${B("--approvals=safe|auto|yolo")} what agents may do unasked ${F("(default auto)")}
|
|
50
55
|
${B("--council")} ${F("/")} ${B("--no-council")} design council first ${F("(default: big goals)")}
|
|
51
56
|
${B("--qa")} ${F("/")} ${B("--no-qa")} agents re-check the build ${F("(default: big goals)")}
|
|
57
|
+
${B("--budget=5M")} tokens it may use ${F("(stops new work at 90%)")}
|
|
52
58
|
${B("--with <file>")} put a file, folder or glob on the shared screen
|
|
53
59
|
${F("(repeatable; or pipe: cat log | huddle join …)")}
|
|
60
|
+
${B("huddle app")} the dashboard: live run, history, talk to agents
|
|
54
61
|
${B("huddle join <address> <CODE>")} join someone's room from an invite
|
|
55
62
|
|
|
56
63
|
${F("watch and replay")}
|
|
57
64
|
${B("huddle view")} ${F("[repo]")} watch a run live, read-only, from a 2nd terminal
|
|
58
65
|
${B("huddle story")} ${F("[--html f]")} replay the last run: ideas, contract, merges
|
|
59
66
|
${F("(--list, --run n)")}
|
|
67
|
+
${B("huddle brief")} ${F("[--run n]")} what the last run left for your next solo agent
|
|
68
|
+
${F("(off / on: its pointer in CLAUDE.md, AGENTS.md)")}
|
|
69
|
+
${B("huddle runs")} every run here: goal, agents, result, cost
|
|
70
|
+
${B("huddle export")} ${F("[run]")} a run as a chat you can read or share
|
|
71
|
+
${F("(--level summary|chat|full, --out <path>,")}
|
|
72
|
+
${F(" --format md|html|json, --no-redact)")}
|
|
60
73
|
${B("huddle board")} print the board
|
|
61
74
|
${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
|
|
62
75
|
|
|
@@ -81,7 +94,8 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
|
|
|
81
94
|
${B("huddle up")} ${F("/")} ${B("down")} ${F("/")} ${B("disconnect")} start · stop the live room · unwire
|
|
82
95
|
`;
|
|
83
96
|
async function main() {
|
|
84
|
-
const
|
|
97
|
+
const argv = process.argv.slice(2);
|
|
98
|
+
const [cmd, ...rest] = asGoal(argv) ?? argv;
|
|
85
99
|
// Legacy Windows consoles get ASCII glyphs. Not for the machine-read channels: the Plug's JSON-RPC and Claude's hooks.
|
|
86
100
|
if (!["plug", "guard", "mail"].includes(cmd ?? ""))
|
|
87
101
|
useAsciiOutput();
|
|
@@ -167,8 +181,12 @@ async function main() {
|
|
|
167
181
|
// --council: every agent brainstorms the design first; --no-council: the brain plans alone.
|
|
168
182
|
const council = rest.includes("--no-council") ? false : rest.includes("--council") ? true : undefined;
|
|
169
183
|
const qa = rest.includes("--no-qa") ? false : rest.includes("--qa") ? true : undefined;
|
|
170
|
-
|
|
184
|
+
const budget = rest.find((a) => a.startsWith("--budget="))?.split("=")[1];
|
|
185
|
+
return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa, budget });
|
|
171
186
|
}
|
|
187
|
+
case "app":
|
|
188
|
+
case "dashboard":
|
|
189
|
+
return (await import("./app/server.js")).runApp(rest);
|
|
172
190
|
case "board":
|
|
173
191
|
return (await app()).printBoard();
|
|
174
192
|
case "view":
|
|
@@ -176,6 +194,12 @@ async function main() {
|
|
|
176
194
|
return (await import("./ui/watch.js")).runView(rest);
|
|
177
195
|
case "story":
|
|
178
196
|
return (await import("./ui/story.js")).runStory(rest);
|
|
197
|
+
case "brief":
|
|
198
|
+
return (await import("./ui/brief.js")).runBrief(rest);
|
|
199
|
+
case "runs":
|
|
200
|
+
return (await import("./ui/export.js")).runRuns(rest);
|
|
201
|
+
case "export":
|
|
202
|
+
return (await import("./ui/export.js")).runExport(rest);
|
|
179
203
|
case "lessons":
|
|
180
204
|
return (await import("./ui/lessons.js")).runLessons(rest);
|
|
181
205
|
case "up":
|
package/dist/mcp/server.js
CHANGED
|
@@ -141,6 +141,24 @@ const TOOLS = [
|
|
|
141
141
|
},
|
|
142
142
|
handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
|
|
143
143
|
},
|
|
144
|
+
{
|
|
145
|
+
name: "huddle_ask_user",
|
|
146
|
+
description: "Ask the USER (the person this work is for) to decide something BIG that the goal doesn't settle: a choice that changes what they get " +
|
|
147
|
+
"(scope, a feature in or out, a user-visible behavior, an irreversible step, real money or data). NOT for names, file layout, code style " +
|
|
148
|
+
"or anything you or a teammate can decide — the user isn't an engineer and sees many agents. Give 2-4 short options in plain words " +
|
|
149
|
+
"(no jargon), the one you recommend FIRST; they may also answer in their own words. Waits a short while; with no answer, go with option 1. " +
|
|
150
|
+
"At most 2 per run.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
question: { type: "string", description: "one plain sentence, e.g. 'Should it also convert Rankine?'" },
|
|
155
|
+
options: { type: "array", items: { type: "string" }, description: "2-4 plain-words options, your recommendation first" },
|
|
156
|
+
why: { type: "string", description: "optional: one line on what changes for them either way" },
|
|
157
|
+
},
|
|
158
|
+
required: ["question", "options"],
|
|
159
|
+
},
|
|
160
|
+
handler: (a) => room("/ask_user", "POST", { question: a.question, options: a.options, why: a.why }),
|
|
161
|
+
},
|
|
144
162
|
{
|
|
145
163
|
name: "huddle_send",
|
|
146
164
|
description: "Message a teammate agent directly: a heads-up ('I renamed X to Y'), an interface you're exposing, or an answer to their question (pass reply_to = the question's id). `to` = an agent id from huddle_peers, a task id, \"all\" for everyone working now, or \"huddle\" to answer Huddle itself. They see it the next time they call any huddle tool.",
|
|
@@ -239,6 +257,7 @@ export const PLUG_INSTRUCTIONS = `Huddle: you are one of several AI coding agent
|
|
|
239
257
|
- Start: huddle_board (the plan and who is doing what), huddle_recall (shared memory: decisions, contracts, the design council's notes: huddle_recall "council"), huddle_peers, huddle_inbox.
|
|
240
258
|
- Before editing: huddle_announce_plan (what you will touch), huddle_check_overlap (is someone else in that file), huddle_contract (the exact names tasks share).
|
|
241
259
|
- Names other tasks use: code to the contract verbatim. Never invent one that isn't in it — huddle_amend_contract(section, change, why) first; it tells every teammate.
|
|
260
|
+
- The user: messages whose sender is "the user" come from the person this work is for — they win over the plan and the contract. A big decision the goal doesn't settle (scope, user-visible behavior, anything irreversible): huddle_ask_user with 2-4 plain options, your pick first. Never for names or code style.
|
|
242
261
|
- While working: huddle_progress (a short note; the reply shows the team's in-flight work), huddle_remember (a decision or contract others need; kind "lesson" is kept for future runs), huddle_send (message one teammate), huddle_ask (ask a teammate or the brain a question), huddle_inbox (read replies).
|
|
243
262
|
- Sharing work: when huddle_offer_help reaches you, huddle_split hands part of your task to a free agent; huddle_take_backlog picks up the next task; huddle_claim takes a specific one.
|
|
244
263
|
- QA rounds: huddle_report_bug files one reproducible bug in the merged build; it becomes a fix task for the file's author.
|
|
@@ -123,7 +123,9 @@ export function repoContext(cwd, maxFiles = 250) {
|
|
|
123
123
|
return null;
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
|
-
|
|
126
|
+
// Found live (0.1.2): agents set "engines": {"node": ">=22"} on a machine with Node 20.
|
|
127
|
+
const machine = `MACHINE: ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, Node ${process.version}. Target what runs here: never require a newer Node (engines, syntax, built-in APIs) than this.`;
|
|
128
|
+
const parts = [machine, `FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
|
|
127
129
|
for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
|
|
128
130
|
const h = head(f, 40);
|
|
129
131
|
if (h)
|