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.
- package/CHANGELOG.md +28 -0
- package/QUICKSTART.md +29 -1
- package/README.md +12 -0
- package/dist/app/page.js +86 -12
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +180 -9
- package/dist/args.js +2 -2
- package/dist/cli.js +20 -2
- 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 +23 -3
- package/dist/orchestrator/council.js +1 -1
- package/dist/orchestrator/planner.js +1 -1
- package/dist/orchestrator/scheduler.js +23 -3
- package/dist/room/server.js +29 -0
- package/dist/ui/app.js +61 -2
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/undo.js +131 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
|
@@ -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
|
|
@@ -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;
|
package/dist/mcp/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/room/server.js
CHANGED
|
@@ -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;
|
package/dist/ui/catalog.js
CHANGED
|
@@ -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
|
|
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) {
|