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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -2
  3. package/dist/adapters/amp.js +40 -0
  4. package/dist/adapters/antigravity.js +52 -0
  5. package/dist/adapters/claude.js +70 -0
  6. package/dist/adapters/codex.js +59 -0
  7. package/dist/adapters/copilot.js +69 -0
  8. package/dist/adapters/cursor.js +85 -0
  9. package/dist/adapters/custom.js +340 -0
  10. package/dist/adapters/droid.js +57 -0
  11. package/dist/adapters/gemini.js +61 -0
  12. package/dist/adapters/goose.js +55 -0
  13. package/dist/adapters/index.js +135 -0
  14. package/dist/adapters/kiro.js +41 -0
  15. package/dist/adapters/opencode.js +63 -0
  16. package/dist/adapters/qwen.js +67 -0
  17. package/dist/adapters/types.js +6 -0
  18. package/dist/adapters/usage.js +141 -0
  19. package/dist/attach/connect.js +409 -0
  20. package/dist/attach/guard.js +64 -0
  21. package/dist/attach/live.js +222 -0
  22. package/dist/attach/plug.js +174 -0
  23. package/dist/attach/session.js +172 -0
  24. package/dist/cli.js +176 -0
  25. package/dist/mcp/server.js +240 -0
  26. package/dist/orchestrator/ask.js +51 -0
  27. package/dist/orchestrator/brain.js +164 -0
  28. package/dist/orchestrator/council.js +274 -0
  29. package/dist/orchestrator/handoff.js +117 -0
  30. package/dist/orchestrator/launcher.js +155 -0
  31. package/dist/orchestrator/planner.js +194 -0
  32. package/dist/orchestrator/qa.js +350 -0
  33. package/dist/orchestrator/scheduler.js +748 -0
  34. package/dist/orchestrator/takeover.js +85 -0
  35. package/dist/preflight.js +31 -0
  36. package/dist/relay/server.js +113 -0
  37. package/dist/room/db.js +617 -0
  38. package/dist/room/identity.js +122 -0
  39. package/dist/room/live.js +293 -0
  40. package/dist/room/progress.js +110 -0
  41. package/dist/room/relaylink.js +85 -0
  42. package/dist/room/server.js +752 -0
  43. package/dist/room/sqlite.js +156 -0
  44. package/dist/room/transport.js +107 -0
  45. package/dist/room/tunnel.js +85 -0
  46. package/dist/sandbox/sync.js +104 -0
  47. package/dist/sandbox/worktree.js +261 -0
  48. package/dist/types.js +4 -0
  49. package/dist/ui/app.js +1534 -0
  50. package/dist/ui/catalog.js +75 -0
  51. package/dist/ui/dashboard.js +192 -0
  52. package/dist/ui/doctor.js +54 -0
  53. package/dist/ui/logo.js +40 -0
  54. package/dist/ui/remote.js +332 -0
  55. package/dist/ui/story.js +932 -0
  56. package/dist/ui/term.js +114 -0
  57. package/dist/ui/watch.js +174 -0
  58. package/install.sh +44 -0
  59. package/package.json +60 -4
