huddle-cli 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +9 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. package/package.json +6 -3
package/dist/cli.js CHANGED
@@ -8,96 +8,165 @@
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%
12
+ // --with <file|dir|glob> put files on the shared screen first (repeatable); piped stdin too
13
+ // huddle setup install + sign in to the agents you pick (asks first)
11
14
  // huddle doctor is this machine ready: node, git, room db, your agents
15
+ // huddle agents your agents: ready / not signed in / not installed, used or skipped
16
+ // huddle agents use|skip <ids> choose who works with you (remembered)
12
17
  // huddle --version print the version
13
18
  // huddle agents --all every connector (built in + agents.json) and how to get it
14
19
  // huddle agents install <id> install one connector's CLI, after asking
15
20
  // huddle board print the board + shared memory
16
21
  // 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
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)
26
+ // huddle lessons what Huddle learned across runs (--forget <id>, --clear)
18
27
  // huddle init set up .huddle/ in this repo (automatic on first run)
19
28
  // huddle up | down attach mode: start/stop this repo's live room
20
29
  // huddle connect | disconnect wire your own claude/codex/cursor sessions into it
21
30
  // huddle peers who's live in this repo and which files they hold
31
+ // huddle add <file|dir|glob|-> put it on the live room's shared screen (- = stdin)
32
+ // huddle ctx [show|rm|note|clear] what's on that screen; take things off, pin notes
22
33
  // huddle attach <agent> open an agent in its own worktree, connected
23
34
  // huddle land [branch] tests -> rebase -> merge an attach session's branch
24
35
  // huddle plug (internal) the MCP server each agent talks to
25
36
  // huddle guard (internal) Claude's pre-edit hook in attach mode
37
+ // huddle mail --hook (internal) Claude's post-tool hook: teammates' new messages
26
38
  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";
39
+ import { C, useAsciiOutput } from "./ui/term.js";
40
+ import { asGoal } from "./args.js";
33
41
  import { SqliteUnavailableError } from "./room/sqlite.js";
34
- import { installConnector, printCatalog } from "./ui/catalog.js";
42
+ // Everything heavier loads per command: `huddle mail --hook` runs after every
43
+ // one of Claude's tool calls and has to start in well under 300ms.
44
+ const app = () => import("./ui/app.js");
45
+ const B = C.bold;
46
+ const F = C.faint;
47
+ // Every line fits 80 columns.
35
48
  const USAGE = `${C.violet("huddle")} — your agents, in the same room
36
49
 
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
50
+ ${B("huddle")} open Huddle
51
+ ${B('huddle join "<goal>"')} plan the goal and run your agents
52
+ ${B("--plan")} dry run: show who'd do what
53
+ ${B("--agents=claude,codex")} only these agents work on this run
54
+ ${B("--approvals=safe|auto|yolo")} what agents may do unasked ${F("(default auto)")}
55
+ ${B("--council")} ${F("/")} ${B("--no-council")} design council first ${F("(default: big goals)")}
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%)")}
58
+ ${B("--with <file>")} put a file, folder or glob on the shared screen
59
+ ${F("(repeatable; or pipe: cat log | huddle join …)")}
60
+ ${B("huddle app")} the dashboard: live run, history, talk to agents
61
+ ${B("huddle join <address> <CODE>")} join someone's room from an invite
53
62
 
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
63
+ ${F("watch and replay")}
64
+ ${B("huddle view")} ${F("[repo]")} watch a run live, read-only, from a 2nd terminal
65
+ ${B("huddle story")} ${F("[--html f]")} replay the last run: ideas, contract, merges
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)")}
73
+ ${B("huddle board")} print the board
74
+ ${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
75
+
76
+ ${F("your agents")}
77
+ ${B("huddle setup")} install + sign in to your agents ${F("(--yes)")}
78
+ ${B("huddle doctor")} check this machine: node, git, your agents
79
+ ${B("huddle agents")} your agents: ready or not, used or skipped
80
+ ${B("huddle agents use|skip <ids>")} choose who works with you ${F("(e.g. skip cursor)")}
81
+ ${B("huddle agents --all")} every connector Huddle knows, installed or not
82
+ ${B("huddle agents install <id>")} install one ${F("(asks first)")}
83
+ ${B("huddle relay")} ${F("[--port 8080]")} run a relay so rooms work across networks
84
+ ${B("huddle --version")} print the version
85
+
86
+ ${F("run claude, codex and cursor yourself — in one repo, safely")}
87
+ ${B("huddle connect")} wire your agents into this repo's live room
88
+ ${B("huddle peers")} who's live, what they're on, their files
89
+ ${B("huddle add <file|->")} put a file, folder, glob or stdin on the screen
90
+ ${F("(cat err.log | huddle add -)")}
91
+ ${B("huddle ctx")} ${F("[show|rm|note|clear]")} what's on the screen every session reads
92
+ ${B("huddle attach <agent>")} open an agent in its own worktree branch
93
+ ${B("huddle land")} ${F("[branch]")} test + merge that session's work
94
+ ${B("huddle up")} ${F("/")} ${B("down")} ${F("/")} ${B("disconnect")} start · stop the live room · unwire
60
95
  `;
