huddle-cli 0.1.1 → 0.1.5

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 (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/QUICKSTART.md +63 -2
  3. package/README.md +15 -0
  4. package/dist/adapters/codex.js +3 -0
  5. package/dist/app/live.js +142 -0
  6. package/dist/app/page.js +775 -0
  7. package/dist/app/qr.js +47 -0
  8. package/dist/app/server.js +433 -0
  9. package/dist/args.js +18 -0
  10. package/dist/attach/connect.js +3 -3
  11. package/dist/attach/plug.js +8 -1
  12. package/dist/cli.js +46 -4
  13. package/dist/connectors/cli.js +121 -0
  14. package/dist/connectors/github.js +162 -0
  15. package/dist/connectors/hub.js +234 -0
  16. package/dist/connectors/types.js +3 -0
  17. package/dist/mcp/context-tools.js +2 -0
  18. package/dist/mcp/server.js +42 -3
  19. package/dist/orchestrator/brain.js +3 -1
  20. package/dist/orchestrator/budget.js +283 -0
  21. package/dist/orchestrator/busy.js +106 -0
  22. package/dist/orchestrator/council.js +11 -5
  23. package/dist/orchestrator/launcher.js +3 -1
  24. package/dist/orchestrator/learn.js +3 -7
  25. package/dist/orchestrator/planner.js +32 -5
  26. package/dist/orchestrator/qa.js +9 -1
  27. package/dist/orchestrator/scheduler.js +105 -19
  28. package/dist/platform.js +104 -0
  29. package/dist/room/mail.js +19 -6
  30. package/dist/room/server.js +82 -2
  31. package/dist/room/user.js +54 -0
  32. package/dist/ui/app.js +511 -69
  33. package/dist/ui/brief.js +494 -0
  34. package/dist/ui/catalog.js +1 -1
  35. package/dist/ui/dashboard.js +92 -21
  36. package/dist/ui/export.js +1194 -0
  37. package/dist/ui/narrator.js +56 -0
  38. package/dist/ui/story.js +118 -93
  39. package/dist/ui/talk.js +176 -0
  40. package/dist/ui/term.js +31 -0
  41. package/dist/ui/transcript.js +434 -0
  42. package/dist/ui/undo.js +131 -0
  43. package/dist/ui/watch.js +4 -0
  44. package/dist/update.js +84 -0
  45. package/package.json +3 -1
package/dist/cli.js CHANGED
@@ -8,17 +8,22 @@
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
14
15
  // huddle agents your agents: ready / not signed in / not installed, used or skipped
15
16
  // huddle agents use|skip <ids> choose who works with you (remembered)
16
17
  // huddle --version print the version
17
- // huddle agents --all every connector (built in + agents.json) and how to get it
18
+ // huddle update install the newest huddle-cli from npm (--update too)
19
+ // huddle agents --all every agent (built in + agents.json) and how to get it
18
20
  // huddle agents install <id> install one connector's CLI, after asking
19
21
  // huddle board print the board + shared memory
20
22
  // huddle view [repo] the live view (ctrl-t) from another terminal, read-only
21
23
  // huddle story [--html f] replay the latest run: who built on whose idea, who said what to whom, merges
24
+ // huddle brief [--run n] the hand-off brief a solo agent reads after a run (off|on: the CLAUDE.md/AGENTS.md pointer)
25
+ // huddle runs every run in this repo: goal, agents, result, cost
26
+ // huddle export [run] a run as summary / chat / full transcript (md, html, json)
22
27
  // huddle lessons what Huddle learned across runs (--forget <id>, --clear)
23
28
  // huddle init set up .huddle/ in this repo (automatic on first run)
24
29
  // huddle up | down attach mode: start/stop this repo's live room
@@ -33,6 +38,7 @@
33
38
  // huddle mail --hook (internal) Claude's post-tool hook: teammates' new messages
34
39
  import { packageInfo } from "./preflight.js"; // first: refuses old Node before anything else loads
35
40
  import { C, useAsciiOutput } from "./ui/term.js";
41
+ import { asGoal } from "./args.js";
36
42
  import { SqliteUnavailableError } from "./room/sqlite.js";
37
43
  // Everything heavier loads per command: `huddle mail --hook` runs after every
38
44
  // one of Claude's tool calls and has to start in well under 300ms.
@@ -49,14 +55,26 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
49
55
  ${B("--approvals=safe|auto|yolo")} what agents may do unasked ${F("(default auto)")}
50
56
  ${B("--council")} ${F("/")} ${B("--no-council")} design council first ${F("(default: big goals)")}
51
57
  ${B("--qa")} ${F("/")} ${B("--no-qa")} agents re-check the build ${F("(default: big goals)")}
58
+ ${B("--budget=5M")} tokens it may use ${F("(stops new work at 90%)")}
52
59
  ${B("--with <file>")} put a file, folder or glob on the shared screen
53
60
  ${F("(repeatable; or pipe: cat log | huddle join …)")}
61
+ ${B("huddle fix issue 12")} GitHub issue 12 is the goal ${F("(also: fix #12)")}
62
+ ${B("huddle app")} the dashboard: live run, history, talk to agents
63
+ ${B("--phone")} on your phone too: scan the QR code it prints
64
+ ${F("(--anywhere: off your Wi-Fi, needs cloudflared)")}
54
65
  ${B("huddle join <address> <CODE>")} join someone's room from an invite
55
66
 
56
67
  ${F("watch and replay")}
57
68
  ${B("huddle view")} ${F("[repo]")} watch a run live, read-only, from a 2nd terminal
58
69
  ${B("huddle story")} ${F("[--html f]")} replay the last run: ideas, contract, merges
59
70
  ${F("(--list, --run n)")}
71
+ ${B("huddle brief")} ${F("[--run n]")} what the last run left for your next solo agent
72
+ ${F("(off / on: its pointer in CLAUDE.md, AGENTS.md)")}
73
+ ${B("huddle runs")} every run here: goal, agents, result, cost
74
+ ${B("huddle export")} ${F("[run]")} a run as a chat you can read or share
75
+ ${F("(--level summary|chat|full, --out <path>,")}
76
+ ${F(" --format md|html|json, --no-redact)")}
77
+ ${B("huddle undo")} ${F("[--run n]")} take back what a run merged ${F("(one revert commit)")}
60
78
  ${B("huddle board")} print the board
61
79
  ${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
62
80
 
@@ -65,10 +83,12 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
65
83
  ${B("huddle doctor")} check this machine: node, git, your agents
66
84
  ${B("huddle agents")} your agents: ready or not, used or skipped
67
85
  ${B("huddle agents use|skip <ids>")} choose who works with you ${F("(e.g. skip cursor)")}
68
- ${B("huddle agents --all")} every connector Huddle knows, installed or not
86
+ ${B("huddle agents --all")} every agent Huddle knows, installed or not
69
87
  ${B("huddle agents install <id>")} install one ${F("(asks first)")}
88
+ ${B("huddle connectors")} GitHub, Jira, Linear… for every agent
70
89
  ${B("huddle relay")} ${F("[--port 8080]")} run a relay so rooms work across networks
71
90
  ${B("huddle --version")} print the version
91
+ ${B("huddle update")} get the newest Huddle from npm
72
92
 
73
93
  ${F("run claude, codex and cursor yourself — in one repo, safely")}
74
94
  ${B("huddle connect")} wire your agents into this repo's live room
@@ -81,7 +101,8 @@ const USAGE = `${C.violet("huddle")} — your agents, in the same room
81
101
  ${B("huddle up")} ${F("/")} ${B("down")} ${F("/")} ${B("disconnect")} start · stop the live room · unwire
82
102
  `;
83
103
  async function main() {
84
- const [cmd, ...rest] = process.argv.slice(2);
104
+ const argv = process.argv.slice(2);
105
+ const [cmd, ...rest] = asGoal(argv) ?? argv;
85
106
  // Legacy Windows consoles get ASCII glyphs. Not for the machine-read channels: the Plug's JSON-RPC and Claude's hooks.
86
107
  if (!["plug", "guard", "mail"].includes(cmd ?? ""))
87
108
  useAsciiOutput();
@@ -167,8 +188,12 @@ async function main() {
167
188
  // --council: every agent brainstorms the design first; --no-council: the brain plans alone.
168
189
  const council = rest.includes("--no-council") ? false : rest.includes("--council") ? true : undefined;
169
190
  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 });
191
+ const budget = rest.find((a) => a.startsWith("--budget="))?.split("=")[1];
192
+ return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa, budget });
171
193
  }