package/dist/cli.js ADDED
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ // huddle — the multiplayer layer for AI coding agents.
3
+ //
4
+ // huddle open Huddle (welcome, connect agents, prompt)
5
+ // huddle join "<goal>" plan a goal and run your agents on it
6
+ // huddle join "<goal>" --plan dry run: show the plan, create/spawn nothing
7
+ // --approvals=safe|auto|yolo what agents may do without asking (default auto)
8
+ // --agents=claude,codex only these agents work on this run
9
+ // --council / --no-council every agent brainstorms the design first (default: big goals)
10
+ // --qa / --no-qa a QA round after the build (default: on for big goals)
11
+ // huddle doctor is this machine ready: node, git, room db, your agents
12
+ // huddle --version print the version
13
+ // huddle agents --all every connector (built in + agents.json) and how to get it
14
+ // huddle agents install <id> install one connector's CLI, after asking
15
+ // huddle board print the board + shared memory
16
+ // huddle view [repo] the live view (ctrl-t) from another terminal, read-only
17
+ // huddle story [--html f] replay the latest run: who said what to whom, reviews, merges
18
+ // huddle init set up .huddle/ in this repo (automatic on first run)
19
+ // huddle up | down attach mode: start/stop this repo's live room
20
+ // huddle connect | disconnect wire your own claude/codex/cursor sessions into it
21
+ // huddle peers who's live in this repo and which files they hold
22
+ // huddle attach <agent> open an agent in its own worktree, connected
23
+ // huddle land [branch] tests -> rebase -> merge an attach session's branch
24
+ // huddle plug (internal) the MCP server each agent talks to
25
+ // huddle guard (internal) Claude's pre-edit hook in attach mode
26
+ import { packageInfo } from "./preflight.js"; // first: refuses old Node before anything else loads
27
+ import { runPlug } from "./mcp/server.js";
28
+ import { C } from "./ui/term.js";
29
+ import { connectAgents, ensureInit, huddleDir, printBoard, runApp, runGoal } from "./ui/app.js";
30
+ import { INVITE_CODE, joinRoom } from "./ui/remote.js";
31
+ import { startRelay } from "./relay/server.js";
32
+ import { runDoctor } from "./ui/doctor.js";
33
+ import { SqliteUnavailableError } from "./room/sqlite.js";
34
+ import { installConnector, printCatalog } from "./ui/catalog.js";
35
+ const USAGE = `${C.violet("huddle")} — your agents, in the same room
36
+
37
+ ${C.bold("huddle")} open Huddle
38
+ ${C.bold('huddle join "<goal>"')} plan the goal and run your agents
39
+ ${C.bold('huddle join "<goal>" --plan')} dry run — show who'd do what
40
+ ${C.bold(" --approvals=safe|auto|yolo")} what agents may do without asking (default auto)
41
+ ${C.bold(" --agents=claude,codex")} only these agents work on this run
42
+ ${C.bold(" --council | --no-council")} every agent brainstorms the design first ${C.faint("(default: big goals only)")}
43
+ ${C.bold(" --qa")} ${C.faint("/")} ${C.bold("--no-qa")} agents re-check the build and fix what they find ${C.faint("(default: big goals)")}
44
+ ${C.bold("huddle join <address> <CODE>")} join someone's room from an invite
45
+ ${C.bold("huddle doctor")} check this machine: node, git, your agents
46
+ ${C.bold("huddle agents --all")} every connector Huddle knows, installed or not
47
+ ${C.bold("huddle agents install <id>")} install one (asks first)
48
+ ${C.bold("huddle relay")} ${C.faint("[--port 8080]")} run a relay so rooms work across networks
49
+ ${C.bold("huddle board")} print the board
50
+ ${C.bold("huddle view")} ${C.faint("[repo]")} watch a run live from another terminal ${C.faint("(read-only)")}
51
+ ${C.bold("huddle story")} ${C.faint("[--html f]")} replay the last run: messages, reviews, merges ${C.faint("(--list, --run n)")}
52
+ ${C.bold("huddle --version")} print the version
53
+
54
+ ${C.faint("run claude, codex and cursor yourself — in one repo, safely:")}
55
+ ${C.bold("huddle connect")} wire your agents into this repo's live room ${C.faint("(asks first)")}
56
+ ${C.bold("huddle peers")} who's live, what they're doing, which files they hold
57
+ ${C.bold("huddle attach <agent>")} open an agent in its own worktree branch
58
+ ${C.bold("huddle land")} ${C.faint("[branch]")} test + merge that session's work
59
+ ${C.bold("huddle up")} ${C.faint("/")} ${C.bold("down")} ${C.faint("/")} ${C.bold("disconnect")} start · stop the live room · unwire
60
+ `;
61
+ async function main() {
62
+ const [cmd, ...rest] = process.argv.slice(2);
63
+ switch (cmd) {
64
+ case undefined:
65
+ if (!process.stdin.isTTY)
66
+ return void console.log(USAGE);
67
+ return runApp();
68
+ case "plug":
69
+ return runPlug();
70
+ case "init":
71
+ if (!ensureInit()) {
72
+ console.log(C.red("✗ Not a git repo.") + " Huddle sandboxes are git worktrees — run `git init` first.");
73
+ process.exit(1);
74
+ }
75
+ return void console.log(`${C.green("✓")} Huddle initialized in ${C.faint(huddleDir())}`);
76
+ case "doctor":
77
+ case "agents":
78
+ if (rest[0] === "install" && rest[1])
79
+ return void (await installConnector(rest[1]));
80
+ if (rest.includes("--all"))
81
+ return printCatalog();
82
+ process.exitCode = await runDoctor();
83
+ return;
84
+ case "join": {
85
+ // `huddle join <address> <CODE>` joins someone's room; anything else is a goal.
86
+ if (rest.length === 2 && INVITE_CODE.test(rest[1]))
87
+ return joinRoom(rest[0], rest[1]);
88
+ const goal = rest.filter((a) => !a.startsWith("--")).join(" ");
89
+ if (!goal) {
90
+ console.log(C.red("✗ Give a goal:") + ' huddle join "build the login page"');
91
+ process.exit(1);
92
+ }
93
+ const flag = rest.find((a) => a.startsWith("--approvals="))?.split("=")[1];
94
+ const approvals = (["safe", "auto", "yolo"].includes(flag ?? "") ? flag : "auto");
95
+ const probed = await connectAgents();
96
+ let results = probed;
97
+ // --agents=claude,codex: run with just these (the rest sit this one out).
98
+ const pick = rest.find((a) => a.startsWith("--agents="))?.split("=")[1];
99
+ if (pick !== undefined) {
100
+ const ids = pick.split(",").map((s) => s.trim()).filter(Boolean);
101
+ const unknown = ids.filter((id) => !results.some((r) => r.adapter.id === id));
102
+ if (!ids.length || unknown.length) {
103
+ console.log(C.red(`✗ --agents: no agent called ${unknown.join(", ") || "(empty)"}.`) + ` Known: ${results.map((r) => r.adapter.id).join(", ")}`);
104
+ process.exit(1);
105
+ }
106
+ const notReady = results.filter((r) => ids.includes(r.adapter.id) && r.state !== "ready").map((r) => r.adapter.id);
107
+ if (notReady.length)
108
+ console.log(C.yellow(`! not ready, skipped: ${notReady.join(", ")}`));
109
+ results = results.filter((r) => ids.includes(r.adapter.id));
110
+ }
111
+ // Non-interactive: nobody to ask, so it's plan (--plan) or auto.
112
+ // --council: every agent brainstorms the design first; --no-council: the brain plans alone.
113
+ const council = rest.includes("--no-council") ? false : rest.includes("--council") ? true : undefined;
114
+ const qa = rest.includes("--no-qa") ? false : rest.includes("--qa") ? true : undefined;
115
+ return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa });
116
+ }
117
+ case "board":
118
+ return printBoard();
119
+ case "view":
120
+ case "watch":
121
+ return (await import("./ui/watch.js")).runView(rest);
122
+ case "story":
123
+ return (await import("./ui/story.js")).runStory(rest);
124
+ case "up":
125
+ return (await import("./attach/session.js")).runUp(rest);
126
+ case "down":
127
+ return (await import("./attach/session.js")).runDown();
128
+ case "peers":
129
+ return (await import("./attach/session.js")).runPeers();
130
+ case "attach":
131
+ return (await import("./attach/session.js")).runAttach(rest);
132
+ case "land":
133
+ return (await import("./attach/session.js")).runLand(rest);
134
+ case "connect":
135
+ return (await import("./attach/connect.js")).runConnect(rest);
136
+ case "disconnect":
137
+ return (await import("./attach/connect.js")).runDisconnect();
138
+ case "guard":
139
+ return (await import("./attach/guard.js")).runGuard();
140
+ case "relay": {
141
+ // Run a relay so rooms work across networks. Host it anywhere Node runs.
142
+ const i = rest.indexOf("--port");
143
+ const port = Number(i >= 0 ? rest[i + 1] : process.env.PORT ?? 8080);
144
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
145
+ console.log(C.red("✗ --port needs a number from 0 to 65535,") + " e.g. huddle relay --port 8080");
146
+ process.exit(1);
147
+ }
148
+ const r = await startRelay(port, (l) => console.log(C.faint(new Date().toISOString().slice(11, 19) + " " + l)));
149
+ console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
150
+ return;
151
+ }
152
+ case "--version":
153
+ case "-v":
154
+ case "-V":
155
+ case "version":
156
+ return void console.log(packageInfo().version);
157
+ case "help":
158
+ case "--help":
159
+ case "-h":
160
+ return void console.log(USAGE);
161
+ default:
162
+ console.log(C.red(`✗ unknown command: ${cmd}\n`) + USAGE);
163
+ process.exit(1);
164
+ }
165
+ }
166
+ main().catch((e) => {
167
+ // A message for the user, not a stack trace. HUDDLE_DEBUG=1 shows the stack.
168
+ if (e instanceof SqliteUnavailableError)
169
+ console.error(C.red("✗ ") + e.message);
170
+ else
171
+ console.error(C.red("huddle error:"), e?.message ?? e);
172
+ if (process.env.HUDDLE_DEBUG && e?.stack)
173
+ console.error(C.faint(e.stack));
174
+ process.exit(1);
175
+ });
176
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,240 @@
1
+ // The Plug: one MCP server, spoken identically by every agent (Claude, Codex,
2
+ // Gemini, Cursor). It is launched per-agent as `huddle plug` and talks to the
3
+ // Room over HTTP, so the agent process stays stateless — the Room owns truth.
4
+ //
5
+ // The agent's identity is passed in via HUDDLE_AGENT_ID (set by the launcher),
6
+ // so each agent's calls are attributed correctly without the model having to
7
+ // remember who it is.
8
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
11
+ const ROOM = process.env.HUDDLE_ROOM_URL ?? "http://localhost:4737";
12
+ const AGENT = process.env.HUDDLE_AGENT_ID ?? "unknown-agent";
13
+ // Proves to the Room that this call comes from one of this session's own agents.
14
+ const SECRET = process.env.HUDDLE_SECRET ?? "";
15
+ async function room(path, method, body) {
16
+ const res = await fetch(`${ROOM}${path}`, {
17
+ method,
18
+ headers: { "content-type": "application/json", "x-huddle-secret": SECRET, "x-huddle-agent": AGENT },
19
+ body: method === "POST" ? JSON.stringify({ agent_id: AGENT, ...body }) : undefined,
20
+ });
21
+ return res.json();
22
+ }
23
+ const TOOLS = [
24
+ {
25
+ name: "huddle_board",
26
+ description: "See the whole shared task board: the goal, every agent, who claimed what, and live progress. Call this BEFORE you start work and whenever you finish a step.",
27
+ inputSchema: { type: "object", properties: {} },
28
+ handler: () => room("/board", "GET"),
29
+ },
30
+ {
31
+ name: "huddle_peers",
32
+ description: "See what every other agent is doing RIGHT NOW (status + current task). Use this to avoid duplicating work and to know if a teammate is close to done.",
33
+ inputSchema: { type: "object", properties: {} },
34
+ handler: () => room("/peers", "GET"),
35
+ },
36
+ {
37
+ name: "huddle_claim",
38
+ description: "Atomically claim a backlog task so it's yours. Fails if someone already took it or its dependencies aren't merged yet. Always claim before coding.",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: { task_id: { type: "string" } },
42
+ required: ["task_id"],
43
+ },
44
+ handler: (a) => room("/claim", "POST", { task_id: a.task_id }),
45
+ },
46
+ {
47
+ name: "huddle_check_overlap",
48
+ description: "Before you edit files/symbols, check whether any teammate has already claimed them. Pass entries as 'path' or 'path::symbol'. Returns anyone you'd collide with.",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: { touches: { type: "array", items: { type: "string" } } },
52
+ required: ["touches"],
53
+ },
54
+ handler: (a) => room("/check_overlap", "POST", { touches: a.touches }),
55
+ },
56
+ {
57
+ name: "huddle_announce_plan",
58
+ description: "Announce what you're about to change BEFORE writing code: your intent + the files/symbols you'll touch ('path' or 'path::symbol'). Takes soft leases and flags collisions early.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ task_id: { type: "string" },
63
+ intent: { type: "string" },
64
+ touches: { type: "array", items: { type: "string" } },
65
+ },
66
+ required: ["task_id", "intent", "touches"],
67
+ },
68
+ handler: (a) => room("/announce_plan", "POST", { task_id: a.task_id, intent: a.intent, touches: a.touches }),
69
+ },
70
+ {
71
+ name: "huddle_progress",
72
+ description: "Post live progress on your task (0-100) with a short note. Fans out instantly to the dashboard and to any teammate waiting on you.",
73
+ inputSchema: {
74
+ type: "object",
75
+ properties: {
76
+ task_id: { type: "string" },
77
+ pct: { type: "number" },
78
+ note: { type: "string" },
79
+ },
80
+ required: ["task_id", "pct"],
81
+ },
82
+ handler: (a) => room("/progress", "POST", { task_id: a.task_id, pct: a.pct, note: a.note ?? "" }),
83
+ },
84
+ {
85
+ name: "huddle_split",
86
+ description: "Your task is big and a teammate is free (you'll see a help offer in huddle_inbox)? Hand a separable PART of it to the room as a new task — ideally one in files you won't keep editing. Huddle gives it to a free agent and merges it separately; you keep the rest and must not do that part yourself. after_mine=true makes the part wait until your task merges.",
87
+ inputSchema: {
88
+ type: "object",
89
+ properties: {
90
+ task_id: { type: "string", description: "your task" },
91
+ title: { type: "string", description: "short title of the part you hand off" },
92
+ spec: { type: "string", description: "exactly what the part must do, incl. names/contracts it must match" },
93
+ files: { type: "array", items: { type: "string" }, description: "files the part owns" },
94
+ after_mine: { type: "boolean" },
95
+ },
96
+ required: ["task_id", "title", "spec"],
97
+ },
98
+ handler: (a) => room("/split", "POST", { task_id: a.task_id, title: a.title, spec: a.spec, files: a.files ?? [], after_mine: !!a.after_mine }),
99
+ },
100
+ {
101
+ name: "huddle_offer_help",
102
+ description: "You're done (or nearly) and could take more: tell every teammate who is mid-task that you can take part of their work. Returns what's in flight (owner, status, progress, last note, age). They decide whether to huddle_split.",
103
+ inputSchema: { type: "object", properties: {} },
104
+ handler: () => room("/offer_help", "POST", {}),
105
+ },
106
+ {
107
+ name: "huddle_remember",
108
+ description: "Write to SHARED MEMORY so other agents learn from you. kind = decision | fact | gotcha. Always include 'why' — teammates need the reasoning, not just the what.",
109
+ inputSchema: {
110
+ type: "object",
111
+ properties: {
112
+ kind: { type: "string", enum: ["decision", "fact", "gotcha"] },
113
+ body: { type: "string" },
114
+ why: { type: "string" },
115
+ },
116
+ required: ["kind", "body"],
117
+ },
118
+ handler: (a) => room("/remember", "POST", { kind: a.kind, body: a.body, why: a.why ?? "" }),
119
+ },
120
+ {
121
+ name: "huddle_recall",
122
+ description: "Read shared memory — the decisions, facts, and gotchas other agents have recorded. Call this at the start of any task to inherit context instead of re-deriving it.",
123
+ inputSchema: {
124
+ type: "object",
125
+ properties: { query: { type: "string" } },
126
+ },
127
+ handler: (a) => room(`/recall${a.query ? `?q=${encodeURIComponent(a.query)}` : ""}`, "GET"),
128
+ },
129
+ {
130
+ name: "huddle_ask",
131
+ description: "Ask a question another agent (on any teammate's machine) may know — e.g. 'what shape is the Order type?'. With `to` = a teammate agent that's working right now, it goes to THEIR inbox and waits a short while for their reply; otherwise (or if they're slow) it's answered from shared memory, and their late reply arrives in your huddle_inbox. Use it before guessing at a contract someone else owns.",
132
+ inputSchema: {
133
+ type: "object",
134
+ properties: {
135
+ question: { type: "string" },
136
+ to: { type: "string", description: "optional: the agent who owns it, e.g. 'codex' or 'harsh/codex' (see huddle_peers)" },
137
+ },
138
+ required: ["question"],
139
+ },
140
+ handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
141
+ },
142
+ {
143
+ name: "huddle_send",
144
+ 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, or \"all\" for everyone working now. They see it the next time they call any huddle tool.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {
148
+ to: { type: "string", description: "agent id (e.g. 'codex', 'claude#2', 'harsh/codex'), a task id, or 'all'" },
149
+ message: { type: "string" },
150
+ reply_to: { type: "string", description: "optional: id of the message/question you're answering" },
151
+ },
152
+ required: ["message"],
153
+ },
154
+ handler: (a) => room("/send", "POST", { to: a.to, message: a.message, reply_to: a.reply_to }),
155
+ },
156
+ {
157
+ name: "huddle_inbox",
158
+ description: "Read messages and questions teammates sent you (marks them read). Any huddle tool result that says 'unread_messages' means: call this now. Answer questions with huddle_send(reply_to=…).",
159
+ inputSchema: { type: "object", properties: {} },
160
+ handler: () => room("/inbox", "GET"),
161
+ },
162
+ {
163
+ name: "huddle_report_bug",
164
+ description: "QA: file ONE concrete, reproducible bug you found in the team's merged work. It becomes a fix task on the board, routed to the agent that wrote those files. Exact steps or a command in `repro`; `files` = where the defect is. When sensible, add a regression test that fails today: write it in your worktree and pass its path as `test_file` (or its content as `test_code`). Duplicates are merged automatically. Not for style nits.",
165
+ inputSchema: {
166
+ type: "object",
167
+ properties: {
168
+ title: { type: "string", description: "short: what is broken" },
169
+ repro: { type: "string", description: "exact command(s) or steps that show it" },
170
+ expected: { type: "string" },
171
+ actual: { type: "string" },
172
+ files: { type: "array", items: { type: "string" }, description: "files the defect is in" },
173
+ test_file: { type: "string", description: "optional: path of a regression test you wrote (fails now, passes once fixed)" },
174
+ test_code: { type: "string", description: "optional: that test's full content, if you didn't write the file" },
175
+ },
176
+ required: ["title", "repro", "expected", "actual"],
177
+ },
178
+ handler: (a) => room("/report_bug", "POST", {
179
+ title: a.title,
180
+ repro: a.repro,
181
+ expected: a.expected,
182
+ actual: a.actual,
183
+ files: a.files ?? [],
184
+ test_file: a.test_file,
185
+ test_code: a.test_code,
186
+ }),
187
+ },
188
+ {
189
+ name: "huddle_take_backlog",
190
+ description: "You're free. Ask the Room for the highest-value unblocked backlog task that fits your strengths, and claim it automatically. This is how idle agents remove bottlenecks.",
191
+ inputSchema: {
192
+ type: "object",
193
+ properties: { prefers: { type: "array", items: { type: "string" } } },
194
+ },
195
+ handler: (a) => room("/take_backlog", "POST", { prefers: a.prefers ?? [] }),
196
+ },
197
+ {
198
+ name: "huddle_done",
199
+ description: "Mark your task ready. This triggers the test-gate and, if green, the merge into main. Include a short summary of what you did.",
200
+ inputSchema: {
201
+ type: "object",
202
+ properties: { task_id: { type: "string" }, summary: { type: "string" } },
203
+ required: ["task_id", "summary"],
204
+ },
205
+ handler: (a) => room("/done", "POST", { task_id: a.task_id, summary: a.summary }),
206
+ },
207
+ ];
208
+ /**
209
+ * What an MCP host shows the model about the Plug as a whole: the verbs by
210
+ * phase of a task, so every vendor's agent learns the same team habits. Every
211
+ * tool in TOOLS is named here (test/team-run.test.ts checks).
212
+ */
213
+ export const PLUG_INSTRUCTIONS = `Huddle: you are one of several AI coding agents working on one repo at once, each in its own git worktree. These tools are how the team talks.
214
+ - 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.
215
+ - Before editing: huddle_announce_plan (what you will touch), huddle_check_overlap (is someone else in that file).
216
+ - While working: huddle_progress (a short note; the reply shows the team's in-flight work), huddle_remember (a decision or contract others need), huddle_send (message one teammate), huddle_ask (ask a teammate or the brain a question), huddle_inbox (read replies).
217
+ - 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.
218
+ - QA rounds: huddle_report_bug files one reproducible bug in the merged build; it becomes a fix task for the file's author.
219
+ - Finish: huddle_done with a short summary. Huddle tests and merges it.`;
220
+ /** The Plug's verb names, in the order listed to agents. */
221
+ export const PLUG_VERBS = TOOLS.map((t) => t.name);
222
+ export async function runPlug() {
223
+ // Attach mode (`huddle connect`): a session you started yourself, no scheduler.
224
+ if (process.env.HUDDLE_LIVE)
225
+ return (await import("../attach/plug.js")).runLivePlug();
226
+ const server = new Server({ name: "huddle", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
227
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
228
+ tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
229
+ }));
230
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
231
+ const tool = TOOLS.find((t) => t.name === req.params.name);
232
+ if (!tool)
233
+ throw new Error(`unknown tool: ${req.params.name}`);
234
+ const result = await tool.handler((req.params.arguments ?? {}));
235
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
236
+ });
237
+ const transport = new StdioServerTransport();
238
+ await server.connect(transport);
239
+ }
240
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,51 @@
1
+ // One-shot, read-only questions to an agent: answering "where are we with
2
+ // this project?" and planning a goal. Runs in the repo itself (no worktree),
3
+ // with each vendor's read-only / plan mode, so nothing can be edited.
4
+ import { spawn } from "node:child_process";
5
+ import { randomUUID } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ /** Preference order for who answers questions and plans. */
10
+ const ASK_ORDER = ["claude", "codex", "antigravity", "cursor", "gemini"];
11
+ export function pickAsker(ready) {
12
+ return [...ready].filter((a) => a.ask).sort((a, b) => ASK_ORDER.indexOf(a.id) - ASK_ORDER.indexOf(b.id))[0];
13
+ }
14
+ export function askAgent(adapter, prompt, cwd, opts = {}) {
15
+ // Unique per call: the design council asks several agents in the same millisecond.
16
+ const outFile = path.join(os.tmpdir(), `huddle-ask-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
17
+ const spec = adapter.ask({ prompt, cwd, outFile, model: opts.model, noTools: opts.noTools, quick: opts.quick });
18
+ return new Promise((resolve) => {
19
+ const child = spawn(spec.cmd[0], spec.cmd.slice(1), { cwd: spec.cwd, stdio: ["ignore", "pipe", "pipe"] });
20
+ let out = "";
21
+ let err = "";
22
+ child.stdout.on("data", (d) => (out += d));
23
+ child.stderr.on("data", (d) => (err += d));
24
+ // SIGKILL after a grace period: a stalled CLI can ignore SIGTERM (seen live with cursor-agent).
25
+ const stop = () => {
26
+ if (child.exitCode !== null || child.signalCode !== null)
27
+ return;
28
+ child.kill("SIGTERM");
29
+ setTimeout(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"), 5000).unref();
30
+ };
31
+ const timer = setTimeout(stop, opts.timeoutMs ?? 5 * 60_000);
32
+ opts.signal?.addEventListener("abort", stop);
33
+ const finish = (code) => {
34
+ clearTimeout(timer);
35
+ let text = out;
36
+ if (spec.usesOutFile) {
37
+ try {
38
+ text = fs.readFileSync(outFile, "utf8");
39
+ fs.unlinkSync(outFile);
40
+ }
41
+ catch {
42
+ /* fall back to stdout */
43
+ }
44
+ }
45
+ resolve({ ok: code === 0 && text.trim().length > 0, text: text.trim() || err.trim() });
46
+ };
47
+ child.on("exit", finish);
48
+ child.on("error", () => finish(-1));
49
+ });
50
+ }
51
+ //# sourceMappingURL=ask.js.map
@@ -0,0 +1,164 @@
1
+ // The brain: the model Huddle itself thinks with — planning goals, reviewing
2
+ // diffs, writing the "what you got" report. It's separate from the agents that
3
+ // write code, so it can be something small and cheap:
4
+ //
5
+ // ollama:<model> a local model (e.g. Qwen2.5-Coder) — free, private, offline
6
+ // cursor:<model> a cheap hosted model through Cursor (e.g. grok-4.7-low-fast)
7
+ // claude:<model> Claude at a given size (claude:haiku, claude:opus)
8
+ // codex / antigravity[:model]
9
+ //
10
+ // Agent-backed brains can read the repo themselves. A local model can't, so
11
+ // every prompt carries a compact repo snapshot (see repoContext) — which also
12
+ // makes agent brains faster, since they don't have to explore first.
13
+ import { spawnSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { ADAPTERS } from "../adapters/index.js";
17
+ import { askAgent } from "./ask.js";
18
+ const OLLAMA = process.env.OLLAMA_HOST?.replace(/\/$/, "") || "http://127.0.0.1:11434";
19
+ /** Models a local Ollama has pulled, or [] if Ollama isn't running. */
20
+ export async function ollamaModels() {
21
+ try {
22
+ const r = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(1500) });
23
+ const j = await r.json();
24
+ return (j.models ?? []).map((m) => String(m.name).replace(/:latest$/, ""));
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ function ollamaBrain(model) {
31
+ return {
32
+ spec: `ollama:${model}`,
33
+ label: `${model} (local, free)`,
34
+ readsRepo: false,
35
+ async think(prompt, _cwd, opts = {}) {
36
+ const t0 = Date.now();
37
+ try {
38
+ const signals = [AbortSignal.timeout(opts.timeoutMs ?? 5 * 60_000), ...(opts.signal ? [opts.signal] : [])];
39
+ const r = await fetch(`${OLLAMA}/api/chat`, {
40
+ method: "POST",
41
+ headers: { "content-type": "application/json" },
42
+ signal: AbortSignal.any(signals),
43
+ body: JSON.stringify({
44
+ model,
45
+ stream: false,
46
+ ...(opts.json ? { format: "json" } : {}),
47
+ options: { temperature: 0.2, num_ctx: 16384 },
48
+ messages: [{ role: "user", content: prompt }],
49
+ }),
50
+ });
51
+ const j = await r.json();
52
+ const text = String(j?.message?.content ?? "").trim();
53
+ return { ok: r.ok && !!text, text: text || String(j?.error ?? ""), tokens: (j.prompt_eval_count ?? 0) + (j.eval_count ?? 0), ms: Date.now() - t0 };
54
+ }
55
+ catch (e) {
56
+ return { ok: false, text: String(e?.message ?? e), ms: Date.now() - t0 };
57
+ }
58
+ },
59
+ };
60
+ }
61
+ function agentBrain(adapterId, model) {
62
+ const adapter = ADAPTERS.find((a) => a.id === adapterId);
63
+ if (!adapter?.ask)
64
+ return null;
65
+ return {
66
+ spec: model ? `${adapterId}:${model}` : adapterId,
67
+ label: model ? `${adapterId} · ${model}` : adapterId,
68
+ readsRepo: true,
69
+ async think(prompt, cwd, opts = {}) {
70
+ const t0 = Date.now();
71
+ const r = await askAgent(adapter, prompt, cwd, {
72
+ signal: opts.signal,
73
+ timeoutMs: opts.timeoutMs,
74
+ model,
75
+ noTools: opts.selfContained,
76
+ });
77
+ return { ...r, ms: Date.now() - t0 };
78
+ },
79
+ };
80
+ }
81
+ /** Parse a brain spec like "ollama:qwencoder" or "cursor:grok-4.7-low-fast". */
82
+ export function brainFrom(spec) {
83
+ const [kind, ...rest] = spec.split(":");
84
+ const model = rest.join(":") || undefined;
85
+ if (kind === "ollama")
86
+ return model ? ollamaBrain(model) : null;
87
+ return agentBrain(kind, model);
88
+ }
89
+ /** The default when nothing's configured: the strongest agent you have ready. */
90
+ export const DEFAULT_BRAIN = "claude";
91
+ /** Suggestions for the `brain` command, cheapest first. */
92
+ export async function brainOptions(readyAgents) {
93
+ const out = [];
94
+ for (const m of await ollamaModels()) {
95
+ if (/llava|vision|embed/i.test(m))
96
+ continue; // not text planners
97
+ out.push({ spec: `ollama:${m}`, note: "local · free · private · speed depends on your RAM" });
98
+ }
99
+ if (readyAgents.includes("cursor"))
100
+ out.push({ spec: "cursor:grok-4.7-low-fast", note: "cheap + fast, via your Cursor plan" });
101
+ if (readyAgents.includes("claude")) {
102
+ out.push({ spec: "claude:haiku", note: "smallest Claude — cheapest hosted option" });
103
+ out.push({ spec: "claude:sonnet", note: "default — plans in ~15s, a fraction of Opus's tokens" });
104
+ out.push({ spec: "claude", note: "Opus — strongest plans, most tokens" });
105
+ }
106
+ if (readyAgents.includes("codex"))
107
+ out.push({ spec: "codex", note: "your Codex default model" });
108
+ return out;
109
+ }
110
+ /**
111
+ * A compact snapshot of the repo for the brain: file list, README head,
112
+ * manifest. ~a few thousand tokens; lets even a local model plan sensibly.
113
+ */
114
+ export function repoContext(cwd, maxFiles = 250) {
115
+ const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean);
116
+ const head = (f, lines) => {
117
+ try {
118
+ return fs.readFileSync(path.join(cwd, f), "utf8").split("\n").slice(0, lines).join("\n");
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ };
124
+ const parts = [`FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
125
+ for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
126
+ const h = head(f, 40);
127
+ if (h)
128
+ parts.push(`${f}:\n${h}`);
129
+ }
130
+ const readme = files.find((f) => /^readme(\.md)?$/i.test(f));
131
+ if (readme) {
132
+ const h = head(readme, 40);
133
+ if (h)
134
+ parts.push(`${readme} (first 40 lines):\n${h}`);
135
+ }
136
+ // Small repo: show the source itself. A tool-less planner that only sees
137
+ // file names tries to "cat" them anyway (seen live: Sonnet replied with a
138
+ // fake bash call instead of a plan, Opus hung) and misses rules that live
139
+ // in code, like a test requiring every module in an index.
140
+ if (files.length <= 80) {
141
+ const rank = (f) => (/(^|\/)(index|main|app|registry|routes?)\.[a-z]+$/i.test(f) ? 0 : /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\./i.test(f) ? 1 : 2);
142
+ const skip = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|readme(\.md)?|package\.json)$|\.(png|jpe?g|gif|ico|pdf|zip|gz|woff2?|mp[34]|lock)$/i;
143
+ let budget = 24_000;
144
+ for (const f of [...files].sort((a, b) => rank(a) - rank(b))) {
145
+ if (skip.test(f))
146
+ continue;
147
+ let body;
148
+ try {
149
+ if (fs.statSync(path.join(cwd, f)).size > 4000)
150
+ continue;
151
+ body = fs.readFileSync(path.join(cwd, f), "utf8");
152
+ }
153
+ catch {
154
+ continue;
155
+ }
156
+ if (body.includes("\u0000") || body.length > budget)
157
+ continue;
158
+ budget -= body.length;
159
+ parts.push(`${f}:\n${body.trimEnd()}`);
160
+ }
161
+ }
162
+ return parts.join("\n\n");
163
+ }
164
+ //# sourceMappingURL=brain.js.map