61
96
  async function main() {
62
- const [cmd, ...rest] = process.argv.slice(2);
97
+ const argv = process.argv.slice(2);
98
+ const [cmd, ...rest] = asGoal(argv) ?? argv;
99
+ // Legacy Windows consoles get ASCII glyphs. Not for the machine-read channels: the Plug's JSON-RPC and Claude's hooks.
100
+ if (!["plug", "guard", "mail"].includes(cmd ?? ""))
101
+ useAsciiOutput();
63
102
  switch (cmd) {
64
103
  case undefined:
65
104
  if (!process.stdin.isTTY)
66
105
  return void console.log(USAGE);
67
- return runApp();
106
+ return (await app()).runApp();
68
107
  case "plug":
69
- return runPlug();
70
- case "init":
108
+ return (await import("./mcp/server.js")).runPlug();
109
+ case "init": {
110
+ const { ensureInit, huddleDir } = await app();
71
111
  if (!ensureInit()) {
72
112
  console.log(C.red("✗ Not a git repo.") + " Huddle sandboxes are git worktrees — run `git init` first.");
73
113
  process.exit(1);
74
114
  }
75
115
  return void console.log(`${C.green("✓")} Huddle initialized in ${C.faint(huddleDir())}`);
76
- case "doctor":
77
- case "agents":
116
+ }
117
+ case "setup":
118
+ process.exitCode = await (await import("./ui/setup.js")).runSetup(rest);
119
+ return;
120
+ case "agents": {
121
+ const { chooseAgents, installConnector, printAgents, printCatalog } = await import("./ui/catalog.js");
78
122
  if (rest[0] === "install" && rest[1])
79
123
  return void (await installConnector(rest[1]));
124
+ if ((rest[0] === "use" || rest[0] === "skip") && rest.length > 1)
125
+ return void (process.exitCode = chooseAgents(rest[0], rest.slice(1)) ? 0 : 1);
80
126
  if (rest.includes("--all"))
81
127
  return printCatalog();
82
- process.exitCode = await runDoctor();
128
+ if (!rest.length)
129
+ return printAgents();
130
+ process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
131
+ return;
132
+ }
133
+ case "doctor":
134
+ process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
83
135
  return;
84
136
  case "join": {
85
137
  // `huddle join <address> <CODE>` joins someone's room; anything else is a goal.
138
+ const { INVITE_CODE, joinRoom } = await import("./ui/remote.js");
86
139
  if (rest.length === 2 && INVITE_CODE.test(rest[1]))
87
140
  return joinRoom(rest[0], rest[1]);
88
- const goal = rest.filter((a) => !a.startsWith("--")).join(" ");
141
+ const { connectAgents, dbPath, ensureInit, runGoal } = await app();
142
+ const { addForRun, readPipedStdin, takeWith } = await import("./ui/paste.js");
143
+ const withs = takeWith(rest);
144
+ const goal = withs.rest.filter((a) => !a.startsWith("--")).join(" ");
89
145
  if (!goal) {
90
146
  console.log(C.red("✗ Give a goal:") + ' huddle join "build the login page"');
91
147
  process.exit(1);
92
148
  }
149
+ // The shared screen first (--with files, piped stdin), so the planner sees its index.
150
+ const piped = await readPipedStdin();
151
+ if (withs.files.length || piped.trim()) {
152
+ if (!ensureInit()) {
153
+ console.log(C.red("✗ Not a git repo.") + " The shared screen lives in a repo's .huddle/ — run `git init` first.");
154
+ process.exit(1);
155
+ }
156
+ if (await addForRun(dbPath(), withs.files, piped, (l = "") => console.log(l))) {
157
+ console.log(C.red("✗ couldn't put everything on the shared screen (above) — nothing was run."));
158
+ process.exit(1);
159
+ }
160
+ }
93
161
  const flag = rest.find((a) => a.startsWith("--approvals="))?.split("=")[1];
94
162
  const approvals = (["safe", "auto", "yolo"].includes(flag ?? "") ? flag : "auto");
95
- const probed = await connectAgents();
163
+ let probed = await connectAgents();
96
164
  let results = probed;
97
- // --agents=claude,codex: run with just these (the rest sit this one out).
165
+ // --agents=claude,codex: run with just these (the rest sit this one out) — even ones you skipped.
98
166
  const pick = rest.find((a) => a.startsWith("--agents="))?.split("=")[1];
99
167
  if (pick !== undefined) {
100
168
  const ids = pick.split(",").map((s) => s.trim()).filter(Boolean);
169
+ results = probed = (await import("./adapters/index.js")).unskip(probed, ids);
101
170
  const unknown = ids.filter((id) => !results.some((r) => r.adapter.id === id));
102
171
  if (!ids.length || unknown.length) {
103
172
  console.log(C.red(`✗ --agents: no agent called ${unknown.join(", ") || "(empty)"}.`) + ` Known: ${results.map((r) => r.adapter.id).join(", ")}`);
@@ -112,21 +181,38 @@ async function main() {
112
181
  // --council: every agent brainstorms the design first; --no-council: the brain plans alone.
113
182
  const council = rest.includes("--no-council") ? false : rest.includes("--council") ? true : undefined;
114
183
  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 });
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 });
116
186
  }
187
+ case "app":
188
+ case "dashboard":
189
+ return (await import("./app/server.js")).runApp(rest);
117
190
  case "board":
118
- return printBoard();
191
+ return (await app()).printBoard();
119
192
  case "view":
120
193
  case "watch":
121
194
  return (await import("./ui/watch.js")).runView(rest);
122
195
  case "story":
123
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);
203
+ case "lessons":
204
+ return (await import("./ui/lessons.js")).runLessons(rest);
124
205
  case "up":
