huddle-cli 0.1.0 → 0.1.3
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 +69 -0
- package/QUICKSTART.md +135 -0
- package/README.md +26 -4
- package/dist/adapters/amp.js +1 -1
- package/dist/adapters/antigravity.js +2 -0
- package/dist/adapters/claude.js +19 -3
- package/dist/adapters/codex.js +9 -1
- package/dist/adapters/cursor.js +12 -5
- package/dist/adapters/custom.js +4 -2
- package/dist/adapters/droid.js +1 -0
- package/dist/adapters/goose.js +2 -0
- package/dist/adapters/index.js +44 -18
- package/dist/adapters/kiro.js +1 -1
- package/dist/adapters/opencode.js +1 -1
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +701 -0
- package/dist/app/server.js +262 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +7 -4
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +21 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +134 -42
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +57 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +16 -4
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +434 -87
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +31 -13
- package/dist/orchestrator/learn.js +101 -0
- package/dist/orchestrator/planner.js +54 -9
- package/dist/orchestrator/qa.js +71 -15
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +575 -50
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +377 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +79 -16
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +2 -2
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +75 -20
- package/dist/room/mail.js +225 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +220 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/room/user.js +54 -0
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +963 -159
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +269 -55
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/export.js +1194 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +11 -5
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +716 -94
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +182 -16
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/watch.js +149 -48
- package/package.json +6 -3
package/dist/cli.js
CHANGED
|
@@ -8,96 +8,165 @@
|
|
|
8
8
|
// --agents=claude,codex only these agents work on this run
|
|
9
9
|
// --council / --no-council every agent brainstorms the design first (default: big goals)
|
|
10
10
|
// --qa / --no-qa a QA round after the build (default: on for big goals)
|
|
11
|
+
// --budget=5M tokens this run may use: new work stops at 90%, agents at 120%
|
|
12
|
+
// --with <file|dir|glob> put files on the shared screen first (repeatable); piped stdin too
|
|
13
|
+
// huddle setup install + sign in to the agents you pick (asks first)
|
|
11
14
|
// huddle doctor is this machine ready: node, git, room db, your agents
|
|
15
|
+
// huddle agents your agents: ready / not signed in / not installed, used or skipped
|
|
16
|
+
// huddle agents use|skip <ids> choose who works with you (remembered)
|
|
12
17
|
// huddle --version print the version
|
|
13
18
|
// huddle agents --all every connector (built in + agents.json) and how to get it
|
|
14
19
|
// huddle agents install <id> install one connector's CLI, after asking
|
|
15
20
|
// huddle board print the board + shared memory
|
|
16
21
|
// huddle view [repo] the live view (ctrl-t) from another terminal, read-only
|
|
17
|
-
// huddle story [--html f] replay the latest run: who said what to whom,
|
|
22
|
+
// huddle story [--html f] replay the latest run: who built on whose idea, who said what to whom, merges
|
|
23
|
+
// huddle brief [--run n] the hand-off brief a solo agent reads after a run (off|on: the CLAUDE.md/AGENTS.md pointer)
|
|
24
|
+
// huddle runs every run in this repo: goal, agents, result, cost
|
|
25
|
+
// huddle export [run] a run as summary / chat / full transcript (md, html, json)
|
|
26
|
+
// huddle lessons what Huddle learned across runs (--forget <id>, --clear)
|
|
18
27
|
// huddle init set up .huddle/ in this repo (automatic on first run)
|
|
19
28
|
// huddle up | down attach mode: start/stop this repo's live room
|
|
20
29
|
// huddle connect | disconnect wire your own claude/codex/cursor sessions into it
|
|
21
30
|
// huddle peers who's live in this repo and which files they hold
|
|
31
|
+
// huddle add <file|dir|glob|-> put it on the live room's shared screen (- = stdin)
|
|
32
|
+
// huddle ctx [show|rm|note|clear] what's on that screen; take things off, pin notes
|
|
22
33
|
// huddle attach <agent> open an agent in its own worktree, connected
|
|
23
34
|
// huddle land [branch] tests -> rebase -> merge an attach session's branch
|
|
24
35
|
// huddle plug (internal) the MCP server each agent talks to
|
|
25
36
|
// huddle guard (internal) Claude's pre-edit hook in attach mode
|
|
37
|
+
// huddle mail --hook (internal) Claude's post-tool hook: teammates' new messages
|
|
26
38
|
import { packageInfo } from "./preflight.js"; // first: refuses old Node before anything else loads
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { connectAgents, ensureInit, huddleDir, printBoard, runApp, runGoal } from "./ui/app.js";
|
|
30
|
-
import { INVITE_CODE, joinRoom } from "./ui/remote.js";
|
|
31
|
-
import { startRelay } from "./relay/server.js";
|
|
32
|
-
import { runDoctor } from "./ui/doctor.js";
|
|
39
|
+
import { C, useAsciiOutput } from "./ui/term.js";
|
|
40
|
+
import { asGoal } from "./args.js";
|
|
33
41
|
import { SqliteUnavailableError } from "./room/sqlite.js";
|
|
34
|
-
|
|
42
|
+
// Everything heavier loads per command: `huddle mail --hook` runs after every
|
|
43
|
+
// one of Claude's tool calls and has to start in well under 300ms.
|
|
44
|
+
const app = () => import("./ui/app.js");
|
|
45
|
+
const B = C.bold;
|
|
46
|
+
const F = C.faint;
|
|
47
|
+
// Every line fits 80 columns.
|
|
35
48
|
const USAGE = `${C.violet("huddle")} — your agents, in the same room
|
|
36
49
|
|
|
37
|
-
${
|
|
38
|
-
${
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
${
|
|
48
|
-
${
|
|
49
|
-
${C.bold("huddle board")} print the board
|
|
50
|
-
${C.bold("huddle view")} ${C.faint("[repo]")} watch a run live from another terminal ${C.faint("(read-only)")}
|
|
51
|
-
${C.bold("huddle story")} ${C.faint("[--html f]")} replay the last run: messages, reviews, merges ${C.faint("(--list, --run n)")}
|
|
52
|
-
${C.bold("huddle --version")} print the version
|
|
50
|
+
${B("huddle")} open Huddle
|
|
51
|
+
${B('huddle join "<goal>"')} plan the goal and run your agents
|
|
52
|
+
${B("--plan")} dry run: show who'd do what
|
|
53
|
+
${B("--agents=claude,codex")} only these agents work on this run
|
|
54
|
+
${B("--approvals=safe|auto|yolo")} what agents may do unasked ${F("(default auto)")}
|
|
55
|
+
${B("--council")} ${F("/")} ${B("--no-council")} design council first ${F("(default: big goals)")}
|
|
56
|
+
${B("--qa")} ${F("/")} ${B("--no-qa")} agents re-check the build ${F("(default: big goals)")}
|
|
57
|
+
${B("--budget=5M")} tokens it may use ${F("(stops new work at 90%)")}
|
|
58
|
+
${B("--with <file>")} put a file, folder or glob on the shared screen
|
|
59
|
+
${F("(repeatable; or pipe: cat log | huddle join …)")}
|
|
60
|
+
${B("huddle app")} the dashboard: live run, history, talk to agents
|
|
61
|
+
${B("huddle join <address> <CODE>")} join someone's room from an invite
|
|
53
62
|
|
|
54
|
-
${
|
|
55
|
-
${
|
|
56
|
-
${
|
|
57
|
-
|
|
58
|
-
${
|
|
59
|
-
|
|
63
|
+
${F("watch and replay")}
|
|
64
|
+
${B("huddle view")} ${F("[repo]")} watch a run live, read-only, from a 2nd terminal
|
|
65
|
+
${B("huddle story")} ${F("[--html f]")} replay the last run: ideas, contract, merges
|
|
66
|
+
${F("(--list, --run n)")}
|
|
67
|
+
${B("huddle brief")} ${F("[--run n]")} what the last run left for your next solo agent
|
|
68
|
+
${F("(off / on: its pointer in CLAUDE.md, AGENTS.md)")}
|
|
69
|
+
${B("huddle runs")} every run here: goal, agents, result, cost
|
|
70
|
+
${B("huddle export")} ${F("[run]")} a run as a chat you can read or share
|
|
71
|
+
${F("(--level summary|chat|full, --out <path>,")}
|
|
72
|
+
${F(" --format md|html|json, --no-redact)")}
|
|
73
|
+
${B("huddle board")} print the board
|
|
74
|
+
${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
|
|
75
|
+
|
|
76
|
+
${F("your agents")}
|
|
77
|
+
${B("huddle setup")} install + sign in to your agents ${F("(--yes)")}
|
|
78
|
+
${B("huddle doctor")} check this machine: node, git, your agents
|
|
79
|
+
${B("huddle agents")} your agents: ready or not, used or skipped
|
|
80
|
+
${B("huddle agents use|skip <ids>")} choose who works with you ${F("(e.g. skip cursor)")}
|
|
81
|
+
${B("huddle agents --all")} every connector Huddle knows, installed or not
|
|
82
|
+
${B("huddle agents install <id>")} install one ${F("(asks first)")}
|
|
83
|
+
${B("huddle relay")} ${F("[--port 8080]")} run a relay so rooms work across networks
|
|
84
|
+
${B("huddle --version")} print the version
|
|
85
|
+
|
|
86
|
+
${F("run claude, codex and cursor yourself — in one repo, safely")}
|
|
87
|
+
${B("huddle connect")} wire your agents into this repo's live room
|
|
88
|
+
${B("huddle peers")} who's live, what they're on, their files
|
|
89
|
+
${B("huddle add <file|->")} put a file, folder, glob or stdin on the screen
|
|
90
|
+
${F("(cat err.log | huddle add -)")}
|
|
91
|
+
${B("huddle ctx")} ${F("[show|rm|note|clear]")} what's on the screen every session reads
|
|
92
|
+
${B("huddle attach <agent>")} open an agent in its own worktree branch
|
|
93
|
+
${B("huddle land")} ${F("[branch]")} test + merge that session's work
|
|
94
|
+
${B("huddle up")} ${F("/")} ${B("down")} ${F("/")} ${B("disconnect")} start · stop the live room · unwire
|
|
60
95
|
`;
|
|
61
96
|
async function main() {
|
|
62
|
-
const
|
|
97
|
+
const argv = process.argv.slice(2);
|
|
98
|
+
const [cmd, ...rest] = asGoal(argv) ?? argv;
|
|
99
|
+
// Legacy Windows consoles get ASCII glyphs. Not for the machine-read channels: the Plug's JSON-RPC and Claude's hooks.
|
|
100
|
+
if (!["plug", "guard", "mail"].includes(cmd ?? ""))
|
|
101
|
+
useAsciiOutput();
|
|
63
102
|
switch (cmd) {
|
|
64
103
|
case undefined:
|
|
65
104
|
if (!process.stdin.isTTY)
|
|
66
105
|
return void console.log(USAGE);
|
|
67
|
-
return runApp();
|
|
106
|
+
return (await app()).runApp();
|
|
68
107
|
case "plug":
|
|
69
|
-
return runPlug();
|
|
70
|
-
case "init":
|
|
108
|
+
return (await import("./mcp/server.js")).runPlug();
|
|
109
|
+
case "init": {
|
|
110
|
+
const { ensureInit, huddleDir } = await app();
|
|
71
111
|
if (!ensureInit()) {
|
|
72
112
|
console.log(C.red("✗ Not a git repo.") + " Huddle sandboxes are git worktrees — run `git init` first.");
|
|
73
113
|
process.exit(1);
|
|
74
114
|
}
|
|
75
115
|
return void console.log(`${C.green("✓")} Huddle initialized in ${C.faint(huddleDir())}`);
|
|
76
|
-
|
|
77
|
-
case "
|
|
116
|
+
}
|
|
117
|
+
case "setup":
|
|
118
|
+
process.exitCode = await (await import("./ui/setup.js")).runSetup(rest);
|
|
119
|
+
return;
|
|
120
|
+
case "agents": {
|
|
121
|
+
const { chooseAgents, installConnector, printAgents, printCatalog } = await import("./ui/catalog.js");
|
|
78
122
|
if (rest[0] === "install" && rest[1])
|
|
79
123
|
return void (await installConnector(rest[1]));
|
|
124
|
+
if ((rest[0] === "use" || rest[0] === "skip") && rest.length > 1)
|
|
125
|
+
return void (process.exitCode = chooseAgents(rest[0], rest.slice(1)) ? 0 : 1);
|
|
80
126
|
if (rest.includes("--all"))
|
|
81
127
|
return printCatalog();
|
|
82
|
-
|
|
128
|
+
if (!rest.length)
|
|
129
|
+
return printAgents();
|
|
130
|
+
process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
case "doctor":
|
|
134
|
+
process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
|
|
83
135
|
return;
|
|
84
136
|
case "join": {
|
|
85
137
|
// `huddle join <address> <CODE>` joins someone's room; anything else is a goal.
|
|
138
|
+
const { INVITE_CODE, joinRoom } = await import("./ui/remote.js");
|
|
86
139
|
if (rest.length === 2 && INVITE_CODE.test(rest[1]))
|
|
87
140
|
return joinRoom(rest[0], rest[1]);
|
|
88
|
-
const
|
|
141
|
+
const { connectAgents, dbPath, ensureInit, runGoal } = await app();
|
|
142
|
+
const { addForRun, readPipedStdin, takeWith } = await import("./ui/paste.js");
|
|
143
|
+
const withs = takeWith(rest);
|
|
144
|
+
const goal = withs.rest.filter((a) => !a.startsWith("--")).join(" ");
|
|
89
145
|
if (!goal) {
|
|
90
146
|
console.log(C.red("✗ Give a goal:") + ' huddle join "build the login page"');
|
|
91
147
|
process.exit(1);
|
|
92
148
|
}
|
|
149
|
+
// The shared screen first (--with files, piped stdin), so the planner sees its index.
|
|
150
|
+
const piped = await readPipedStdin();
|
|
151
|
+
if (withs.files.length || piped.trim()) {
|
|
152
|
+
if (!ensureInit()) {
|
|
153
|
+
console.log(C.red("✗ Not a git repo.") + " The shared screen lives in a repo's .huddle/ — run `git init` first.");
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
if (await addForRun(dbPath(), withs.files, piped, (l = "") => console.log(l))) {
|
|
157
|
+
console.log(C.red("✗ couldn't put everything on the shared screen (above) — nothing was run."));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
93
161
|
const flag = rest.find((a) => a.startsWith("--approvals="))?.split("=")[1];
|
|
94
162
|
const approvals = (["safe", "auto", "yolo"].includes(flag ?? "") ? flag : "auto");
|
|
95
|
-
|
|
163
|
+
let probed = await connectAgents();
|
|
96
164
|
let results = probed;
|
|
97
|
-
// --agents=claude,codex: run with just these (the rest sit this one out).
|
|
165
|
+
// --agents=claude,codex: run with just these (the rest sit this one out) — even ones you skipped.
|
|
98
166
|
const pick = rest.find((a) => a.startsWith("--agents="))?.split("=")[1];
|
|
99
167
|
if (pick !== undefined) {
|
|
100
168
|
const ids = pick.split(",").map((s) => s.trim()).filter(Boolean);
|
|
169
|
+
results = probed = (await import("./adapters/index.js")).unskip(probed, ids);
|
|
101
170
|
const unknown = ids.filter((id) => !results.some((r) => r.adapter.id === id));
|
|
102
171
|
if (!ids.length || unknown.length) {
|
|
103
172
|
console.log(C.red(`✗ --agents: no agent called ${unknown.join(", ") || "(empty)"}.`) + ` Known: ${results.map((r) => r.adapter.id).join(", ")}`);
|
|
@@ -112,21 +181,38 @@ async function main() {
|
|
|
112
181
|
// --council: every agent brainstorms the design first; --no-council: the brain plans alone.
|
|
113
182
|
const council = rest.includes("--no-council") ? false : rest.includes("--council") ? true : undefined;
|
|
114
183
|
const qa = rest.includes("--no-qa") ? false : rest.includes("--qa") ? true : undefined;
|
|
115
|
-
|
|
184
|
+
const budget = rest.find((a) => a.startsWith("--budget="))?.split("=")[1];
|
|
185
|
+
return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa, budget });
|
|
116
186
|
}
|
|
187
|
+
case "app":
|
|
188
|
+
case "dashboard":
|
|
189
|
+
return (await import("./app/server.js")).runApp(rest);
|
|
117
190
|
case "board":
|
|
118
|
-
return printBoard();
|
|
191
|
+
return (await app()).printBoard();
|
|
119
192
|
case "view":
|
|
120
193
|
case "watch":
|
|
121
194
|
return (await import("./ui/watch.js")).runView(rest);
|
|
122
195
|
case "story":
|
|
123
196
|
return (await import("./ui/story.js")).runStory(rest);
|
|
197
|
+
case "brief":
|
|
198
|
+
return (await import("./ui/brief.js")).runBrief(rest);
|
|
199
|
+
case "runs":
|
|
200
|
+
return (await import("./ui/export.js")).runRuns(rest);
|
|
201
|
+
case "export":
|
|
202
|
+
return (await import("./ui/export.js")).runExport(rest);
|
|
203
|
+
case "lessons":
|
|
204
|
+
return (await import("./ui/lessons.js")).runLessons(rest);
|
|
124
205
|
case "up":
|
|
125
206
|
return (await import("./attach/session.js")).runUp(rest);
|
|
126
207
|
case "down":
|
|
127
208
|
return (await import("./attach/session.js")).runDown();
|
|
128
209
|
case "peers":
|
|
129
210
|
return (await import("./attach/session.js")).runPeers();
|
|
211
|
+
case "add":
|
|
212
|
+
return (await import("./attach/screen.js")).runAdd(rest);
|
|
213
|
+
case "ctx":
|
|
214
|
+
case "context":
|
|
215
|
+
return (await import("./attach/screen.js")).runCtx(rest);
|
|
130
216
|
case "attach":
|
|
131
217
|
return (await import("./attach/session.js")).runAttach(rest);
|
|
132
218
|
case "land":
|
|
@@ -137,6 +223,11 @@ async function main() {
|
|
|
137
223
|
return (await import("./attach/connect.js")).runDisconnect();
|
|
138
224
|
case "guard":
|
|
139
225
|
return (await import("./attach/guard.js")).runGuard();
|
|
226
|
+
case "mail":
|
|
227
|
+
if (rest.includes("--hook"))
|
|
228
|
+
return (await import("./room/mail.js")).runMailHook(rest);
|
|
229
|
+
console.log(C.red("✗ huddle mail is internal:") + " agents read mail through the Plug (huddle_inbox).");
|
|
230
|
+
process.exit(1);
|
|
140
231
|
case "relay": {
|
|
141
232
|
// Run a relay so rooms work across networks. Host it anywhere Node runs.
|
|
142
233
|
const i = rest.indexOf("--port");
|
|
@@ -145,6 +236,7 @@ async function main() {
|
|
|
145
236
|
console.log(C.red("✗ --port needs a number from 0 to 65535,") + " e.g. huddle relay --port 8080");
|
|
146
237
|
process.exit(1);
|
|
147
238
|
}
|
|
239
|
+
const { startRelay } = await import("./relay/server.js");
|
|
148
240
|
const r = await startRelay(port, (l) => console.log(C.faint(new Date().toISOString().slice(11, 19) + " " + l)));
|
|
149
241
|
console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
|
|
150
242
|
return;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The Plug's shared-screen verbs (src/room/context.ts is the Room side).
|
|
2
|
+
// Kept out of server.ts so the screen is one file to read.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
export function contextTools(room) {
|
|
7
|
+
return [
|
|
8
|
+
{
|
|
9
|
+
name: "huddle_context",
|
|
10
|
+
description: "The SHARED SCREEN: everything the user pasted, dropped or uploaded for the whole team (logs, docs, code, screenshots, PDFs) — one copy in the Room, not in your prompt. Lists each item's id, kind, title, size, summary, sections and the notes pinned on it.",
|
|
11
|
+
inputSchema: { type: "object", properties: {} },
|
|
12
|
+
handler: () => room("/context", "GET"),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "huddle_context_read",
|
|
16
|
+
description: "Read one item from the shared screen. Text comes back in chunks of up to 400 lines: pass from_line/to_line, or section (a number or title from huddle_context). Images and PDFs come back as a file path — open it with your image/file reading tool.",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
id: { type: "string", description: "the item id, e.g. ctx-3" },
|
|
21
|
+
from_line: { type: "number" },
|
|
22
|
+
to_line: { type: "number" },
|
|
23
|
+
section: { type: "string", description: "optional: a section number or title" },
|
|
24
|
+
},
|
|
25
|
+
required: ["id"],
|
|
26
|
+
},
|
|
27
|
+
handler: (a) => room("/context_read", "POST", { id: a.id, from_line: a.from_line, to_line: a.to_line, section: a.section }),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "huddle_context_search",
|
|
31
|
+
description: "Search every text item on the shared screen. Returns matching lines with the item id and line number, so you can huddle_context_read around them.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: { query: { type: "string" }, regex: { type: "boolean", description: "treat query as a regular expression" } },
|
|
35
|
+
required: ["query"],
|
|
36
|
+
},
|
|
37
|
+
handler: (a) => room("/context_search", "POST", { query: a.query, regex: !!a.regex }),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "huddle_context_note",
|
|
41
|
+
description: "Pin a short comment on a shared-screen item — what it means, what you found in it, which part you're handling. Every agent and the user sees it next to the item. This is how the team talks about the same document.",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: { id: { type: "string" }, note: { type: "string" } },
|
|
45
|
+
required: ["id", "note"],
|
|
46
|
+
},
|
|
47
|
+
handler: (a) => room("/context_note", "POST", { id: a.id, note: a.note }),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A Room result as MCP content. Screenshots also go as an image block (hosts
|
|
53
|
+
* that render MCP images show it to the model directly). Bytes sent across
|
|
54
|
+
* machines (a teammate's agent) are saved here and replaced by a local path.
|
|
55
|
+
*/
|
|
56
|
+
export function toContent(result) {
|
|
57
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
58
|
+
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
59
|
+
const { image, file, ...rest } = result;
|
|
60
|
+
if (file?.data_base64 && file?.name) {
|
|
61
|
+
try {
|
|
62
|
+
const dir = path.join(os.tmpdir(), "huddle-context");
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
const p = path.join(dir, String(file.name).replace(/[^\w.-]/g, "_"));
|
|
65
|
+
fs.writeFileSync(p, Buffer.from(String(file.data_base64), "base64"));
|
|
66
|
+
rest.path = p;
|
|
67
|
+
rest.note = `Open this file with your image/file reading tool (e.g. Read ${p}).`;
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
rest.note = `couldn't save the file here: ${e?.message ?? e}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const out = [{ type: "text", text: JSON.stringify(rest, null, 2) }];
|
|
74
|
+
// HUDDLE_CTX_MCP_IMAGES=0: path only, for a host that can't take image blocks.
|
|
75
|
+
if (image?.data_base64 && image?.mime && process.env.HUDDLE_CTX_MCP_IMAGES !== "0")
|
|
76
|
+
out.push({ type: "image", data: String(image.data_base64), mimeType: String(image.mime) });
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=context-tools.js.map
|
package/dist/mcp/server.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
11
|
+
import { contextTools, toContent } from "./context-tools.js";
|
|
12
|
+
import { packageInfo } from "../preflight.js";
|
|
11
13
|
const ROOM = process.env.HUDDLE_ROOM_URL ?? "http://localhost:4737";
|
|
12
14
|
const AGENT = process.env.HUDDLE_AGENT_ID ?? "unknown-agent";
|
|
13
15
|
// Proves to the Room that this call comes from one of this session's own agents.
|
|
@@ -105,11 +107,11 @@ const TOOLS = [
|
|
|
105
107
|
},
|
|
106
108
|
{
|
|
107
109
|
name: "huddle_remember",
|
|
108
|
-
description: "Write to SHARED MEMORY so other agents learn from you. kind = decision | fact | gotcha. Always include 'why' — teammates need the reasoning, not just the what.",
|
|
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').",
|
|
109
111
|
inputSchema: {
|
|
110
112
|
type: "object",
|
|
111
113
|
properties: {
|
|
112
|
-
kind: { type: "string", enum: ["decision", "fact", "gotcha"] },
|
|
114
|
+
kind: { type: "string", enum: ["decision", "fact", "gotcha", "lesson"] },
|
|
113
115
|
body: { type: "string" },
|
|
114
116
|
why: { type: "string" },
|
|
115
117
|
},
|
|
@@ -139,9 +141,27 @@ const TOOLS = [
|
|
|
139
141
|
},
|
|
140
142
|
handler: (a) => room("/ask", "POST", { question: a.question, to: a.to }),
|
|
141
143
|
},
|
|
144
|
+
{
|
|
145
|
+
name: "huddle_ask_user",
|
|
146
|
+
description: "Ask the USER (the person this work is for) to decide something BIG that the goal doesn't settle: a choice that changes what they get " +
|
|
147
|
+
"(scope, a feature in or out, a user-visible behavior, an irreversible step, real money or data). NOT for names, file layout, code style " +
|
|
148
|
+
"or anything you or a teammate can decide — the user isn't an engineer and sees many agents. Give 2-4 short options in plain words " +
|
|
149
|
+
"(no jargon), the one you recommend FIRST; they may also answer in their own words. Waits a short while; with no answer, go with option 1. " +
|
|
150
|
+
"At most 2 per run.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
question: { type: "string", description: "one plain sentence, e.g. 'Should it also convert Rankine?'" },
|
|
155
|
+
options: { type: "array", items: { type: "string" }, description: "2-4 plain-words options, your recommendation first" },
|
|
156
|
+
why: { type: "string", description: "optional: one line on what changes for them either way" },
|
|
157
|
+
},
|
|
158
|
+
required: ["question", "options"],
|
|
159
|
+
},
|
|
160
|
+
handler: (a) => room("/ask_user", "POST", { question: a.question, options: a.options, why: a.why }),
|
|
161
|
+
},
|
|
142
162
|
{
|
|
143
163
|
name: "huddle_send",
|
|
144
|
-
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,
|
|
164
|
+
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.",
|
|
145
165
|
inputSchema: {
|
|
146
166
|
type: "object",
|
|
147
167
|
properties: {
|
|
@@ -154,10 +174,30 @@ const TOOLS = [
|
|
|
154
174
|
handler: (a) => room("/send", "POST", { to: a.to, message: a.message, reply_to: a.reply_to }),
|
|
155
175
|
},
|
|
156
176
|
{
|
|
157
|
-
name: "
|
|
158
|
-
description: "Read
|
|
177
|
+
name: "huddle_contract",
|
|
178
|
+
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.",
|
|
159
179
|
inputSchema: { type: "object", properties: {} },
|
|
160
|
-
handler: () => room("/
|
|
180
|
+
handler: () => room("/contract", "GET"),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
name: "huddle_amend_contract",
|
|
184
|
+
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.",
|
|
185
|
+
inputSchema: {
|
|
186
|
+
type: "object",
|
|
187
|
+
properties: {
|
|
188
|
+
section: { type: "string", description: "what part it changes, e.g. 'store.js exports' or 'error strings'" },
|
|
189
|
+
change: { type: "string", description: "the exact new name/signature/shape" },
|
|
190
|
+
why: { type: "string" },
|
|
191
|
+
},
|
|
192
|
+
required: ["section", "change", "why"],
|
|
193
|
+
},
|
|
194
|
+
handler: (a) => room("/amend_contract", "POST", { section: a.section, change: a.change, why: a.why }),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: "huddle_inbox",
|
|
198
|
+
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=…).",
|
|
199
|
+
inputSchema: { type: "object", properties: { id: { type: "string", description: "optional: one message's id" } } },
|
|
200
|
+
handler: (a) => room(a?.id ? `/inbox?id=${encodeURIComponent(String(a.id))}` : "/inbox", "GET"),
|
|
161
201
|
},
|
|
162
202
|
{
|
|
163
203
|
name: "huddle_report_bug",
|
|
@@ -196,7 +236,9 @@ const TOOLS = [
|
|
|
196
236
|
},
|
|
197
237
|
{
|
|
198
238
|
name: "huddle_done",
|
|
199
|
-
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."
|
|
239
|
+
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. " +
|
|
240
|
+
"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 " +
|
|
241
|
+
"huddle_send(to=<who sent it>, reply_to=<its id>, message=\"won't fix: <why>\"), then call huddle_done again.",
|
|
200
242
|
inputSchema: {
|
|
201
243
|
type: "object",
|
|
202
244
|
properties: { task_id: { type: "string" }, summary: { type: "string" } },
|
|
@@ -204,6 +246,7 @@ const TOOLS = [
|
|
|
204
246
|
},
|
|
205
247
|
handler: (a) => room("/done", "POST", { task_id: a.task_id, summary: a.summary }),
|
|
206
248
|
},
|
|
249
|
+
...contextTools(room),
|
|
207
250
|
];
|
|
208
251
|
/**
|
|
209
252
|
* What an MCP host shows the model about the Plug as a whole: the verbs by
|
|
@@ -212,10 +255,13 @@ const TOOLS = [
|
|
|
212
255
|
*/
|
|
213
256
|
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.
|
|
214
257
|
- 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.
|
|
215
|
-
- Before editing: huddle_announce_plan (what you will touch), huddle_check_overlap (is someone else in that file).
|
|
216
|
-
-
|
|
258
|
+
- 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).
|
|
259
|
+
- 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.
|
|
260
|
+
- The user: messages whose sender is "the user" come from the person this work is for — they win over the plan and the contract. A big decision the goal doesn't settle (scope, user-visible behavior, anything irreversible): huddle_ask_user with 2-4 plain options, your pick first. Never for names or code style.
|
|
261
|
+
- 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).
|
|
217
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.
|
|
218
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
|
+
- 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.
|
|
219
265
|
- Finish: huddle_done with a short summary. Huddle tests and merges it.`;
|
|
220
266
|
/** The Plug's verb names, in the order listed to agents. */
|
|
221
267
|
export const PLUG_VERBS = TOOLS.map((t) => t.name);
|
|
@@ -223,7 +269,7 @@ export async function runPlug() {
|
|
|
223
269
|
// Attach mode (`huddle connect`): a session you started yourself, no scheduler.
|
|
224
270
|
if (process.env.HUDDLE_LIVE)
|
|
225
271
|
return (await import("../attach/plug.js")).runLivePlug();
|
|
226
|
-
const server = new Server({ name: "huddle", version:
|
|
272
|
+
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: PLUG_INSTRUCTIONS });
|
|
227
273
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
228
274
|
tools: TOOLS.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
|
|
229
275
|
}));
|
|
@@ -232,7 +278,7 @@ export async function runPlug() {
|
|
|
232
278
|
if (!tool)
|
|
233
279
|
throw new Error(`unknown tool: ${req.params.name}`);
|
|
234
280
|
const result = await tool.handler((req.params.arguments ?? {}));
|
|
235
|
-
return { content:
|
|
281
|
+
return { content: toContent(result) };
|
|
236
282
|
});
|
|
237
283
|
const transport = new StdioServerTransport();
|
|
238
284
|
await server.connect(transport);
|
package/dist/orchestrator/ask.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// One-shot, read-only questions to an agent: answering "where are we with
|
|
2
2
|
// this project?" and planning a goal. Runs in the repo itself (no worktree),
|
|
3
3
|
// with each vendor's read-only / plan mode, so nothing can be edited.
|
|
4
|
-
import { spawn } from "node:child_process";
|
|
5
4
|
import { randomUUID } from "node:crypto";
|
|
6
5
|
import fs from "node:fs";
|
|
7
6
|
import os from "node:os";
|
|
8
7
|
import path from "node:path";
|
|
8
|
+
import { stopChild } from "./launcher.js";
|
|
9
|
+
import { onFinished, spawnTool } from "../platform.js";
|
|
9
10
|
/** Preference order for who answers questions and plans. */
|
|
10
11
|
const ASK_ORDER = ["claude", "codex", "antigravity", "cursor", "gemini"];
|
|
11
12
|
export function pickAsker(ready) {
|
|
@@ -16,21 +17,20 @@ export function askAgent(adapter, prompt, cwd, opts = {}) {
|
|
|
16
17
|
const outFile = path.join(os.tmpdir(), `huddle-ask-${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}.txt`);
|
|
17
18
|
const spec = adapter.ask({ prompt, cwd, outFile, model: opts.model, noTools: opts.noTools, quick: opts.quick });
|
|
18
19
|
return new Promise((resolve) => {
|
|
19
|
-
const child =
|
|
20
|
+
const child = spawnTool(spec.cmd, { cwd: spec.cwd, stdio: ["ignore", "pipe", "pipe"] }, { prompt, stdinPrompt: adapter.stdinPrompt });
|
|
20
21
|
let out = "";
|
|
21
22
|
let err = "";
|
|
22
23
|
child.stdout.on("data", (d) => (out += d));
|
|
23
24
|
child.stderr.on("data", (d) => (err += d));
|
|
24
25
|
// SIGKILL after a grace period: a stalled CLI can ignore SIGTERM (seen live with cursor-agent).
|
|
25
|
-
const stop = () =>
|
|
26
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
27
|
-
return;
|
|
28
|
-
child.kill("SIGTERM");
|
|
29
|
-
setTimeout(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"), 5000).unref();
|
|
30
|
-
};
|
|
26
|
+
const stop = () => stopChild(child);
|
|
31
27
|
const timer = setTimeout(stop, opts.timeoutMs ?? 5 * 60_000);
|
|
32
28
|
opts.signal?.addEventListener("abort", stop);
|
|
29
|
+
let over = false;
|
|
33
30
|
const finish = (code) => {
|
|
31
|
+
if (over)
|
|
32
|
+
return;
|
|
33
|
+
over = true;
|
|
34
34
|
clearTimeout(timer);
|
|
35
35
|
let text = out;
|
|
36
36
|
if (spec.usesOutFile) {
|
|
@@ -44,8 +44,11 @@ export function askAgent(adapter, prompt, cwd, opts = {}) {
|
|
|
44
44
|
}
|
|
45
45
|
resolve({ ok: code === 0 && text.trim().length > 0, text: text.trim() || err.trim() });
|
|
46
46
|
};
|
|
47
|
-
|
|
48
|
-
|
|
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);
|
|
49
52
|
});
|
|
50
53
|
}
|
|
51
54
|
//# sourceMappingURL=ask.js.map
|
|
@@ -15,6 +15,8 @@ import fs from "node:fs";
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { ADAPTERS } from "../adapters/index.js";
|
|
17
17
|
import { askAgent } from "./ask.js";
|
|
18
|
+
import { lessonsBrief } from "../room/lessons.js";
|
|
19
|
+
import { screenForPlanner } from "../room/context.js";
|
|
18
20
|
const OLLAMA = process.env.OLLAMA_HOST?.replace(/\/$/, "") || "http://127.0.0.1:11434";
|
|
19
21
|
/** Models a local Ollama has pulled, or [] if Ollama isn't running. */
|
|
20
22
|
export async function ollamaModels() {
|
|
@@ -100,8 +102,8 @@ export async function brainOptions(readyAgents) {
|
|
|
100
102
|
out.push({ spec: "cursor:grok-4.7-low-fast", note: "cheap + fast, via your Cursor plan" });
|
|
101
103
|
if (readyAgents.includes("claude")) {
|
|
102
104
|
out.push({ spec: "claude:haiku", note: "smallest Claude — cheapest hosted option" });
|
|
103
|
-
out.push({ spec: "claude:sonnet", note: "
|
|
104
|
-
out.push({ spec: "claude", note: "
|
|
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" });
|
|
105
107
|
}
|
|
106
108
|
if (readyAgents.includes("codex"))
|
|
107
109
|
out.push({ spec: "codex", note: "your Codex default model" });
|
|
@@ -112,7 +114,7 @@ export async function brainOptions(readyAgents) {
|
|
|
112
114
|
* manifest. ~a few thousand tokens; lets even a local model plan sensibly.
|
|
113
115
|
*/
|
|
114
116
|
export function repoContext(cwd, maxFiles = 250) {
|
|
115
|
-
const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split(
|
|
117
|
+
const files = (spawnSync("git", ["ls-files"], { cwd, encoding: "utf8" }).stdout ?? "").split(/\r?\n/).filter(Boolean);
|
|
116
118
|
const head = (f, lines) => {
|
|
117
119
|
try {
|
|
118
120
|
return fs.readFileSync(path.join(cwd, f), "utf8").split("\n").slice(0, lines).join("\n");
|
|
@@ -121,7 +123,9 @@ export function repoContext(cwd, maxFiles = 250) {
|
|
|
121
123
|
return null;
|
|
122
124
|
}
|
|
123
125
|
};
|
|
124
|
-
|
|
126
|
+
// Found live (0.1.2): agents set "engines": {"node": ">=22"} on a machine with Node 20.
|
|
127
|
+
const machine = `MACHINE: ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, Node ${process.version}. Target what runs here: never require a newer Node (engines, syntax, built-in APIs) than this.`;
|
|
128
|
+
const parts = [machine, `FILES (${files.length}${files.length > maxFiles ? `, first ${maxFiles}` : ""}):\n${files.slice(0, maxFiles).join("\n") || "(empty repo)"}`];
|
|
125
129
|
for (const f of ["package.json", "pyproject.toml", "Cargo.toml", "go.mod"]) {
|
|
126
130
|
const h = head(f, 40);
|
|
127
131
|
if (h)
|
|
@@ -159,6 +163,14 @@ export function repoContext(cwd, maxFiles = 250) {
|
|
|
159
163
|
parts.push(`${f}:\n${body.trimEnd()}`);
|
|
160
164
|
}
|
|
161
165
|
}
|
|
166
|
+
// The planner and the council plan from this snapshot: what earlier runs taught goes with it.
|
|
167
|
+
const lessons = lessonsBrief({ repo: cwd }).trim();
|
|
168
|
+
if (lessons)
|
|
169
|
+
parts.push(lessons);
|
|
170
|
+
// What the user pasted / dropped: the index (+ small items), not N full copies.
|
|
171
|
+
const screen = screenForPlanner(cwd);
|
|
172
|
+
if (screen)
|
|
173
|
+
parts.push(screen);
|
|
162
174
|
return parts.join("\n\n");
|
|
163
175
|
}
|
|
164
176
|
//# sourceMappingURL=brain.js.map
|