huddle-cli 0.0.0-stage → 0.1.1
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 +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// The Plug: one MCP server, spoken identically by every agent (Claude, Codex,
|
|
2
|
+
// Gemini, Cursor). It is launched per-agent as `huddle plug` and talks to the
|
|
3
|
+
// Room over HTTP, so the agent process stays stateless — the Room owns truth.
|
|
4
|
+
//
|
|
5
|
+
// The agent's identity is passed in via HUDDLE_AGENT_ID (set by the launcher),
|
|
6
|
+
// so each agent's calls are attributed correctly without the model having to
|
|
7
|
+
// remember who it is.
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { contextTools, toContent } from "./context-tools.js";
|
|
12
|
+
import { packageInfo } from "../preflight.js";
|
|
13
|
+
const ROOM = process.env.HUDDLE_ROOM_URL ?? "http://localhost:4737";
|
|
14
|
+
const AGENT = process.env.HUDDLE_AGENT_ID ?? "unknown-agent";
|
|
15
|
+
// Proves to the Room that this call comes from one of this session's own agents.
|
|
16
|
+
const SECRET = process.env.HUDDLE_SECRET ?? "";
|
|
17
|
+
async function room(path, method, body) {
|
|
18
|
+
const res = await fetch(`${ROOM}${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers: { "content-type": "application/json", "x-huddle-secret": SECRET, "x-huddle-agent": AGENT },
|
|
21
|
+
body: method === "POST" ? JSON.stringify({ agent_id: AGENT, ...body }) : undefined,
|
|
22
|
+
});
|
|
23
|
+
return res.json();
|
|
24
|
+
}
|
|
25
|
+
const TOOLS = [
|
|
26
|
+
{
|
|
27
|
+
name: "huddle_board",
|
|
28
|
+
description: "See the whole shared task board: the goal, every agent, who claimed what, and live progress. Call this BEFORE you start work and whenever you finish a step.",
|
|
29
|
+
inputSchema: { type: "object", properties: {} },
|
|
30
|
+
handler: () => room("/board", "GET"),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "huddle_peers",
|
|
34
|
+
description: "See what every other agent is doing RIGHT NOW (status + current task). Use this to avoid duplicating work and to know if a teammate is close to done.",
|
|
35
|
+
inputSchema: { type: "object", properties: {} },
|
|
36
|
+
handler: () => room("/peers", "GET"),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "huddle_claim",
|
|
40
|
+
description: "Atomically claim a backlog task so it's yours. Fails if someone already took it or its dependencies aren't merged yet. Always claim before coding.",
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: "object",
|
|
43
|
+
properties: { task_id: { type: "string" } },
|
|
44
|
+
required: ["task_id"],
|
|
45
|
+
},
|
|
46
|
+
handler: (a) => room("/claim", "POST", { task_id: a.task_id }),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "huddle_check_overlap",
|
|
50
|
+
description: "Before you edit files/symbols, check whether any teammate has already claimed them. Pass entries as 'path' or 'path::symbol'. Returns anyone you'd collide with.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { touches: { type: "array", items: { type: "string" } } },
|
|
54
|
+
required: ["touches"],
|
|
55
|
+
},
|
|
56
|
+
handler: (a) => room("/check_overlap", "POST", { touches: a.touches }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "huddle_announce_plan",
|
|
60
|
+
description: "Announce what you're about to change BEFORE writing code: your intent + the files/symbols you'll touch ('path' or 'path::symbol'). Takes soft leases and flags collisions early.",
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
task_id: { type: "string" },
|
|
65
|
+
intent: { type: "string" },
|
|
66
|
+
touches: { type: "array", items: { type: "string" } },
|
|
67
|
+
},
|
|
68
|
+
required: ["task_id", "intent", "touches"],
|
|
69
|
+
},
|
|
70
|
+
handler: (a) => room("/announce_plan", "POST", { task_id: a.task_id, intent: a.intent, touches: a.touches }),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: "huddle_progress",
|
|
74
|
+
description: "Post live progress on your task (0-100) with a short note. Fans out instantly to the dashboard and to any teammate waiting on you.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
task_id: { type: "string" },
|
|
79
|
+
pct: { type: "number" },
|
|
80
|
+
note: { type: "string" },
|
|
81
|
+
},
|
|
82
|
+
required: ["task_id", "pct"],
|
|
83
|
+
},
|
|
84
|
+
handler: (a) => room("/progress", "POST", { task_id: a.task_id, pct: a.pct, note: a.note ?? "" }),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: "huddle_split",
|
|
88
|
+
description: "Your task is big and a teammate is free (you'll see a help offer in huddle_inbox)? Hand a separable PART of it to the room as a new task — ideally one in files you won't keep editing. Huddle gives it to a free agent and merges it separately; you keep the rest and must not do that part yourself. after_mine=true makes the part wait until your task merges.",
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: {
|
|
92
|
+
task_id: { type: "string", description: "your task" },
|
|
93
|
+
title: { type: "string", description: "short title of the part you hand off" },
|
|
94
|
+
spec: { type: "string", description: "exactly what the part must do, incl. names/contracts it must match" },
|
|
95
|
+
files: { type: "array", items: { type: "string" }, description: "files the part owns" },
|
|
96
|
+
after_mine: { type: "boolean" },
|
|
97
|
+
},
|
|
98
|
+
required: ["task_id", "title", "spec"],
|
|
99
|
+
},
|
|
100
|
+
handler: (a) => room("/split", "POST", { task_id: a.task_id, title: a.title, spec: a.spec, files: a.files ?? [], after_mine: !!a.after_mine }),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "huddle_offer_help",
|
|
104
|
+
description: "You're done (or nearly) and could take more: tell every teammate who is mid-task that you can take part of their work. Returns what's in flight (owner, status, progress, last note, age). They decide whether to huddle_split.",
|
|
105
|
+
inputSchema: { type: "object", properties: {} },
|
|
106
|
+
handler: () => room("/offer_help", "POST", {}),
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "huddle_remember",
|
|
110
|
+
description: "Write to SHARED MEMORY so other agents learn from you. kind = decision | fact | gotcha | lesson. Always include 'why' — teammates need the reasoning, not just the what. kind=lesson is also kept for FUTURE runs in this repo: one short, general rule you'd want next time (e.g. 'run npm run typecheck too: npm test doesn't type-check').",
|
|
111
|
+
inputSchema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: {
|
|
114
|
+
kind: { type: "string", enum: ["decision", "fact", "gotcha", "lesson"] },
|
|
115
|
+
body: { type: "string" },
|
|
116
|
+
why: { type: "string" },
|
|
117
|
+
},
|
|
118
|
+
required: ["kind", "body"],
|
|
119
|
+
},
|
|
120
|
+
handler: (a) => room("/remember", "POST", { kind: a.kind, body: a.body, why: a.why ?? "" }),
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "huddle_recall",
|
|
124
|
+
description: "Read shared memory — the decisions, facts, and gotchas other agents have recorded. Call this at the start of any task to inherit context instead of re-deriving it.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: { query: { type: "string" } },
|
|
128
|
+
},
|
|
129
|
+
handler: (a) => room(`/recall${a.query ? `?q=${encodeURIComponent(a.query)}` : ""}`, "GET"),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "huddle_ask",
|
|
133
|
+
description: "Ask a question another agent (on any teammate's machine) may know — e.g. 'what shape is the Order type?'. With `to` = a teammate agent that's working right now, it goes to THEIR inbox and waits a short while for their reply; otherwise (or if they're slow) it's answered from shared memory, and their late reply arrives in your huddle_inbox. Use it before guessing at a contract someone else owns.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
question: { type: "string" },
|
|
138
|
+
to: { type: "string", description: "optional: the agent who owns it, e.g. 'codex' or 'harsh/codex' (see huddle_peers)" },
|
|
139
|
+
},
|
|
140
|
+
required: ["question"],
|
|
141
|
+
},
|
|
142
|
+
handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "huddle_send",
|
|
146
|
+
description: "Message a teammate agent directly: a heads-up ('I renamed X to Y'), an interface you're exposing, or an answer to their question (pass reply_to = the question's id). `to` = an agent id from huddle_peers, a task id, \"all\" for everyone working now, or \"huddle\" to answer Huddle itself. They see it the next time they call any huddle tool.",
|
|
147
|
+
inputSchema: {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
to: { type: "string", description: "agent id (e.g. 'codex', 'claude#2', 'harsh/codex'), a task id, or 'all'" },
|
|
151
|
+
message: { type: "string" },
|
|
152
|
+
reply_to: { type: "string", description: "optional: id of the message/question you're answering" },
|
|
153
|
+
},
|
|
154
|
+
required: ["message"],
|
|
155
|
+
},
|
|
156
|
+
handler: (a) => room("/send", "POST", { to: a.to, message: a.message, reply_to: a.reply_to }),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "huddle_contract",
|
|
160
|
+
description: "Read the team's CONTRACT: the exact cross-task names everyone codes to (files and owners, function signatures, data fields, CLI flags, routes, error strings), its version, and every amendment since. Check it before you name anything another task uses.",
|
|
161
|
+
inputSchema: { type: "object", properties: {} },
|
|
162
|
+
handler: () => room("/contract", "GET"),
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "huddle_amend_contract",
|
|
166
|
+
description: "Change or add a name in the CONTRACT (a signature, a field, a file owner, an error string) when your task needs one that isn't there or is wrong. Bumps the version and messages every teammate working now. Never invent a name another task depends on without this.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
section: { type: "string", description: "what part it changes, e.g. 'store.js exports' or 'error strings'" },
|
|
171
|
+
change: { type: "string", description: "the exact new name/signature/shape" },
|
|
172
|
+
why: { type: "string" },
|
|
173
|
+
},
|
|
174
|
+
required: ["section", "change", "why"],
|
|
175
|
+
},
|
|
176
|
+
handler: (a) => room("/amend_contract", "POST", { section: a.section, change: a.change, why: a.why }),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
name: "huddle_inbox",
|
|
180
|
+
description: "Read messages and questions teammates sent you (marks them read). Huddle tool results already carry new ones (new_messages); call this for more, or with id for a message shown cut short. Answer questions with huddle_send(reply_to=…).",
|
|
181
|
+
inputSchema: { type: "object", properties: { id: { type: "string", description: "optional: one message's id" } } },
|
|
182
|
+
handler: (a) => room(a?.id ? `/inbox?id=${encodeURIComponent(String(a.id))}` : "/inbox", "GET"),
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "huddle_report_bug",
|
|
186
|
+
description: "QA: file ONE concrete, reproducible bug you found in the team's merged work. It becomes a fix task on the board, routed to the agent that wrote those files. Exact steps or a command in `repro`; `files` = where the defect is. When sensible, add a regression test that fails today: write it in your worktree and pass its path as `test_file` (or its content as `test_code`). Duplicates are merged automatically. Not for style nits.",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
title: { type: "string", description: "short: what is broken" },
|
|
191
|
+
repro: { type: "string", description: "exact command(s) or steps that show it" },
|
|
192
|
+
expected: { type: "string" },
|
|
193
|
+
actual: { type: "string" },
|
|
194
|
+
files: { type: "array", items: { type: "string" }, description: "files the defect is in" },
|
|
195
|
+
test_file: { type: "string", description: "optional: path of a regression test you wrote (fails now, passes once fixed)" },
|
|
196
|
+
test_code: { type: "string", description: "optional: that test's full content, if you didn't write the file" },
|
|
197
|
+
},
|
|
198
|
+
required: ["title", "repro", "expected", "actual"],
|
|
199
|
+
},
|
|
200
|
+
handler: (a) => room("/report_bug", "POST", {
|
|
201
|
+
title: a.title,
|
|
202
|
+
repro: a.repro,
|
|
203
|
+
expected: a.expected,
|
|
204
|
+
actual: a.actual,
|
|
205
|
+
files: a.files ?? [],
|
|
206
|
+
test_file: a.test_file,
|
|
207
|
+
test_code: a.test_code,
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "huddle_take_backlog",
|
|
212
|
+
description: "You're free. Ask the Room for the highest-value unblocked backlog task that fits your strengths, and claim it automatically. This is how idle agents remove bottlenecks.",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: { prefers: { type: "array", items: { type: "string" } } },
|
|
216
|
+
},
|
|
217
|
+
handler: (a) => room("/take_backlog", "POST", { prefers: a.prefers ?? [] }),
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "huddle_done",
|
|
221
|
+
description: "Mark your task ready. This triggers the test-gate and, if green, the merge into main. Include a short summary of what you did. " +
|
|
222
|
+
"If a teammate's buddy note or a radar finding on your task has no answer yet, it is refused once with the list: fix each one, or reply " +
|
|
223
|
+
"huddle_send(to=<who sent it>, reply_to=<its id>, message=\"won't fix: <why>\"), then call huddle_done again.",
|
|
224
|
+
inputSchema: {
|
|
225
|
+
type: "object",
|
|
226
|
+
properties: { task_id: { type: "string" }, summary: { type: "string" } },
|
|
227
|
+
required: ["task_id", "summary"],
|
|
228
|
+
},
|
|
229
|
+
handler: (a) => room("/done", "POST", { task_id: a.task_id, summary: a.summary }),
|
|
230
|
+
},
|
|
231
|
+
...contextTools(room),
|
|
232
|
+
];
|
|
233
|
+
/**
|
|
234
|
+
* What an MCP host shows the model about the Plug as a whole: the verbs by
|
|
235
|
+
* phase of a task, so every vendor's agent learns the same team habits. Every
|
|
236
|
+
* tool in TOOLS is named here (test/team-run.test.ts checks).
|
|
237
|
+
*/
|
|
238
|
+
export const PLUG_INSTRUCTIONS = `Huddle: you are one of several AI coding agents working on one repo at once, each in its own git worktree. These tools are how the team talks.
|
|
239
|
+
- Start: huddle_board (the plan and who is doing what), huddle_recall (shared memory: decisions, contracts, the design council's notes: huddle_recall "council"), huddle_peers, huddle_inbox.
|
|
240
|
+
- Before editing: huddle_announce_plan (what you will touch), huddle_check_overlap (is someone else in that file), huddle_contract (the exact names tasks share).
|
|
241
|
+
- Names other tasks use: code to the contract verbatim. Never invent one that isn't in it — huddle_amend_contract(section, change, why) first; it tells every teammate.
|
|
242
|
+
- While working: huddle_progress (a short note; the reply shows the team's in-flight work), huddle_remember (a decision or contract others need; kind "lesson" is kept for future runs), huddle_send (message one teammate), huddle_ask (ask a teammate or the brain a question), huddle_inbox (read replies).
|
|
243
|
+
- 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.
|
|
244
|
+
- QA rounds: huddle_report_bug files one reproducible bug in the merged build; it becomes a fix task for the file's author.
|
|
245
|
+
- 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.
|
|
246
|
+
- Finish: huddle_done with a short summary. Huddle tests and merges it.`;
|
|
247
|
+
/** The Plug's verb names, in the order listed to agents. */
|
|
248
|
+
export const PLUG_VERBS = TOOLS.map((t) => t.name);
|
|
249
|
+
export async function runPlug() {
|
|
250
|
+
// Attach mode (`huddle connect`): a session you started yourself, no scheduler.
|
|
251
|
+
if (process.env.HUDDLE_LIVE)
|
|
252
|
+
return (await import("../attach/plug.js")).runLivePlug();
|
|
253
|
+
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
|
|
254
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
255
|
+
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
256
|
+
}));
|
|
257
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
258
|
+
const tool = TOOLS.find((t) => t.name === req.params.name);
|
|
259
|
+
if (!tool)
|
|
260
|
+
throw new Error(`unknown tool: ${req.params.name}`);
|
|
261
|
+
const result = await tool.handler((req.params.arguments ?? {}));
|
|
262
|
+
return { content: toContent(result) };
|
|
263
|
+
});
|
|
264
|
+
const transport = new StdioServerTransport();
|
|
265
|
+
await server.connect(transport);
|
|
266
|
+
}
|
|
267
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// One-shot, read-only questions to an agent: answering "where are we with
|
|
2
|
+
// this project?" and planning a goal. Runs in the repo itself (no worktree),
|
|
3
|
+
// with each vendor's read-only / plan mode, so nothing can be edited.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { stopChild } from "./launcher.js";
|
|
9
|
+
import { onFinished, spawnTool } from "../platform.js";
|
|
10
|
+
/** Preference order for who answers questions and plans. */
|
|
11
|
+
const ASK_ORDER = ["claude", "codex", "antigravity", "cursor", "gemini"];
|
|
12
|
+
export function pickAsker(ready) {
|
|
13
|
+
return [...ready].filter((a) => a.ask).sort((a, b) => ASK_ORDER.indexOf(a.id) - ASK_ORDER.indexOf(b.id))[0];
|
|
14
|
+
}
|
|
15
|
+
export function askAgent(adapter, prompt, cwd, opts = {}) {
|
|
16
|
+
// Unique per call: the design council asks several agents in the same millisecond.
|
|
17
|
+
const outFile = path.join(os.tmpdir(), `huddle-ask-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
|
|
18
|
+
const spec = adapter.ask({ prompt, cwd, outFile, model: opts.model, noTools: opts.noTools, quick: opts.quick });
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const child = spawnTool(spec.cmd, { cwd: spec.cwd, stdio: ["ignore", "pipe", "pipe"] }, { prompt, stdinPrompt: adapter.stdinPrompt });
|
|
21
|
+
let out = "";
|
|
22
|
+
let err = "";
|
|
23
|
+
child.stdout.on("data", (d) => (out += d));
|
|
24
|
+
child.stderr.on("data", (d) => (err += d));
|
|
25
|
+
// SIGKILL after a grace period: a stalled CLI can ignore SIGTERM (seen live with cursor-agent).
|
|
26
|
+
const stop = () => stopChild(child);
|
|
27
|
+
const timer = setTimeout(stop, opts.timeoutMs ?? 5 * 60_000);
|
|
28
|
+
opts.signal?.addEventListener("abort", stop);
|
|
29
|
+
let over = false;
|
|
30
|
+
const finish = (code) => {
|
|
31
|
+
if (over)
|
|
32
|
+
return;
|
|
33
|
+
over = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
let text = out;
|
|
36
|
+
if (spec.usesOutFile) {
|
|
37
|
+
try {
|
|
38
|
+
text = fs.readFileSync(outFile, "utf8");
|
|
39
|
+
fs.unlinkSync(outFile);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* fall back to stdout */
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
resolve({ ok: code === 0 && text.trim().length > 0, text: text.trim() || err.trim() });
|
|
46
|
+
};
|
|
47
|
+
// "exit" can arrive before the last of stdout has been read — when our event
|
|
48
|
+
// loop was busy (a merge's test run blocks it) the answer was lost and the
|
|
49
|
+
// review counted as timed out (found on CI). onFinished waits for the pipes
|
|
50
|
+
// to drain, but not forever: a CLI's leftover child can hold them open.
|
|
51
|
+
onFinished(child, finish);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=ask.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The brain: the model Huddle itself thinks with — planning goals, reviewing
|
|
2
|
+
// diffs, writing the "what you got" report. It's separate from the agents that
|
|
3
|
+
// write code, so it can be something small and cheap:
|
|
4
|
+
//
|
|
5
|
+
// ollama:<model> a local model (e.g. Qwen2.5-Coder) — free, private, offline
|
|
6
|
+
// cursor:<model> a cheap hosted model through Cursor (e.g. grok-4.7-low-fast)
|
|
7
|
+
// claude:<model> Claude at a given size (claude:haiku, claude:opus)
|
|
8
|
+
// codex / antigravity[:model]
|
|
9
|
+
//
|
|
10
|
+
// Agent-backed brains can read the repo themselves. A local model can't, so
|
|
11
|
+
// every prompt carries a compact repo snapshot (see repoContext) — which also
|
|
12
|
+
// makes agent brains faster, since they don't have to explore first.
|
|
13
|
+
import { spawnSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { ADAPTERS } from "../adapters/index.js";
|
|
17
|
+
import { askAgent } from "./ask.js";
|
|
18
|
+
import { lessonsBrief } from "../room/lessons.js";
|
|
19
|
+
import { screenForPlanner } from "../room/context.js";
|
|
20
|
+
const OLLAMA = process.env.OLLAMA_HOST?.replace(/\/$/, "") || "http://127.0.0.1:11434";
|
|
21
|
+
/** Models a local Ollama has pulled, or [] if Ollama isn't running. */
|
|
22
|
+
export async function ollamaModels() {
|
|
23
|
+
try {
|
|
24
|
+
const r = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(1500) });
|
|
25
|
+
const j = await r.json();
|
|
26
|
+
return (j.models ?? []).map((m) => String(m.name).replace(/:latest$/, ""));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function ollamaBrain(model) {
|
|
33
|
+
return {
|
|
34
|
+
spec: `ollama:${model}`,
|
|
35
|
+
label: `${model} (local, free)`,
|
|
36
|
+
readsRepo: false,
|
|
37
|
+
async think(prompt, _cwd, opts = {}) {
|
|
38
|
+
const t0 = Date.now();
|
|
39
|
+
try {
|
|
40
|
+
const signals = [AbortSignal.timeout(opts.timeoutMs ?? 5 * 60_000), ...(opts.signal ? [opts.signal] : [])];
|
|
41
|
+
const r = await fetch(`${OLLAMA}/api/chat`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
signal: AbortSignal.any(signals),
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
model,
|
|
47
|
+
stream: false,
|
|
48
|
+
...(opts.json ? { format: "json" } : {}),
|
|
49
|
+
options: { temperature: 0.2, num_ctx: 16384 },
|
|
50
|
+
messages: [{ role: "user", content: prompt }],
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
const j = await r.json();
|
|
54
|
+
const text = String(j?.message?.content ?? "").trim();
|
|
55
|
+
return { ok: r.ok && !!text, text: text || String(j?.error ?? ""), tokens: (j.prompt_eval_count ?? 0) + (j.eval_count ?? 0), ms: Date.now() - t0 };
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
return { ok: false, text: String(e?.message ?? e), ms: Date.now() - t0 };
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function agentBrain(adapterId, model) {
|
|
64
|
+
const adapter = ADAPTERS.find((a) => a.id === adapterId);
|
|
65
|
+
if (!adapter?.ask)
|
|
66
|
+
return null;
|
|
67
|
+
return {
|
|
68
|
+
spec: model ? `${adapterId}:${model}` : adapterId,
|
|
69
|
+
label: model ? `${adapterId} · ${model}` : adapterId,
|
|
70
|
+
readsRepo: true,
|
|
71
|
+
async think(prompt, cwd, opts = {}) {
|
|
72
|
+
const t0 = Date.now();
|
|
73
|
+
const r = await askAgent(adapter, prompt, cwd, {
|
|
74
|
+
signal: opts.signal,
|
|
75
|
+
timeoutMs: opts.timeoutMs,
|
|
76
|
+
model,
|
|
77
|
+
noTools: opts.selfContained,
|
|
78
|
+
});
|
|
79
|
+
return { ...r, ms: Date.now() - t0 };
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** Parse a brain spec like "ollama:qwencoder" or "cursor:grok-4.7-low-fast". */
|
|
84
|
+
export function brainFrom(spec) {
|
|
85
|
+
const [kind, ...rest] = spec.split(":");
|
|
86
|
+
const model = rest.join(":") || undefined;
|
|
87
|
+
if (kind === "ollama")
|
|
88
|
+
return model ? ollamaBrain(model) : null;
|
|
89
|
+
return agentBrain(kind, model);
|
|
90
|
+
}
|
|
91
|
+
/** The default when nothing's configured: the strongest agent you have ready. */
|
|
92
|
+
export const DEFAULT_BRAIN = "claude";
|
|
93
|
+
/** Suggestions for the `brain` command, cheapest first. */
|
|
94
|
+
export async function brainOptions(readyAgents) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const m of await ollamaModels()) {
|
|
97
|
+
if (/llava|vision|embed/i.test(m))
|
|
98
|
+
continue; // not text planners
|
|
99
|
+
out.push({ spec: `ollama:${m}`, note: "local · free · private · speed depends on your RAM" });
|
|
100
|
+
}
|
|
101
|
+
if (readyAgents.includes("cursor"))
|
|
102
|
+
out.push({ spec: "cursor:grok-4.7-low-fast", note: "cheap + fast, via your Cursor plan" });
|
|
103
|
+
if (readyAgents.includes("claude")) {
|
|
104
|
+
out.push({ spec: "claude:haiku", note: "smallest Claude — cheapest hosted option" });
|
|
105
|
+
out.push({ spec: "claude:sonnet", note: "fast — plans in ~15s, a fraction of Opus's tokens" });
|
|
106
|
+
out.push({ spec: "claude:opus", note: "default — Opus, strongest plans, most tokens" });
|
|
107
|
+
}
|
|
108
|
+
if (readyAgents.includes("codex"))
|
|
109
|
+
out.push({ spec: "codex", note: "your Codex default model" });
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A compact snapshot of the repo for the brain: file list, README head,
|
|
114
|
+
* manifest. ~a few thousand tokens; lets even a local model plan sensibly.
|
|
115
|
+
*/
|
|
116
|
+
export function repoContext(cwd, maxFiles = 250) {
|
|
117
|
+
const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split(/\r?\n/).filter(Boolean);
|
|
118
|
+
const head = (f, lines) => {
|
|
119
|
+
try {
|
|
120
|
+
return fs.readFileSync(path.join(cwd, f), "utf8").split("\n").slice(0, lines).join("\n");
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const parts = [`FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
|
|
127
|
+
for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
|
|
128
|
+
const h = head(f, 40);
|
|
129
|
+
if (h)
|
|
130
|
+
parts.push(`${f}:\n${h}`);
|
|
131
|
+
}
|
|
132
|
+
const readme = files.find((f) => /^readme(\.md)?$/i.test(f));
|
|
133
|
+
if (readme) {
|
|
134
|
+
const h = head(readme, 40);
|
|
135
|
+
if (h)
|
|
136
|
+
parts.push(`${readme} (first 40 lines):\n${h}`);
|
|
137
|
+
}
|
|
138
|
+
// Small repo: show the source itself. A tool-less planner that only sees
|
|
139
|
+
// file names tries to "cat" them anyway (seen live: Sonnet replied with a
|
|
140
|
+
// fake bash call instead of a plan, Opus hung) and misses rules that live
|
|
141
|
+
// in code, like a test requiring every module in an index.
|
|
142
|
+
if (files.length <= 80) {
|
|
143
|
+
const rank = (f) => (/(^|\/)(index|main|app|registry|routes?)\.[a-z]+$/i.test(f) ? 0 : /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\./i.test(f) ? 1 : 2);
|
|
144
|
+
const skip = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|readme(\.md)?|package\.json)$|\.(png|jpe?g|gif|ico|pdf|zip|gz|woff2?|mp[34]|lock)$/i;
|
|
145
|
+
let budget = 24_000;
|
|
146
|
+
for (const f of [...files].sort((a, b) => rank(a) - rank(b))) {
|
|
147
|
+
if (skip.test(f))
|
|
148
|
+
continue;
|
|
149
|
+
let body;
|
|
150
|
+
try {
|
|
151
|
+
if (fs.statSync(path.join(cwd, f)).size > 4000)
|
|
152
|
+
continue;
|
|
153
|
+
body = fs.readFileSync(path.join(cwd, f), "utf8");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (body.includes("\u0000") || body.length > budget)
|
|
159
|
+
continue;
|
|
160
|
+
budget -= body.length;
|
|
161
|
+
parts.push(`${f}:\n${body.trimEnd()}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// The planner and the council plan from this snapshot: what earlier runs taught goes with it.
|
|
165
|
+
const lessons = lessonsBrief({ repo: cwd }).trim();
|
|
166
|
+
if (lessons)
|
|
167
|
+
parts.push(lessons);
|
|
168
|
+
// What the user pasted / dropped: the index (+ small items), not N full copies.
|
|
169
|
+
const screen = screenForPlanner(cwd);
|
|
170
|
+
if (screen)
|
|
171
|
+
parts.push(screen);
|
|
172
|
+
return parts.join("\n\n");
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=brain.js.map
|