125
206
  return (await import("./attach/session.js")).runUp(rest);
126
207
  case "down":
127
208
  return (await import("./attach/session.js")).runDown();
128
209
  case "peers":
129
210
  return (await import("./attach/session.js")).runPeers();
211
+ case "add":
212
+ return (await import("./attach/screen.js")).runAdd(rest);
213
+ case "ctx":
214
+ case "context":
215
+ return (await import("./attach/screen.js")).runCtx(rest);
130
216
  case "attach":
131
217
  return (await import("./attach/session.js")).runAttach(rest);
132
218
  case "land":
@@ -137,6 +223,11 @@ async function main() {
137
223
  return (await import("./attach/connect.js")).runDisconnect();
138
224
  case "guard":
139
225
  return (await import("./attach/guard.js")).runGuard();
226
+ case "mail":
227
+ if (rest.includes("--hook"))
228
+ return (await import("./room/mail.js")).runMailHook(rest);
229
+ console.log(C.red("✗ huddle mail is internal:") + " agents read mail through the Plug (huddle_inbox).");
230
+ process.exit(1);
140
231
  case "relay": {
141
232
  // Run a relay so rooms work across networks. Host it anywhere Node runs.
142
233
  const i = rest.indexOf("--port");
@@ -145,6 +236,7 @@ async function main() {
145
236
  console.log(C.red("✗ --port needs a number from 0 to 65535,") + " e.g. huddle relay --port 8080");
146
237
  process.exit(1);
147
238
  }
239
+ const { startRelay } = await import("./relay/server.js");
148
240
  const r = await startRelay(port, (l) => console.log(C.faint(new Date().toISOString().slice(11, 19) + " " + l)));
149
241
  console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
150
242
  return;
@@ -0,0 +1,79 @@
1
+ // The Plug's shared-screen verbs (src/room/context.ts is the Room side).
2
+ // Kept out of server.ts so the screen is one file to read.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ export function contextTools(room) {
7
+ return [
8
+ {
9
+ name: "huddle_context",
10
+ description: "The SHARED SCREEN: everything the user pasted, dropped or uploaded for the whole team (logs, docs, code, screenshots, PDFs) — one copy in the Room, not in your prompt. Lists each item's id, kind, title, size, summary, sections and the notes pinned on it.",
11
+ inputSchema: { type: "object", properties: {} },
12
+ handler: () => room("/context", "GET"),
13
+ },
14
+ {
15
+ name: "huddle_context_read",
16
+ description: "Read one item from the shared screen. Text comes back in chunks of up to 400 lines: pass from_line/to_line, or section (a number or title from huddle_context). Images and PDFs come back as a file path — open it with your image/file reading tool.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ id: { type: "string", description: "the item id, e.g. ctx-3" },
21
+ from_line: { type: "number" },
22
+ to_line: { type: "number" },
23
+ section: { type: "string", description: "optional: a section number or title" },
24
+ },
25
+ required: ["id"],
26
+ },
27
+ handler: (a) => room("/context_read", "POST", { id: a.id, from_line: a.from_line, to_line: a.to_line, section: a.section }),
28
+ },
29
+ {
30
+ name: "huddle_context_search",
31
+ description: "Search every text item on the shared screen. Returns matching lines with the item id and line number, so you can huddle_context_read around them.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: { query: { type: "string" }, regex: { type: "boolean", description: "treat query as a regular expression" } },
35
+ required: ["query"],
36
+ },
37
+ handler: (a) => room("/context_search", "POST", { query: a.query, regex: !!a.regex }),
38
+ },
39
+ {
40
+ name: "huddle_context_note",
41
+ description: "Pin a short comment on a shared-screen item — what it means, what you found in it, which part you're handling. Every agent and the user sees it next to the item. This is how the team talks about the same document.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { id: { type: "string" }, note: { type: "string" } },
45
+ required: ["id", "note"],
46
+ },
47
+ handler: (a) => room("/context_note", "POST", { id: a.id, note: a.note }),
48
+ },
49
+ ];
50
+ }
51
+ /**
52
+ * A Room result as MCP content. Screenshots also go as an image block (hosts
53
+ * that render MCP images show it to the model directly). Bytes sent across
54
+ * machines (a teammate's agent) are saved here and replaced by a local path.
55
+ */
56
+ export function toContent(result) {
57
+ if (!result || typeof result !== "object" || Array.isArray(result))
58
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
59
+ const { image, file, ...rest } = result;
60
+ if (file?.data_base64 && file?.name) {
61
+ try {
62
+ const dir = path.join(os.tmpdir(), "huddle-context");
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const p = path.join(dir, String(file.name).replace(/[^\w.-]/g, "_"));
65
+ fs.writeFileSync(p, Buffer.from(String(file.data_base64), "base64"));
66
+ rest.path = p;
67
+ rest.note = `Open this file with your image/file reading tool (e.g. Read ${p}).`;
68
+ }
69
+ catch (e) {
70
+ rest.note = `couldn't save the file here: ${e?.message ?? e}`;
71
+ }
72
+ }
73
+ const out = [{ type: "text", text: JSON.stringify(rest, null, 2) }];
74
+ // HUDDLE_CTX_MCP_IMAGES=0: path only, for a host that can't take image blocks.
75
+ if (image?.data_base64 && image?.mime && process.env.HUDDLE_CTX_MCP_IMAGES !== "0")
76
+ out.push({ type: "image", data: String(image.data_base64), mimeType: String(image.mime) });
77
+ return out;
78
+ }
79
+ //# sourceMappingURL=context-tools.js.map
@@ -8,6 +8,8 @@
8
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
9
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
10
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
11
+ import { contextTools, toContent } from "./context-tools.js";
12
+ import { packageInfo } from "../preflight.js";
11
13
  const ROOM = process.env.HUDDLE_ROOM_URL ?? "http://localhost:4737";
