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.
@@ -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
@@ -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 { LIVE_INSTRUCTIONS } from "./plug.js";
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${LIVE_INSTRUCTIONS}\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(LIVE_INSTRUCTIONS)}\n${TOML_TOP_END}\n\n`;
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": {
@@ -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: LIVE_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 [cmd, ...rest] = process.argv.slice(2);
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
- return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa });
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":
@@ -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
- const parts = [`FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
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)