huddle-cli 0.1.3 → 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.
@@ -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
@@ -54,6 +54,8 @@ export function contextTools(room) {
54
54
  * machines (a teammate's agent) are saved here and replaced by a local path.
55
55
  */
56
56
  export function toContent(result) {
57
+ if (typeof result === "string")
58
+ return [{ type: "text", text: result }];
57
59
  if (!result || typeof result !== "object" || Array.isArray(result))
58
60
  return [{ type: "text", text: JSON.stringify(result, null, 2) }];
59
61
  const { image, file, ...rest } = result;
@@ -262,6 +262,7 @@ export const PLUG_INSTRUCTIONS = `Huddle: you are one of several AI coding agent
262
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.
263
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
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.
265
+ - Connectors: tools named github_*, atlassian_*, linear_*… (when listed) reach the team's outside tools — issues, tickets, docs, CI. When the goal names an issue or ticket, read it (and its comments) before you start. Post or change things there only when your task asks for it.
265
266
  - Finish: huddle_done with a short summary. Huddle tests and merges it.`;
266
267
  /** The Plug's verb names, in the order listed to agents. */
267
268
  export const PLUG_VERBS = TOOLS.map((t) => t.name);
@@ -270,13 +271,32 @@ export async function runPlug() {
270
271
  if (process.env.HUDDLE_LIVE)
271
272
  return (await import("../attach/plug.js")).runLivePlug();
272
273
  const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
274
+ // Connectors (GitHub, Jira, …) the Room runs for this session, after Huddle's own verbs.
275
+ let extra = null;
276
+ const connectorTools = async () => {
277
+ if (extra)
278
+ return extra;
279
+ try {
280
+ const r = await room("/tools", "GET");
281
+ extra = Array.isArray(r?.tools) ? r.tools : [];
282
+ }
283
+ catch {
284
+ extra = [];
285
+ }
286
+ return extra;
287
+ };
273
288
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
274
- tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
289
+ tools: [...TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })), ...(await connectorTools())],
275
290
  }));
276
291
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
277
292
  const tool = TOOLS.find((t) => t.name === req.params.name);
278
- if (!tool)
279
- throw new Error(`unknown tool: ${req.params.name}`);
293
+ if (!tool) {
294
+ if (!(await connectorTools()).some((t) => t.name === req.params.name))
295
+ throw new Error(`unknown tool: ${req.params.name}`);
296
+ const r = await room("/tool", "POST", { name: req.params.name, arguments: req.params.arguments ?? {} });
297
+ const result = r?.result ?? r;
298
+ return { content: toContent(result), isError: !!(result && typeof result === "object" && "error" in result) };
299
+ }
280
300
  const result = await tool.handler((req.params.arguments ?? {}));
281
301
  return { content: toContent(result) };
282
302
  });