12
14
  const AGENT = process.env.HUDDLE_AGENT_ID ?? "unknown-agent";
13
15
  // Proves to the Room that this call comes from one of this session's own agents.
@@ -105,11 +107,11 @@ const TOOLS = [
105
107
  },
106
108
  {
107
109
  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.",
110
+ description: "Write to SHARED MEMORY so other agents learn from you. kind = decision | fact | gotcha | lesson. Always include 'why' — teammates need the reasoning, not just the what. kind=lesson is also kept for FUTURE runs in this repo: one short, general rule you'd want next time (e.g. 'run npm run typecheck too: npm test doesn't type-check').",
109
111
  inputSchema: {
110
112
  type: "object",
111
113
  properties: {
112
- kind: { type: "string", enum: ["decision", "fact", "gotcha"] },
114
+ kind: { type: "string", enum: ["decision", "fact", "gotcha", "lesson"] },
113
115
  body: { type: "string" },
114
116
  why: { type: "string" },
115
117
  },
@@ -139,9 +141,27 @@ const TOOLS = [
139
141
  },
140
142
  handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
141
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
+ },
142
162
  {
143
163
  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.",
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.",
145
165
  inputSchema: {
146
166
  type: "object",
147
167
  properties: {
@@ -154,10 +174,30 @@ const TOOLS = [
154
174
  handler: (a) => room("/send", "POST", { to: a.to, message: a.message, reply_to: a.reply_to }),
155
175
  },
156
176
  {
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=…).",
177
+ name: "huddle_contract",
178
+ description: "Read the team's CONTRACT: the exact cross-task names everyone codes to (files and owners, function signatures, data fields, CLI flags, routes, error strings), its version, and every amendment since. Check it before you name anything another task uses.",
159
179
  inputSchema: { type: "object", properties: {} },
160
- handler: () => room("/inbox", "GET"),
180
+ handler: () => room("/contract", "GET"),
181
+ },
182
+ {
183
+ name: "huddle_amend_contract",
184
+ description: "Change or add a name in the CONTRACT (a signature, a field, a file owner, an error string) when your task needs one that isn't there or is wrong. Bumps the version and messages every teammate working now. Never invent a name another task depends on without this.",
185
+ inputSchema: {
186
+ type: "object",
187
+ properties: {
188
+ section: { type: "string", description: "what part it changes, e.g. 'store.js exports' or 'error strings'" },
189
+ change: { type: "string", description: "the exact new name/signature/shape" },
190
+ why: { type: "string" },
191
+ },
192
+ required: ["section", "change", "why"],
193
+ },
194
+ handler: (a) => room("/amend_contract", "POST", { section: a.section, change: a.change, why: a.why }),
195
+ },
196
+ {
197
+ name: "huddle_inbox",
198
+ description: "Read messages and questions teammates sent you (marks them read). Huddle tool results already carry new ones (new_messages); call this for more, or with id for a message shown cut short. Answer questions with huddle_send(reply_to=…).",
199
+ inputSchema: { type: "object", properties: { id: { type: "string", description: "optional: one message's id" } } },
200
+ handler: (a) => room(a?.id ? `/inbox?id=${encodeURIComponent(String(a.id))}` : "/inbox", "GET"),
161
201
  },
162
202
  {
163
203
  name: "huddle_report_bug",
@@ -196,7 +236,9 @@ const TOOLS = [
196
236
  },
197
237
  {
198
238
  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.",
239
+ 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. " +
240
+ "If a teammate's buddy note or a radar finding on your task has no answer yet, it is refused once with the list: fix each one, or reply " +
241
+ "huddle_send(to=<who sent it>, reply_to=<its id>, message=\"won't fix: <why>\"), then call huddle_done again.",
200
242
  inputSchema: {
201
243
  type: "object",
202
244
  properties: { task_id: { type: "string" }, summary: { type: "string" } },
@@ -204,6 +246,7 @@ const TOOLS = [
204
246
  },
205
247
  handler: (a) => room("/done", "POST", { task_id: a.task_id, summary: a.summary }),
206
248
  },
249
+ ...contextTools(room),
207
250
  ];
208
251
  /**
209
252
  * What an MCP host shows the model about the Plug as a whole: the verbs by
@@ -212,10 +255,13 @@ const TOOLS = [
212
255
  */
213
256
  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
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.
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).
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).
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.
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).
217
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.
218
263
  - QA rounds: huddle_report_bug files one reproducible bug in the merged build; it becomes a fix task for the file's author.
264
+ - The shared screen (what the user pasted, dropped or uploaded — logs, docs, screenshots): huddle_context lists it, huddle_context_read opens one item in chunks (images/PDFs as a file path), huddle_context_search finds text in it, huddle_context_note pins a comment on an item for everyone.
219
265
  - Finish: huddle_done with a short summary. Huddle tests and merges it.`;
220
266
  /** The Plug's verb names, in the order listed to agents. */
221
267
  export const PLUG_VERBS = TOOLS.map((t) => t.name);
@@ -223,7 +269,7 @@ export async function runPlug() {
223
269
  // Attach mode (`huddle connect`): a session you started yourself, no scheduler.
224
270
  if (process.env.HUDDLE_LIVE)
225
271
  return (await import("../attach/plug.js")).runLivePlug();
226
- const server = new Server({ name: "huddle", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
272
+ const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
227
273
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
228
274
  tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
229
275
  }));
@@ -232,7 +278,7 @@ export async function runPlug() {
232
278
  if (!tool)
233
279
  throw new Error(`unknown tool: ${req.params.name}`);
234
280
  const result = await tool.handler((req.params.arguments ?? {}));
235
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
281
+ return { content: toContent(result) };
236
282
  });
237
283
  const transport = new StdioServerTransport();
238
284
  await server.connect(transport);
@@ -1,11 +1,12 @@
1
1
  // One-shot, read-only questions to an agent: answering "where are we with
2
2
  // this project?" and planning a goal. Runs in the repo itself (no worktree),
3
3
  // with each vendor's read-only / plan mode, so nothing can be edited.
4
- import { spawn } from "node:child_process";
5
4
  import { randomUUID } from "node:crypto";
6
5
  import fs from "node:fs";
7
6
  import os from "node:os";
8
7
  import path from "node:path";
8
+ import { stopChild } from "./launcher.js";
9
+ import { onFinished, spawnTool } from "../platform.js";
9
10
  /** Preference order for who answers questions and plans. */
10
11
  const ASK_ORDER = ["claude", "codex", "antigravity", "cursor", "gemini"];
11
12
  export function pickAsker(ready) {
@@ -16,21 +17,20 @@ export function askAgent(adapter, prompt, cwd, opts = {}) {
16
17
  const outFile = path.join(os.tmpdir(), `huddle-ask-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
17
18
  const spec = adapter.ask({ prompt, cwd, outFile, model: opts.model, noTools: opts.noTools, quick: opts.quick });
18
19
  return new Promise((resolve) => {
19
- const child = spawn(spec.cmd[0], spec.cmd.slice(1), { cwd: spec.cwd, stdio: ["ignore", "pipe", "pipe"] });
20
+ const child = spawnTool(spec.cmd, { cwd: spec.cwd, stdio: ["ignore", "pipe", "pipe"] }, { prompt, stdinPrompt: adapter.stdinPrompt });
20
21
  let out = "";
21
22
  let err = "";
22
23
  child.stdout.on("data", (d) => (out += d));
23
24
  child.stderr.on("data", (d) => (err += d));
24
25
  // 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
- };
26
+ const stop = () => stopChild(child);
31
27
  const timer = setTimeout(stop, opts.timeoutMs ?? 5 * 60_000);
32
28
  opts.signal?.addEventListener("abort", stop);
29
+ let over = false;
33
30
  const finish = (code) => {
31
+ if (over)
32
+ return;
33
+ over = true;
34
34
  clearTimeout(timer);
35
35
  let text = out;
36
36
  if (spec.usesOutFile) {
@@ -44,8 +44,11 @@ export function askAgent(adapter, prompt, cwd, opts = {}) {
44
44
  }
45
45
  resolve({ ok: code === 0 && text.trim().length > 0, text: text.trim() || err.trim() });
46
46
  };
47
- child.on("exit", finish);
48
- child.on("error", () => finish(-1));
47
+ // "exit" can arrive before the last of stdout has been read — when our event
48
+ // loop was busy (a merge's test run blocks it) the answer was lost and the
49
+ // review counted as timed out (found on CI). onFinished waits for the pipes
50
+ // to drain, but not forever: a CLI's leftover child can hold them open.
51
+ onFinished(child, finish);
49
52
  });
50
53
  }
