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.
- package/CHANGELOG.md +66 -0
- package/QUICKSTART.md +63 -2
- package/README.md +15 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +775 -0
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +433 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +46 -4
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +42 -3
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +11 -5
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +32 -5
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +105 -19
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +82 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +511 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/undo.js +131 -0
- package/dist/ui/watch.js +4 -0
- package/dist/update.js +84 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|