@@ -372,7 +372,7 @@ export async function runCouncil(goal, crew, members, repoRoot, opts) {
372
372
  // (0.1.2): the whole team waited 2 minutes on one agent's ratify that then timed out.
373
373
  const ratifyMs = Math.min(timeoutMs, (Number(process.env.HUDDLE_RATIFY_TIMEOUT_S) || 75) * 1000);
374
374
  const ro = { ask: opts.ask ?? defaultAsk, cwd: repoRoot, timeoutMs, signal: opts.signal, onEvent: opts.onEvent };
375
- const context = repoContext(repoRoot, 150);
375
+ const context = repoContext(repoRoot, 150) + (opts.outside ? `\n\n${opts.outside}` : "");
376
376
  const ids = members.map((m) => m.id);
377
377
  const result = (extra) => ({
378
378
  members: ids,
@@ -147,7 +147,7 @@ export async function planGoal(goal, crew, repoRoot, opts = {}) {
147
147
  const brain = opts.brain ?? brainFrom("claude");
148
148
  if (!brain)
149
149
  return { tasks: templatePlan(goal), by: "template", note: "no brain available" };
150
- const prompt = plannerPrompt(goal, crew, repoContext(repoRoot), undefined, { project: opts.project });
150
+ const prompt = plannerPrompt(goal, crew, repoContext(repoRoot) + (opts.outside ? `\n\n${opts.outside}` : ""), undefined, { project: opts.project });
151
151
  let ms = 0;
152
152
  for (let attempt = 0; attempt < 2 && !opts.signal?.aborted; attempt++) {
153
153
  const res = await brain.think(prompt, repoRoot, {
@@ -53,12 +53,32 @@ HOW:
53
53
  resolution and re-runs the tests before anything lands.
54
54
  4. huddle_remember(kind="decision", body=<how you combined them>, why=...), then huddle_done(task_id, summary).`;
55
55
  }
56
- function reviewPrompt(title, spec, diff, contract = "") {
56
+ /** What a task's author read through connectors (a ticket, an issue), for a reviewer who has no tools. */
57
+ export function outsideReads(db, taskId, max = 8000) {
58
+ const seen = new Set();
59
+ const parts = [];
60
+ for (const e of db.eventsOfType(["connector.call"], 0, 500)) {
61
+ if (e.task_id !== taskId)
62
+ continue;
63
+ const p = JSON.parse(e.payload || "{}");
64
+ if (p.ok === false || !p.result)
65
+ continue;
66
+ const key = `${p.tool} ${JSON.stringify(p.args ?? {})}`;
67
+ if (seen.has(key))
68
+ continue;
69
+ seen.add(key);
70
+ parts.push(`- ${key}:\n${p.result}`);
71
+ }
72
+ if (!parts.length)
73
+ return "";
74
+ return cap(`WHAT THE AUTHOR READ FROM OUTSIDE TOOLS (verbatim — the ticket/issue the task refers to):\n${parts.join("\n")}`, max);
75
+ }
76
+ function reviewPrompt(title, spec, diff, contract = "", outside = "") {
57
77
  return `You are reviewing a teammate's change before Huddle merges it. Another AI agent wrote it.
58
78
 
59
79
  TASK: ${title}
60
80
  ${spec}
61
- ${contract ? `\n${cap(contract, 2500)}\n` : ""}
81
+ ${contract ? `\n${cap(contract, 2500)}\n` : ""}${outside ? `\n${outside}\n` : ""}
62
82
  DIFF (against main):
63
83
  ${diff || "(empty diff)"}
64
84
 
@@ -985,7 +1005,7 @@ export async function runBoard(opts) {
985
1005
  onEvent({ type: "review", agent: reviewer.id, author: w.id, task: task.id, title: task.title });
986
1006
  fill(); // everyone else keeps going
987
1007
  const t0 = Date.now();
988
- const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract()), wt.path, {
1008
+ const res = await askAgent(reviewer.adapter, reviewPrompt(task.title, task.spec, branchDiff(repoRoot, wt), contract(), outsideReads(db, task.id)), wt.path, {
989
1009
  signal,
990
1010
  timeoutMs: 90_000,
991
1011
  noTools: true, // the diff is in the prompt — no need to explore
@@ -674,6 +674,33 @@ export async function startRoom(db, port = 0, info = {}) {
674
674
  "POST /context_search": "context_search",
675
675
  "POST /context_note": "context_note",
676
676
  };
677
+ /** GET /tools: the connectors' tools (once they've started, bounded); POST /tool: call one. */
678
+ const connectorVerb = async (p, req, agentId) => {
679
+ const hub = room.connectors;
680
+ if (p === "/tools") {
681
+ if (!hub)
682
+ return { status: 200, body: { tools: [] } };
683
+ await Promise.race([hub.ready, new Promise((r) => setTimeout(r, 20_000))]);
684
+ return { status: 200, body: { tools: hub.tools().map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } };
685
+ }
686
+ const body = await readJson(req);
687
+ const name = String(body.name ?? "");
688
+ if (!hub)
689
+ return { status: 200, body: { error: "no connectors in this run" } };
690
+ const t0 = Date.now();
691
+ const result = await hub.call(name, body.arguments && typeof body.arguments === "object" ? body.arguments : {}, agentId);
692
+ const failed = !!result && typeof result === "object" && "error" in result;
693
+ // What it read is kept (cut short) on the agent's task: a reviewer has no tools, and needs
694
+ // the ticket the author built from (found live, 0.1.4: codex sent back WEB-7 for "the ticket's missing").
695
+ const task = db.listTasks().find((t) => t.claimed_by === agentId && !["merged", "backlog"].includes(t.status));
696
+ const text = typeof result === "string" ? result : JSON.stringify(result);
697
+ db.publish("connector.call", {
698
+ agent_id: agentId,
699
+ task_id: task?.id ?? null,
700
+ payload: { tool: name, args: body.arguments ?? {}, ms: Date.now() - t0, ok: !failed, result: text.length > 6000 ? text.slice(0, 6000) + " …(cut)" : text },
701
+ });
702
+ return { status: 200, body: { result } };
703
+ };
677
704
  const localServer = http.createServer(async (req, res) => {
678
705
  const url = new URL(req.url ?? "/", "http://local");
679
706
  const reply = (r) => {
@@ -688,6 +715,8 @@ export async function startRoom(db, port = 0, info = {}) {
688
715
  const body = req.method === "POST" ? await readJson(req) : Object.fromEntries(url.searchParams);
689
716
  return reply(await room.live.handle(live[1], body, String(req.headers["x-huddle-agent"] ?? "")));
690
717
  }
718
+ if (url.pathname === "/tools" || url.pathname === "/tool")
719
+ return reply(await connectorVerb(url.pathname, req, String(req.headers["x-huddle-agent"] ?? "agent")));
691
720
  const verb = LOCAL_VERBS[`${req.method} ${url.pathname}`];
692
721
  if (!verb)
693
722
  return reply({ status: 404, body: { error: "not found" } });
package/dist/ui/app.js CHANGED
@@ -10,6 +10,8 @@
10
10
  //
11
11
  // Everything happens here. Claude, Codex, Gemini and Cursor run in the
12
12
  // background as Huddle's workers; you watch the board, never their terminals.
13
+ import { issueGoal, issueMentions, issueRef, runAsync } from "../connectors/github.js";
14
+ import { connectorLine, startHub, statusLine, toolsNote } from "../connectors/hub.js";
13
15
  import fs from "node:fs";
14
16
  import os from "node:os";
15
17
  import path from "node:path";
@@ -614,6 +616,7 @@ async function withSpinner(label, p) {
614
616
  /** A run, and the brief's pointers put back however it ended (plan mode, a stop, a crash). */
615
617
  /** The current run's typing line and open questions, closed however the run ends (a throw included). */
616
618
  let endTalkNow = null;
619
+ let hubNow = null;
617
620
  export async function runGoal(goal, opts) {
618
621
  try {
619
622
  await runGoalOnce(goal, opts);
@@ -621,6 +624,8 @@ export async function runGoal(goal, opts) {
621
624
  finally {
622
625
  endTalkNow?.();
623
626
  endTalkNow = null;
627
+ hubNow?.close();
628
+ hubNow = null;
624
629
  if (isGitRepo(process.cwd()))
625
630
  placePointers(repoRoot(), { enabled: readPrefs().brief !== false });
626
631
  }
@@ -659,6 +664,48 @@ async function runGoalOnce(goal, opts) {
659
664
  }
660
665
  const root = repoRoot();
661
666
  const rl = opts.rl;
667
+ // `huddle build #12`: the GitHub issue, verbatim, is the goal (connectors/github.ts).
668
+ const issueNo = issueRef(goal);
669
+ if (issueNo !== null) {
670
+ const r = await runAsync(["gh", "issue", "view", String(issueNo), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 30_000 });
671
+ if (r.code !== 0) {
672
+ print(C.red(`✗ couldn't read GitHub issue #${issueNo}: `) + ((r.stderr || r.stdout).trim().split("\n")[0] || "gh failed") + C.faint(r.code === 127 ? " (install the GitHub CLI: https://cli.github.com)" : ""));
673
+ if (!opts.rl)
674
+ process.exitCode = 1;
675
+ return;
676
+ }
677
+ const issue = JSON.parse(r.stdout);
678
+ goal = issueGoal(issue);
679
+ print(` ${C.violet("◆")} ${C.faint("goal from GitHub issue")} #${issue.number}: ${issue.title}`);
680
+ }
681
+ else {
682
+ // "fix #12 and add a test": the goal stays yours, the issue comes along (when gh can read it).
683
+ for (const n of issueMentions(goal)) {
684
+ const r = await runAsync(["gh", "issue", "view", String(n), "--json", "number,title,body,url,comments"], { cwd: root, timeoutMs: 20_000 });
685
+ if (r.code !== 0)
686
+ continue;
687
+ try {
688
+ const issue = JSON.parse(r.stdout);
689
+ goal += `\n\n--- ${issueGoal(issue).replace(/^Resolve /, "")}`;
690
+ print(` ${C.violet("◆")} ${C.faint("with GitHub issue")} #${issue.number}: ${issue.title}`);
691
+ }
692
+ catch {
693
+ /* not an issue: leave the goal as typed */
694
+ }
695
+ }
696
+ }
697
+ // GitHub, Jira, … for every agent this run, through the Plug (connectors/hub.ts).
698
+ // Started now, so they connect while the plan is made, and the planner knows what agents can read.
699
+ const hub = startHub({ root });
700
+ hubNow = hub;
701
+ let saidTools = false;
702
+ const outside = async () => {
703
+ await Promise.race([hub.ready, new Promise((r) => setTimeout(r, 30_000))]);
704
+ if (!saidTools && hub.status().length)
705
+ print(C.faint(` connectors: ${hub.status().map(statusLine).join(" · ")}`));
706
+ saidTools = true;
707
+ return toolsNote(hub);
708
+ };
662
709
  // Teammates' agents (from `huddle join` on their machines) work alongside yours.
663
710
  const bridge = opts.room?.server.remote;
664
711
  const remoteNow = () => (bridge?.workers() ?? []).map((r) => ({ id: r.id, remote: r }));
@@ -943,7 +990,7 @@ async function runGoalOnce(goal, opts) {
943
990
  /* no history: roster order */
944
991
  }
945
992
  try {
946
- plan = await runCouncil(goal, crew, members, root, { brain, signal: life.signal, onEvent: onCouncil, pastSyntheses: past });
993
+ plan = await runCouncil(goal, crew, members, root, { brain, signal: life.signal, onEvent: onCouncil, pastSyntheses: past, outside: await outside() });
947
994
  }
948
995
  finally {
949
996
  stopDock(); // the plan prints in full; the dock comes back for the build
@@ -954,7 +1001,7 @@ async function runGoalOnce(goal, opts) {
954
1001
  opts.onStats?.(stats);
955
1002
  // A big goal without a council (--no-council, or one agent) still gets a project-sized plan.
956
1003
  const project = !listsOwnTasks(goal) && isBigGoal(goal, { repoFiles });
957
- plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: life.signal, project }));
1004
+ plan = await withSpinner(`planning with ${brain?.label ?? "template"}…`, planGoal(goal, crew, root, { brain, signal: life.signal, project, outside: await outside() }));
958
1005
  }
959
1006
  if (life.signal.aborted)
960
1007
  return endTalk();
@@ -1009,6 +1056,7 @@ async function runGoalOnce(goal, opts) {
1009
1056
  const seen = [];
1010
1057
  db.clearBoard();
1011
1058
  const room = opts.room?.server ?? (await startRoom(db, ROOM_PORT));
1059
+ room.connectors = hub;
1012
1060
  const taskIds = seedBoard(db, goal, plan.tasks);
1013
1061
  // Who planned it, for `huddle story` (the tasks themselves are task.added events).
1014
1062
  db.publish("plan.made", { payload: { by: plan.by, note: plan.note ?? null, ms: plan.ms ?? null, tokens: plan.tokens ?? null, tasks: plan.tasks.length, plain: plan.plain ?? null } });
@@ -1079,6 +1127,12 @@ async function runGoalOnce(goal, opts) {
1079
1127
  const head = ` ${C.violet("◆")} ${pad(String(p.author ?? "memory"), 12)} ${C.faint("noted:")} `;
1080
1128
  print(`${head}${oneLine(p.body, space(head))}`);
1081
1129
  }
1130
+ else if (e.type === "connector.call" && e.agent_id) {
1131
+ // "codex github: read issue #12"
1132
+ const line = connectorLine(p);
1133
+ sayOne(e.agent_id, C.faint(line.head), line.body);
1134
+ act(e.agent_id, `${C.faint(line.head)} ${line.body}`);
1135
+ }
1082
1136
  else if (e.type === "plan.announced" && e.agent_id) {
1083
1137
  sayOne(e.agent_id, C.faint("plans:"), p.intent ?? "");
1084
1138
  act(e.agent_id, `${C.faint("plans:")} ${p.intent ?? ""}`);
@@ -1320,6 +1374,9 @@ async function runGoalOnce(goal, opts) {
1320
1374
  unsub();
1321
1375
  unwatchBudget();
1322
1376
  room.askUser = undefined;
1377
+ room.connectors = undefined;
1378
+ hub.close();
1379
+ hubNow = null;
1323
1380
  endTalk();
1324
1381
  const undone = budget.level === "ok" ? [] : db.listTasks().filter((t) => t.status === "backlog").map((t) => t.title);
1325
1382
  const doneNotes = db.recall().filter((m) => /^Task \S+ done:/.test(m.body)).map((m) => `- (${m.author}) ${m.body}`);
@@ -1330,6 +1387,8 @@ async function runGoalOnce(goal, opts) {
1330
1387
  if (ev.task_id && p.kind === "rescue" && p.branch)
1331
1388
  takeovers.set(ev.task_id, String(p.branch));
1332
1389
  }
1390
+ // What `huddle undo` takes back: every commit this run put on the branch.
1391
+ db.publish("git.range", { payload: { start: startHead, end: git(["rev-parse", "HEAD"], root).out, branch: git(["symbolic-ref", "--short", "HEAD"], root).out } });
1333
1392
  const learned = learnFromRun({ db, root, since: runStart, events: seen });
1334
1393
  progress({ phase: "DONE" });
1335
1394
  board = null;
@@ -93,7 +93,7 @@ export function printAgents() {
93
93
  }
94
94
  console.log(`\n ${C.faint("choose:")} huddle agents use claude,codex ${C.faint("·")} huddle agents skip cursor`);
95
95
  console.log(` ${C.faint("set up the rest:")} huddle setup`);
96
- console.log(` ${C.faint("every connector:")} huddle agents --all\n`);
96
+ console.log(` ${C.faint("every agent: ")} huddle agents --all\n`);
97
97
  }
98
98
  /** `huddle agents use|skip <ids>`: remembered in ~/.huddle/prefs.json. */
99
99
  export function chooseAgents(verb, list) {