51
54
  //# sourceMappingURL=ask.js.map
@@ -15,6 +15,8 @@ import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { ADAPTERS } from "../adapters/index.js";
17
17
  import { askAgent } from "./ask.js";
18
+ import { lessonsBrief } from "../room/lessons.js";
19
+ import { screenForPlanner } from "../room/context.js";
18
20
  const OLLAMA = process.env.OLLAMA_HOST?.replace(/\/$/, "") || "http://127.0.0.1:11434";
19
21
  /** Models a local Ollama has pulled, or [] if Ollama isn't running. */
20
22
  export async function ollamaModels() {
@@ -100,8 +102,8 @@ export async function brainOptions(readyAgents) {
100
102
  out.push({ spec: "cursor:grok-4.7-low-fast", note: "cheap + fast, via your Cursor plan" });
101
103
  if (readyAgents.includes("claude")) {
102
104
  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
+ out.push({ spec: "claude:sonnet", note: "fast — plans in ~15s, a fraction of Opus's tokens" });
106
+ out.push({ spec: "claude:opus", note: "default — Opus, strongest plans, most tokens" });
105
107
  }
106
108
  if (readyAgents.includes("codex"))
107
109
  out.push({ spec: "codex", note: "your Codex default model" });
@@ -112,7 +114,7 @@ export async function brainOptions(readyAgents) {
112
114
  * manifest. ~a few thousand tokens; lets even a local model plan sensibly.
113
115
  */
114
116
  export function repoContext(cwd, maxFiles = 250) {
115
- const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean);
117
+ const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split(/\r?\n/).filter(Boolean);
116
118
  const head = (f, lines) => {
117
119
  try {
118
120
  return fs.readFileSync(path.join(cwd, f), "utf8").split("\n").slice(0, lines).join("\n");
@@ -121,7 +123,9 @@ export function repoContext(cwd, maxFiles = 250) {
121
123
  return null;
122
124
  }
123
125
  };
124
- 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)"}`];
125
129
  for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
126
130
  const h = head(f, 40);
127
131
  if (h)
@@ -159,6 +163,14 @@ export function repoContext(cwd, maxFiles = 250) {
159
163
  parts.push(`${f}:\n${body.trimEnd()}`);
160
164
  }
161
165
  }
166
+ // The planner and the council plan from this snapshot: what earlier runs taught goes with it.
167
+ const lessons = lessonsBrief({ repo: cwd }).trim();
168
+ if (lessons)
169
+ parts.push(lessons);
170
+ // What the user pasted / dropped: the index (+ small items), not N full copies.
171
+ const screen = screenForPlanner(cwd);
172
+ if (screen)
173
+ parts.push(screen);
162
174
  return parts.join("\n\n");
163
175
  }
164
176
  //# sourceMappingURL=brain.js.map