194
+ case "app":
195
+ case "dashboard":
196
+ return (await import("./app/server.js")).runApp(rest);
172
197
  case "board":
173
198
  return (await app()).printBoard();
174
199
  case "view":
@@ -176,6 +201,12 @@ async function main() {
176
201
  return (await import("./ui/watch.js")).runView(rest);
177
202
  case "story":
178
203
  return (await import("./ui/story.js")).runStory(rest);
204
+ case "brief":
205
+ return (await import("./ui/brief.js")).runBrief(rest);
206
+ case "runs":
207
+ return (await import("./ui/export.js")).runRuns(rest);
208
+ case "export":
209
+ return (await import("./ui/export.js")).runExport(rest);
179
210
  case "lessons":
180
211
  return (await import("./ui/lessons.js")).runLessons(rest);
181
212
  case "up":
@@ -217,6 +248,17 @@ async function main() {
217
248
  console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
218
249
  return;
219
250
  }
251
+ case "undo":
252
+ return void (process.exitCode = await (await import("./ui/undo.js")).runUndo(rest));
253
+ case "connectors":
254
+ case "connector": {
255
+ const { repoRoot } = await app();
256
+ return void (process.exitCode = await (await import("./connectors/cli.js")).runConnectors(rest, repoRoot()));
257
+ }
258
+ case "update":
259
+ case "upgrade":
260
+ case "--update":
261
+ return void process.exit((await import("./update.js")).runUpdate());
220
262
  case "--version":
221
263
  case "-v":
222
264
  case "-V":
@@ -0,0 +1,121 @@
1
+ // `huddle connectors`: which outside tools your agents get, and adding more.
2
+ //
3
+ // huddle connectors what's on, with each one's tool count
4
+ // huddle connectors add atlassian Jira + Confluence (signs in in your browser, once)
5
+ // huddle connectors add <name> <https-url> any remote MCP server
6
+ // huddle connectors add <name> -- <command…> any local MCP server
7
+ // huddle connectors add … --write let agents use the tools that post or change things
8
+ // huddle connectors remove <name>
9
+ // huddle connectors github on|off|write|read
10
+ import { C } from "../ui/term.js";
11
+ import { configFile, PRESETS, presetName, readConfig, remoteCommand, startHub, statusLine, writeConfig } from "./hub.js";
12
+ const USAGE = `${C.bold("huddle connectors")} what your agents can reach, and how many tools each gives
13
+ ${C.bold("huddle connectors add")} ${C.faint("<service>")} a service your team uses:
14
+ ${Object.entries(PRESETS)
15
+ .map(([n, p]) => ` ${n.padEnd(34)}${C.faint(p.what)}`)
16
+ .join("\n")}
17
+ ${C.bold("huddle connectors add")} ${C.faint("<name> <url>")} any remote MCP server (signs in through mcp-remote)
18
+ ${C.bold("huddle connectors add")} ${C.faint("<name> -- <cmd…>")} any local MCP server
19
+ ${C.faint("add --write to let agents use the tools that post or change things")}
20
+ ${C.bold("huddle connectors remove")} ${C.faint("<name>")}
21
+ ${C.bold("huddle connectors github")} ${C.faint("on|off|write|read")}
22
+ ${C.faint("GitHub (through the gh CLI) is on by default, read-only")}`;
23
+ async function check(root, cfg, only) {
24
+ const one = only
25
+ ? only === "github"
26
+ ? { github: cfg.github }
27
+ : { github: { off: true }, connectors: { [only]: cfg.connectors[only] } }
28
+ : cfg;
29
+ if (only && only !== "github")
30
+ console.log(C.faint(` connecting to ${only}… (the first time, it may open your browser to sign in)`));
31
+ const hub = startHub({ root, config: one, connectMs: 180_000 });
32
+ await hub.ready;
33
+ let ok = true;
34
+ for (const s of hub.status()) {
35
+ ok &&= s.ok;
36
+ console.log(` ${s.ok ? C.green("✓") : C.red("✗")} ${statusLine(s)}`);
37
+ }
38
+ if (!hub.status().length)
39
+ console.log(C.faint(" (none on)"));
40
+ hub.close();
41
+ return ok;
42
+ }
43
+ export async function runConnectors(args, root) {
44
+ const [sub, ...rest] = args;
45
+ const cfg = readConfig();
46
+ cfg.connectors ??= {};
47
+ // --write is Huddle's flag only before `--`; after it, everything is the server's command.
48
+ const cut = rest.indexOf("--");
49
+ const own = cut < 0 ? rest : rest.slice(0, cut);
50
+ const write = own.includes("--write");
51
+ const words = [...own.filter((a) => a !== "--write"), ...(cut < 0 ? [] : rest.slice(cut))];
52
+ if (!sub || sub === "list" || sub === "status") {
53
+ console.log(C.bold("Connectors") + C.faint(` · every agent in a run gets these tools · ${configFile()}`));
54
+ await check(root, cfg);
55
+ console.log();
56
+ console.log(USAGE);
57
+ return 0;
58
+ }
59
+ if (sub === "help" || sub === "--help")
60
+ return void console.log(USAGE), 0;
61
+ if (sub === "github") {
62
+ const how = words[0];
63
+ if (!how)
64
+ return (await check(root, cfg, "github")) ? 0 : 1;
65
+ if (!["on", "off", "write", "read"].includes(how)) {
66
+ console.log(C.red("✗ ") + "huddle connectors github on|off|write|read");
67
+ return 1;
68
+ }
69
+ cfg.github = how === "off" ? { ...cfg.github, off: true } : { off: false, write: how === "write" ? true : how === "read" ? false : !!cfg.github?.write };
70
+ writeConfig(cfg);
71
+ console.log(`${C.green("✓")} github ${how === "off" ? "off" : cfg.github.write ? "on — agents may comment and open issues, as you" : "on, read-only"}`);
72
+ return how === "off" ? 0 : (await check(root, cfg, "github")) ? 0 : 1;
73
+ }
74
+ if (sub === "add") {
75
+ const dash = words.indexOf("--");
76
+ const name = presetName(words[0] ?? "");
77
+ if (!/^[a-z][a-z0-9-]{0,30}$/.test(name) || name === "github") {
78
+ console.log(C.red("✗ ") + (name === "github" ? "GitHub is built in: huddle connectors github on" : "give it a short name (letters, digits, -): huddle connectors add jira"));
79
+ return 1;
80
+ }
81
+ let command;
82
+ if (dash >= 0)
83
+ command = words.slice(dash + 1);
84
+ else if (words[1] && /^https?:\/\//.test(words[1]))
85
+ command = remoteCommand(words[1]);
86
+ else if (PRESETS[name])
87
+ command = remoteCommand(PRESETS[name].url);
88
+ else {
89
+ console.log(C.red(`✗ no known service called "${name}".`) + ` Give its MCP URL (huddle connectors add ${name} https://…) or a command (huddle connectors add ${name} -- npx -y some-mcp-server).`);
90
+ return 1;
91
+ }
92
+ if (!command.length) {
93
+ console.log(C.red("✗ ") + "nothing after --");
94
+ return 1;
95
+ }
96
+ cfg.connectors[name] = { command, ...(write ? { write: true } : {}) };
97
+ writeConfig(cfg);
98
+ console.log(`${C.green("✓")} added ${C.bold(name)}${PRESETS[name] ? C.faint(` (${PRESETS[name].what})`) : ""}: ${C.faint(command.join(" "))}`);
99
+ const ok = await check(root, cfg, name);
100
+ if (!ok)
101
+ console.log(C.faint(` it stays saved; fix it and check with: huddle connectors · or: huddle connectors remove ${name}`));
102
+ else if (!write)
103
+ console.log(C.faint(` read-only: agents see the tools that read. To let them post or change things: huddle connectors add --write ${words.join(" ")}`));
104
+ return ok ? 0 : 1;
105
+ }
106
+ if (sub === "remove" || sub === "rm") {
107
+ const name = presetName(words[0] ?? "");
108
+ if (!cfg.connectors[name]) {
109
+ console.log(C.red(`✗ no connector "${name}".`) + ` On: ${Object.keys(cfg.connectors).join(", ") || "(none added)"}`);
110
+ return 1;
111
+ }
112
+ delete cfg.connectors[name];
113
+ writeConfig(cfg);
114
+ console.log(`${C.green("✓")} removed ${name}`);
115
+ return 0;
116
+ }
117
+ console.log(C.red(`✗ unknown: connectors ${sub}`));
118
+ console.log(USAGE);
119
+ return 1;
120
+ }
121
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,162 @@
1
+ // The GitHub connector: issues, pull requests and CI for the repo you're in,
2
+ // through the `gh` CLI you already signed in to. Huddle holds no GitHub token;
3
+ // `gh` does, the same way each agent CLI holds its own login.
4
+ //
5
+ // Read tools are on whenever `gh` is signed in and the repo has a GitHub
6
+ // remote. The two that write (a comment, a new issue) post as you, so they
7
+ // show only after `huddle connectors github write`.
8
+ import { spawnTool } from "../platform.js";
9
+ const MAX_OUT = 60_000;
10
+ /** Runs a command to completion, without blocking the Room's event loop. */
11
+ export function runAsync(cmd, opts) {
12
+ return new Promise((resolve) => {
13
+ let stdout = "";
14
+ let stderr = "";
15
+ let done = false;
16
+ const finish = (r) => {
17
+ if (done)
18
+ return;
19
+ done = true;
20
+ clearTimeout(timer);
21
+ resolve(r);
22
+ };
23
+ let child;
24
+ try {
25
+ child = spawnTool(cmd, { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, GH_PROMPT_DISABLED: "1", NO_COLOR: "1" } });
26
+ }
27
+ catch (e) {
28
+ return resolve({ code: 127, stdout: "", stderr: String(e?.message ?? e) });
29
+ }
30
+ const timer = setTimeout(() => {
31
+ child.kill();
32
+ finish({ code: 124, stdout, stderr: stderr + `\n(timed out after ${Math.round((opts.timeoutMs ?? 30_000) / 1000)}s)` });
33
+ }, opts.timeoutMs ?? 30_000);
34
+ child.stdout.on("data", (d) => (stdout += d));
35
+ child.stderr.on("data", (d) => (stderr += d));
36
+ child.on("error", (e) => finish({ code: 127, stdout, stderr: String(e.message) }));
37
+ child.on("close", (code) => finish({ code: code ?? 1, stdout, stderr }));
38
+ child.stdin.on("error", () => { }); // it may exit before reading it all
39
+ child.stdin.end(opts.input ?? "");
40
+ });
41
+ }
42
+ export const clipOut = (s, n = MAX_OUT) => (s.length > n ? s.slice(0, n) + `\n… (cut at ${n} characters)` : s);
43
+ const num = (v) => {
44
+ const n = Number(String(v ?? "").replace(/^#/, ""));
45
+ if (!Number.isInteger(n) || n <= 0)
46
+ throw new Error(`not an issue or PR number: ${JSON.stringify(v)}`);
47
+ return String(n);
48
+ };
49
+ const limit = (v, def = 20) => String(Math.min(100, Math.max(1, Number(v) || def)));
50
+ const state = (v) => (["open", "closed", "merged", "all"].includes(String(v)) ? String(v) : "open");
51
+ /** One tool's result: gh's JSON (or text) when it worked, its error when not. */
52
+ async function out(r) {
53
+ const x = await r;
54
+ if (x.code !== 0)
55
+ return { error: (x.stderr || x.stdout).trim().slice(0, 2000) || `gh exited ${x.code}` };
56
+ const t = x.stdout.trim();
57
+ try {
58
+ return JSON.parse(t);
59
+ }
60
+ catch {
61
+ return clipOut(t);
62
+ }
63
+ }
64
+ export function githubTools(gh, opts) {
65
+ const tools = [
66
+ {
67
+ name: "github_issues",
68
+ description: "List this repo's GitHub issues (number, title, state, labels, author). Filter with state, label or a search query.",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ state: { type: "string", enum: ["open", "closed", "all"] },
73
+ label: { type: "string" },
74
+ search: { type: "string", description: "GitHub search syntax, e.g. 'login in:title'" },
75
+ limit: { type: "number" },
76
+ },
77
+ },
78
+ call: (a) => out(gh([
79
+ "issue", "list", "--state", state(a.state), "--limit", limit(a.limit),
80
+ ...(a.label ? ["--label", String(a.label)] : []),
81
+ ...(a.search ? ["--search", String(a.search)] : []),
82
+ "--json", "number,title,state,labels,author,updatedAt",
83
+ ])),
84
+ },
85
+ {
86
+ name: "github_issue",
87
+ description: "Read one GitHub issue in full: its body, labels and every comment.",
88
+ inputSchema: { type: "object", properties: { number: { type: "number" } }, required: ["number"] },
89
+ call: (a) => out(gh(["issue", "view", num(a.number), "--json", "number,title,body,state,labels,author,comments,url"])),
90
+ },
91
+ {
92
+ name: "github_prs",
93
+ description: "List this repo's pull requests (number, title, branch, author, state).",
94
+ inputSchema: { type: "object", properties: { state: { type: "string", enum: ["open", "closed", "merged", "all"] }, limit: { type: "number" } } },
95
+ call: (a) => out(gh(["pr", "list", "--state", state(a.state), "--limit", limit(a.limit), "--json", "number,title,state,headRefName,author,url"])),
96
+ },
97
+ {
98
+ name: "github_pr",
99
+ description: "Read one pull request: description, changed files, comments and reviews. Pass diff: true for the diff too (cut at 60k characters).",
100
+ inputSchema: { type: "object", properties: { number: { type: "number" }, diff: { type: "boolean" } }, required: ["number"] },
101
+ call: async (a) => {
102
+ const n = num(a.number);
103
+ const view = await out(gh(["pr", "view", n, "--json", "number,title,body,state,headRefName,baseRefName,files,comments,reviews,url"]));
104
+ if (!a.diff)
105
+ return view;
106
+ const d = await gh(["pr", "diff", n]);
107
+ return { pr: view, diff: d.code === 0 ? clipOut(d.stdout) : `(no diff: ${d.stderr.trim().slice(0, 300)})` };
108
+ },
109
+ },
110
+ {
111
+ name: "github_ci",
112
+ description: "The latest GitHub Actions runs (workflow, branch, status, conclusion), so you can see whether CI is green or what broke.",
113
+ inputSchema: { type: "object", properties: { branch: { type: "string" }, limit: { type: "number" } } },
114
+ call: (a) => out(gh(["run", "list", "--limit", limit(a.limit, 8), ...(a.branch ? ["--branch", String(a.branch)] : []), "--json", "databaseId,workflowName,headBranch,status,conclusion,createdAt,url"])),
115
+ },
116
+ ];
117
+ const writes = !opts.write ? [] : [
118
+ {
119
+ name: "github_comment",
120
+ description: "Post a comment on a GitHub issue or pull request, as the person running Huddle. Use it for what they'd want posted (a fix summary, a question), not chatter.",
121
+ inputSchema: { type: "object", properties: { number: { type: "number" }, body: { type: "string" } }, required: ["number", "body"] },
122
+ write: true,
123
+ call: (a, agent) => out(gh(["issue", "comment", num(a.number), "--body-file", "-"], `${String(a.body ?? "").trim()}\n\n${opts.signature(agent)}`)),
124
+ },
125
+ {
126
+ name: "github_new_issue",
127
+ description: "Open a new GitHub issue, as the person running Huddle: for a real bug or follow-up found while working that this run won't fix.",
128
+ inputSchema: { type: "object", properties: { title: { type: "string" }, body: { type: "string" } }, required: ["title", "body"] },
129
+ write: true,
130
+ call: (a, agent) => out(gh(["issue", "create", "--title", String(a.title ?? "").slice(0, 250), "--body-file", "-"], `${String(a.body ?? "").trim()}\n\n${opts.signature(agent)}`)),
131
+ },
132
+ ];
133
+ // A bad argument rejects (never throws mid-call), like every other tool.
134
+ return [...tools, ...writes].map((t) => ({ ...t, call: async (a, agent) => t.call(a, agent) }));
135
+ }
136
+ /** "#12", "issue 12", "fix #12", "github issue #12": the issue number, else null. */
137
+ export function issueRef(goal) {
138
+ const m = /^\s*(?:(?:fix|close|solve|do|work on|build)\s+)?(?:(?:github|gh)\s+)?(?:issue\s*)?#(\d+)\s*$/i.exec(goal) ?? /^\s*(?:(?:fix|close|solve)\s+)?(?:github\s+)?issue\s+(\d+)\s*$/i.exec(goal);
139
+ return m ? Number(m[1]) : null;
140
+ }
141
+ /** The goal a run gets for a GitHub issue: its title, body and comments, verbatim. */
142
+ export function issueGoal(issue) {
143
+ const comments = (issue.comments ?? [])
144
+ .slice(-8)
145
+ .map((c) => `- ${c.author?.login ?? "someone"}: ${String(c.body ?? "").trim().slice(0, 800)}`)
146
+ .join("\n");
147
+ return [
148
+ `Resolve GitHub issue #${issue.number}: ${issue.title}`,
149
+ issue.url ? `(${issue.url})` : "",
150
+ "",
151
+ String(issue.body ?? "").trim().slice(0, 6000) || "(the issue has no description)",
152
+ comments ? `\nComments on the issue:\n${comments}` : "",
153
+ ]
154
+ .filter((l, i) => l || i === 2)
155
+ .join("\n")
156
+ .trim();
157
+ }
158
+ /** Issues a longer goal mentions ("fix #12 and add a test"): up to 3 numbers. */
159
+ export function issueMentions(goal) {
160
+ return [...new Set([...goal.matchAll(/(?:^|[\s(])#(\d{1,6})\b/g)].map((m) => Number(m[1])))].slice(0, 3);
161
+ }
162
+ //# sourceMappingURL=github.js.map
@@ -0,0 +1,234 @@
1
+ // Connectors: the outside tools a team works with — GitHub, Jira, Confluence,
2
+ // Linear, Notion, Miro… — given once to every agent in a run, whichever CLI it is.
3
+ //
4
+ // The Room runs them, not each agent: one process per connector per run, one
5
+ // sign-in, and every call goes through the Plug (huddle plug), so Claude,
6
+ // Codex, Cursor and the rest see the same tools without 14 vendor configs.
7
+ //
8
+ // ~/.huddle/connectors.json yours, on this machine (HUDDLE_HOME moves it)
9
+ // {
10
+ // "github": { "off": false, "write": false },
11
+ // "connectors": {
12
+ // "atlassian": { "command": ["npx", "-y", "mcp-remote", "https://mcp.atlassian.com/v1/mcp"] },
13
+ // "mine": { "command": ["node", "/path/server.js"], "write": true, "env": { "X": "1" } }
14
+ // }
15
+ // }
16
+ //
17
+ // Any MCP server over stdio is a connector. Remote ones (an https URL) run
18
+ // through mcp-remote, which does the service's OAuth itself and keeps the
19
+ // token: Huddle holds none. Until you allow writes (`write: true`), agents
20
+ // see only the tools that read.
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
25
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
26
+ import { githubTools, runAsync } from "./github.js";
27
+ /** Known services: `huddle connectors add atlassian`. Each is its vendor's own remote MCP server. */
28
+ export const PRESETS = {
29
+ atlassian: { url: "https://mcp.atlassian.com/v1/mcp", what: "Jira and Confluence" },
30
+ linear: { url: "https://mcp.linear.app/mcp", what: "Linear issues and projects" },
31
+ notion: { url: "https://mcp.notion.com/mcp", what: "Notion pages and databases" },
32
+ miro: { url: "https://mcp.miro.com/", what: "Miro boards" },
33
+ sentry: { url: "https://mcp.sentry.dev/mcp", what: "Sentry errors" },
34
+ };
35
+ const ALIASES = { jira: "atlassian", confluence: "atlassian" };
36
+ export const presetName = (n) => ALIASES[n.toLowerCase()] ?? n.toLowerCase();
37
+ export const remoteCommand = (url) => ["npx", "-y", "mcp-remote", url];
38
+ const home = () => process.env.HUDDLE_HOME ?? path.join(os.homedir(), ".huddle");
39
+ export const configFile = () => path.join(home(), "connectors.json");
40
+ export function readConfig() {
41
+ try {
42
+ const c = JSON.parse(fs.readFileSync(configFile(), "utf8"));
43
+ return c && typeof c === "object" ? c : {};
44
+ }
45
+ catch {
46
+ return {};
47
+ }
48
+ }
49
+ export function writeConfig(c) {
50
+ fs.mkdirSync(path.dirname(configFile()), { recursive: true });
51
+ fs.writeFileSync(configFile(), JSON.stringify(c, null, 2) + "\n");
52
+ }
53
+ /** A connector's tool names start with its name: [a-z0-9_-], at most 64 characters (what every CLI accepts). */
54
+ export const toolName = (connector, tool) => `${connector}_${tool}`.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64);
55
+ const READ_VERB = /^(get|list|search|read|fetch|view|find|query|lookup|describe|show|retrieve|count|whoami|atlassianUserInfo|getAccessible)/i;
56
+ /**
57
+ * Whether a tool only reads. The server's own annotations decide when it
58
+ * gives them; otherwise its name does (getJiraIssue, search_issues, list_…).
59
+ */
60
+ export function readsOnly(t) {
61
+ if (t.annotations?.readOnlyHint === true)
62
+ return true;
63
+ if (t.annotations?.readOnlyHint === false || t.annotations?.destructiveHint === true)
64
+ return false;
65
+ return READ_VERB.test(t.name.replace(/^[a-z]+_/, "")) || READ_VERB.test(t.name);
66
+ }
67
+ const MAX_RESULT = 60_000;
68
+ /** An MCP tool result as plain text an agent can read (images and the like become a note). */
69
+ function flatten(r) {
70
+ const parts = Array.isArray(r?.content) ? r.content : [];
71
+ const text = parts
72
+ .map((p) => (p?.type === "text" ? String(p.text ?? "") : p?.type === "resource" ? String(p.resource?.text ?? p.resource?.uri ?? "") : `(${p?.type ?? "item"} not shown)`))
73
+ .join("\n");
74
+ const out = text.length > MAX_RESULT ? text.slice(0, MAX_RESULT) + `\n… (cut at ${MAX_RESULT} characters)` : text;
75
+ return r?.isError ? { error: out || "the connector reported an error" } : out || r?.structuredContent || "(empty result)";
76
+ }
77
+ function withTimeout(p, ms, what) {
78
+ return new Promise((resolve, reject) => {
79
+ const t = setTimeout(() => reject(new Error(`${what}: no answer in ${Math.round(ms / 1000)}s`)), ms);
80
+ p.then((v) => (clearTimeout(t), resolve(v)), (e) => (clearTimeout(t), reject(e)));
81
+ });
82
+ }
83
+ export async function githubReady(root) {
84
+ const r = await runAsync(["gh", "repo", "view", "--json", "nameWithOwner"], { cwd: root, timeoutMs: 15_000 });
85
+ if (r.code === 127)
86
+ return { ok: false, note: "the GitHub CLI isn't installed (https://cli.github.com)" };
87
+ if (r.code !== 0) {
88
+ const e = (r.stderr || r.stdout).toLowerCase();
89
+ if (/auth|log ?in|token/.test(e))
90
+ return { ok: false, note: "gh isn't signed in: gh auth login" };
91
+ if (/no git remote|not a git|none of the git remotes|could not determine/.test(e))
92
+ return { ok: false, note: "this repo has no GitHub remote" };
93
+ return { ok: false, note: (r.stderr || r.stdout).trim().split("\n")[0].slice(0, 160) || "gh failed" };
94
+ }
95
+ try {
96
+ return { ok: true, note: JSON.parse(r.stdout).nameWithOwner };
97
+ }
98
+ catch {
99
+ return { ok: true, note: "" };
100
+ }
101
+ }
102
+ /** Starts this machine's connectors for a run in `root`. Never throws: a connector that fails is left out, with a note. */
103
+ export function startHub(opts) {
104
+ const cfg = opts.config ?? readConfig();
105
+ const tools = [];
106
+ const statuses = [];
107
+ const open = [];
108
+ let closed = false;
109
+ const sig = opts.signature ?? ((agent) => `<sub>— ${agent}, via Huddle</sub>`);
110
+ const github = async () => {
111
+ if (cfg.github?.off)
112
+ return;
113
+ const g = await (opts.githubReady ?? (() => githubReady(opts.root)))();
114
+ if (!g.ok)
115
+ return void statuses.push({ name: "github", ok: false, tools: 0, hidden: 0, note: g.note });
116
+ const gh = (args, input) => runAsync(["gh", ...args], { cwd: opts.root, input, timeoutMs: 45_000 });
117
+ const all = githubTools(gh, { write: true, signature: sig });
118
+ const mine = githubTools(gh, { write: !!cfg.github?.write, signature: sig });
119
+ tools.push(...mine);
120
+ statuses.push({ name: "github", ok: true, tools: mine.length, hidden: all.length - mine.length, note: g.note });
121
+ };
122
+ const mcp = async (name, c) => {
123
+ if (c.off)
124
+ return;
125
+ if (!Array.isArray(c.command) || !c.command.length)
126
+ return void statuses.push({ name, ok: false, tools: 0, hidden: 0, note: "no command" });
127
+ const transport = new StdioClientTransport({
128
+ command: c.command[0],
129
+ args: c.command.slice(1),
130
+ env: { ...process.env, ...(c.env ?? {}) },
131
+ cwd: opts.root,
132
+ stderr: "pipe",
133
+ });
134
+ const client = new Client({ name: "huddle", version: "1" });
135
+ try {
136
+ await withTimeout(client.connect(transport), opts.connectMs ?? 60_000, name);
137
+ const listed = await withTimeout(client.listTools(), opts.connectMs ?? 60_000, name);
138
+ if (closed)
139
+ return void client.close().catch(() => { });
140
+ open.push({ name, client, transport });
141
+ let hidden = 0;
142
+ for (const t of listed.tools) {
143
+ const write = !readsOnly(t);
144
+ if (write && !c.write) {
145
+ hidden++;
146
+ continue;
147
+ }
148
+ tools.push({
149
+ name: toolName(name, t.name),
150
+ description: `[${name}] ${t.description ?? t.name}`.slice(0, 1500),
151
+ inputSchema: t.inputSchema ?? { type: "object", properties: {} },
152
+ write,
153
+ call: async (args) => flatten(await client.callTool({ name: t.name, arguments: args }, undefined, { timeout: 120_000 })),
154
+ });
155
+ }
156
+ statuses.push({ name, ok: true, tools: listed.tools.length - hidden, hidden, note: "" });
157
+ }
158
+ catch (e) {
159
+ client.close().catch(() => { });
160
+ statuses.push({ name, ok: false, tools: 0, hidden: 0, note: String(e?.message ?? e).split("\n")[0].slice(0, 200) });
161
+ }
162
+ };
163
+ const ready = Promise.all([github(), ...Object.entries(cfg.connectors ?? {}).map(([n, c]) => mcp(n, c))]).then(() => {
164
+ // Same name twice (two connectors exposing one tool): the first one wins.
165
+ const seen = new Set();
166
+ for (let i = 0; i < tools.length; i++) {
167
+ if (seen.has(tools[i].name))
168
+ tools.splice(i--, 1);
169
+ else
170
+ seen.add(tools[i].name);
171
+ }
172
+ });
173
+ return {
174
+ ready,
175
+ tools: () => tools,
176
+ status: () => statuses,
177
+ async call(name, args, agent) {
178
+ const t = tools.find((x) => x.name === name);
179
+ if (!t)
180
+ return { error: `no connector tool "${name}"` };
181
+ try {
182
+ return await t.call(args ?? {}, agent);
183
+ }
184
+ catch (e) {
185
+ return { error: String(e?.message ?? e) };
186
+ }
187
+ },
188
+ close() {
189
+ closed = true;
190
+ for (const o of open.splice(0))
191
+ o.client.close().catch(() => { });
192
+ },
193
+ };
194
+ }
195
+ /** One line per connector, for the run's opening and `huddle connectors`. */
196
+ export function statusLine(s) {
197
+ if (!s.ok)
198
+ return `${s.name}: off — ${s.note}`;
199
+ return `${s.name}: ${s.tools} tool(s)${s.note ? ` · ${s.note}` : ""}${s.hidden ? ` · ${s.hidden} that write hidden` : ""}`;
200
+ }
201
+ const PHRASES = {
202
+ github_issues: (a) => `listed ${a.state && a.state !== "open" ? a.state + " " : ""}issues${a.search ? ` matching "${a.search}"` : ""}${a.label ? ` labelled ${a.label}` : ""}`,
203
+ github_issue: (a) => `read issue #${a.number}`,
204
+ github_prs: () => "listed pull requests",
205
+ github_pr: (a) => `read PR #${a.number}${a.diff ? " and its diff" : ""}`,
206
+ github_ci: (a) => `checked CI${a.branch ? ` on ${a.branch}` : ""}`,
207
+ github_comment: (a) => `commented on #${a.number}`,
208
+ github_new_issue: (a) => `opened an issue: ${String(a.title ?? "").slice(0, 80)}`,
209
+ };
210
+ /** A connector call in words, for the live run: { head: "github:", body: "read issue #12" }. */
211
+ export function connectorLine(p) {
212
+ const tool = String(p.tool ?? "");
213
+ const [conn, ...rest] = tool.split("_");
214
+ const phrase = PHRASES[tool]?.(p.args ?? {}) ?? rest.join("_").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase();
215
+ return { head: `${conn}:`, body: phrase + (p.ok === false ? " (failed)" : "") };
216
+ }
217
+ /**
218
+ * For the planner and the council, who plan without tools: what the agents
219
+ * will be able to read while they work, so a goal naming a ticket gets tasks
220
+ * that read it first instead of guessing what it says.
221
+ */
222
+ export function toolsNote(hub) {
223
+ const by = new Map();
224
+ for (const t of hub.tools()) {
225
+ const conn = t.name.split("_")[0];
226
+ by.set(conn, [...(by.get(conn) ?? []), t.name]);
227
+ }
228
+ if (!by.size)
229
+ return "";
230
+ return `OUTSIDE TOOLS: every agent can call these while it works (you can't, in this step):
231
+ ${[...by].map(([c, names]) => `- ${c}: ${names.join(", ")}`).join("\n")}
232
+ When the goal names something only these tools can show (a ticket, issue, PR, page, board), don't guess what it says: the spec of every task that needs it says to read it first, naming the tool and the item (e.g. "read WEB-7 with jira_getJiraIssue").`;
233
+ }
234
+ //# sourceMappingURL=hub.js.map
@@ -0,0 +1,3 @@
1
+ // What a connector gives agents: tools, each one callable through the Plug.
2
+ export {};
3
+ //# sourceMappingURL=types.js.map