huddle-cli 0.1.1 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/QUICKSTART.md +63 -2
- package/README.md +15 -0
- package/dist/adapters/codex.js +3 -0
- package/dist/app/live.js +142 -0
- package/dist/app/page.js +775 -0
- package/dist/app/qr.js +47 -0
- package/dist/app/server.js +433 -0
- package/dist/args.js +18 -0
- package/dist/attach/connect.js +3 -3
- package/dist/attach/plug.js +8 -1
- package/dist/cli.js +46 -4
- package/dist/connectors/cli.js +121 -0
- package/dist/connectors/github.js +162 -0
- package/dist/connectors/hub.js +234 -0
- package/dist/connectors/types.js +3 -0
- package/dist/mcp/context-tools.js +2 -0
- package/dist/mcp/server.js +42 -3
- package/dist/orchestrator/brain.js +3 -1
- package/dist/orchestrator/budget.js +283 -0
- package/dist/orchestrator/busy.js +106 -0
- package/dist/orchestrator/council.js +11 -5
- package/dist/orchestrator/launcher.js +3 -1
- package/dist/orchestrator/learn.js +3 -7
- package/dist/orchestrator/planner.js +32 -5
- package/dist/orchestrator/qa.js +9 -1
- package/dist/orchestrator/scheduler.js +105 -19
- package/dist/platform.js +104 -0
- package/dist/room/mail.js +19 -6
- package/dist/room/server.js +82 -2
- package/dist/room/user.js +54 -0
- package/dist/ui/app.js +511 -69
- package/dist/ui/brief.js +494 -0
- package/dist/ui/catalog.js +1 -1
- package/dist/ui/dashboard.js +92 -21
- package/dist/ui/export.js +1194 -0
- package/dist/ui/narrator.js +56 -0
- package/dist/ui/story.js +118 -93
- package/dist/ui/talk.js +176 -0
- package/dist/ui/term.js +31 -0
- package/dist/ui/transcript.js +434 -0
- package/dist/ui/undo.js +131 -0
- package/dist/ui/watch.js +4 -0
- package/dist/update.js +84 -0
- package/package.json +3 -1
package/dist/app/qr.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// A QR code in the terminal, for `huddle app --phone`: point the phone's
|
|
2
|
+
// camera at it instead of typing a 70-character link.
|
|
3
|
+
//
|
|
4
|
+
// The encoder is the one qrcode-terminal ships (Kazuhiko Arase's, MIT); the
|
|
5
|
+
// drawing is ours: two rows of the code per line of text (▀ with a top and a
|
|
6
|
+
// bottom color), so a phone link fits in about 20 lines. In color it paints
|
|
7
|
+
// black on bright white itself, so it scans on a light or a dark terminal.
|
|
8
|
+
// Without color it draws the light parts with blocks, which reads right on a
|
|
9
|
+
// dark terminal (phone cameras also read the inverted code a light one shows).
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
const QRCode = require("qrcode-terminal/vendor/QRCode/index.js");
|
|
13
|
+
const LEVEL = require("qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js");
|
|
14
|
+
/** Light space around the code: scanners need some to find its corners. */
|
|
15
|
+
export const QUIET = 2;
|
|
16
|
+
/** The code's modules, true = dark, with the quiet zone around them. */
|
|
17
|
+
export function qrMatrix(text, level = "L") {
|
|
18
|
+
const q = new QRCode(-1, LEVEL[level]); // -1: the smallest size that fits
|
|
19
|
+
q.addData(text);
|
|
20
|
+
q.make();
|
|
21
|
+
const n = q.getModuleCount();
|
|
22
|
+
const size = n + QUIET * 2;
|
|
23
|
+
return Array.from({ length: size }, (_, r) => Array.from({ length: size }, (_, c) => {
|
|
24
|
+
const y = r - QUIET;
|
|
25
|
+
const x = c - QUIET;
|
|
26
|
+
return y >= 0 && x >= 0 && y < n && x < n && q.isDark(y, x);
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/** The code as lines of text, two rows per line. */
|
|
30
|
+
export function qrLines(text, opts) {
|
|
31
|
+
const m = qrMatrix(text);
|
|
32
|
+
const lines = [];
|
|
33
|
+
for (let r = 0; r < m.length; r += 2) {
|
|
34
|
+
let line = "";
|
|
35
|
+
for (let c = 0; c < m.length; c++) {
|
|
36
|
+
const top = m[r][c];
|
|
37
|
+
const bottom = r + 1 < m.length ? m[r + 1][c] : false;
|
|
38
|
+
if (opts.color)
|
|
39
|
+
line += `\x1b[${top ? 30 : 97};${bottom ? 40 : 107}m▀`;
|
|
40
|
+
else
|
|
41
|
+
line += !top && !bottom ? "█" : !top ? "▀" : !bottom ? "▄" : " ";
|
|
42
|
+
}
|
|
43
|
+
lines.push(opts.color ? line + "\x1b[0m" : line);
|
|
44
|
+
}
|
|
45
|
+
return lines;
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=qr.js.map
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// `huddle app` — the dashboard: a local web page for a repo's runs.
|
|
2
|
+
//
|
|
3
|
+
// It shows the run going on now (from .huddle/live.json, whoever started
|
|
4
|
+
// it), every past run as a conversation (the same records `huddle export`
|
|
5
|
+
// reads), and lets you start a run, answer an agent's question, talk to the
|
|
6
|
+
// agents and stop them (through .huddle/inbox/, app/live.ts).
|
|
7
|
+
//
|
|
8
|
+
// Local only: it listens on 127.0.0.1, every API call needs the token from
|
|
9
|
+
// the URL it prints, and a Host other than localhost is refused (so a web
|
|
10
|
+
// page you visit can't reach it through DNS rebinding).
|
|
11
|
+
//
|
|
12
|
+
// `--phone` opens it to your Wi-Fi too, to run things from your phone: it
|
|
13
|
+
// also listens on this machine's network addresses, and accepts those as the
|
|
14
|
+
// Host (plus the tunnel's name with `--anywhere`). The token is still needed
|
|
15
|
+
// for everything, and it's longer then, since the network can see the door.
|
|
16
|
+
import { spawn } from "node:child_process";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { ADAPTERS, LEGACY, applySkips, probeAsync } from "../adapters/index.js";
|
|
24
|
+
import { RoomDB } from "../room/db.js";
|
|
25
|
+
import { splitRuns } from "../ui/story.js";
|
|
26
|
+
import { buildExport, findRun, renderExport, runRows, EXPORT_FORMATS, EXPORT_LEVELS } from "../ui/export.js";
|
|
27
|
+
import { packageInfo } from "../preflight.js";
|
|
28
|
+
import { readConfig, githubReady } from "../connectors/hub.js";
|
|
29
|
+
import { runAsync } from "../connectors/github.js";
|
|
30
|
+
import { undoPlan, undoRun } from "../ui/undo.js";
|
|
31
|
+
import { clearAppInfo, postCommand, readLive, readReport, writeAppInfo } from "./live.js";
|
|
32
|
+
import { appPage } from "./page.js";
|
|
33
|
+
import { qrLines } from "./qr.js";
|
|
34
|
+
import { C } from "../ui/term.js";
|
|
35
|
+
const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
36
|
+
/**
|
|
37
|
+
* This machine's IPv4 addresses on its networks, the likeliest one for a
|
|
38
|
+
* phone first: Wi-Fi and Ethernet before VPNs and virtual machines' bridges
|
|
39
|
+
* (a Mac running a VM has a 192.168.x bridge no phone can reach).
|
|
40
|
+
*/
|
|
41
|
+
export function lanAddresses() {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const [name, list] of Object.entries(os.networkInterfaces())) {
|
|
44
|
+
const rank = /^(en|eth|wl|wlan|wi-?fi|ethernet)/i.test(name) ? 0 : /^(bridge|vmnet|vboxnet|virbr|docker|br-|veth|utun|tun|tap|tailscale|zt|ham|vethernet)/i.test(name) ? 2 : 1;
|
|
45
|
+
for (const a of list ?? []) {
|
|
46
|
+
if ((a.family === "IPv4" || a.family === 4) && !a.internal && !a.address.startsWith("169.254."))
|
|
47
|
+
out.push({ ip: a.address, rank });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const home = (ip) => /^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip);
|
|
51
|
+
out.sort((a, b) => a.rank - b.rank || Number(home(b.ip)) - Number(home(a.ip)));
|
|
52
|
+
return [...new Set(out.map((a) => a.ip))];
|
|
53
|
+
}
|
|
54
|
+
const json = (res, status, body) => {
|
|
55
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
56
|
+
res.end(JSON.stringify(body));
|
|
57
|
+
};
|
|
58
|
+
function readBody(req, max = 200_000) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
let raw = "";
|
|
61
|
+
req.on("data", (d) => {
|
|
62
|
+
raw += d;
|
|
63
|
+
if (raw.length > max)
|
|
64
|
+
reject(new Error("too large"));
|
|
65
|
+
});
|
|
66
|
+
req.on("end", () => {
|
|
67
|
+
try {
|
|
68
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
reject(new Error("bad json"));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
req.on("error", reject);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** The default way a run starts from the dashboard: its own process, so closing the page (or the app) doesn't stop it. */
|
|
78
|
+
function spawnRun(root, goal) {
|
|
79
|
+
const logs = path.join(root, ".huddle", "logs");
|
|
80
|
+
fs.mkdirSync(logs, { recursive: true });
|
|
81
|
+
const fd = fs.openSync(path.join(logs, `app-run-${new Date().toISOString().replace(/[:.]/g, "-")}.log`), "a");
|
|
82
|
+
const child = spawn(process.execPath, [CLI, "join", goal], {
|
|
83
|
+
cwd: root,
|
|
84
|
+
detached: true,
|
|
85
|
+
stdio: ["ignore", fd, fd],
|
|
86
|
+
env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
|
|
87
|
+
windowsHide: true,
|
|
88
|
+
});
|
|
89
|
+
child.unref();
|
|
90
|
+
fs.closeSync(fd);
|
|
91
|
+
}
|
|
92
|
+
/** Every agent Huddle knows, probed quietly (no spinner rows): who's ready, signed out, skipped. */
|
|
93
|
+
async function probeAgents() {
|
|
94
|
+
const { skippedAgents } = await import("../ui/app.js");
|
|
95
|
+
const all = applySkips(await Promise.all(ADAPTERS.map((a) => probeAsync(a))), skippedAgents());
|
|
96
|
+
return all.filter((r) => !LEGACY.has(r.adapter.id) || r.state === "ready");
|
|
97
|
+
}
|
|
98
|
+
export async function startApp(opts) {
|
|
99
|
+
const root = opts.root;
|
|
100
|
+
const dbFile = path.join(root, ".huddle", "huddle.db");
|
|
101
|
+
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
102
|
+
const db = new RoomDB(dbFile);
|
|
103
|
+
// On the network, a longer key: 192 bits, still short enough for a small QR code.
|
|
104
|
+
const token = crypto.randomBytes(opts.phone ? 24 : 18).toString("base64url");
|
|
105
|
+
const tokenBuf = Buffer.from(token);
|
|
106
|
+
const keyOk = (given) => typeof given === "string" && given.length === token.length && crypto.timingSafeEqual(Buffer.from(given), tokenBuf);
|
|
107
|
+
const addresses = opts.addresses ?? lanAddresses;
|
|
108
|
+
const extraHosts = new Set();
|
|
109
|
+
const startRun = opts.startRun ?? ((goal) => spawnRun(root, goal));
|
|
110
|
+
let agents = opts.agents ?? null;
|
|
111
|
+
let probing = null;
|
|
112
|
+
const probe = () => {
|
|
113
|
+
probing ??= probeAgents()
|
|
114
|
+
.then((r) => void (agents = r))
|
|
115
|
+
.catch(() => void (agents = []))
|
|
116
|
+
.finally(() => (probing = null));
|
|
117
|
+
return probing;
|
|
118
|
+
};
|
|
119
|
+
if (!agents)
|
|
120
|
+
void probe();
|
|
121
|
+
const runsNow = () => splitRuns(db.allEvents());
|
|
122
|
+
/** Changes whenever the run or the history does: the page refetches on it. */
|
|
123
|
+
const signature = () => {
|
|
124
|
+
let live = 0;
|
|
125
|
+
try {
|
|
126
|
+
live = fs.statSync(path.join(root, ".huddle", "live.json")).mtimeMs;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* no run yet */
|
|
130
|
+
}
|
|
131
|
+
const last = db.recentEvents(1)[0];
|
|
132
|
+
return `${live}:${last?.id ?? ""}`;
|
|
133
|
+
};
|
|
134
|
+
const state = () => {
|
|
135
|
+
const runs = runsNow();
|
|
136
|
+
const live = readLive(root);
|
|
137
|
+
return {
|
|
138
|
+
version: packageInfo().version,
|
|
139
|
+
repo: { name: path.basename(root), root },
|
|
140
|
+
agents: (agents ?? []).map((r) => ({ id: r.adapter.id, vendor: r.adapter.vendor, state: r.state, detail: r.detail ?? "" })),
|
|
141
|
+
probing: !agents,
|
|
142
|
+
live,
|
|
143
|
+
runs: runRows(runs).reverse(),
|
|
144
|
+
// Changes when a run is undone (from here or `huddle undo`), so its view refetches.
|
|
145
|
+
undone: runs.reduce((n, r) => n + r.events.filter((e) => e.type === "git.undone").length, 0),
|
|
146
|
+
connectors: connectorsNow(),
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
/** What's configured, from ~/.huddle/connectors.json: shown, not started. */
|
|
150
|
+
const connectorsNow = () => {
|
|
151
|
+
const cfg = readConfig();
|
|
152
|
+
return {
|
|
153
|
+
github: cfg.github?.off ? "off" : cfg.github?.write ? "write" : "read",
|
|
154
|
+
others: Object.entries(cfg.connectors ?? {})
|
|
155
|
+
.filter(([, c]) => !c?.off)
|
|
156
|
+
.map(([name, c]) => ({ name, write: !!c?.write })),
|
|
157
|
+
};
|
|
158
|
+
};
|
|
159
|
+
// Open issues for the start screen. gh is slow (a network call), so the
|
|
160
|
+
// answer is kept a minute, and every page asking at once shares one call.
|
|
161
|
+
let github = null;
|
|
162
|
+
const githubView = () => {
|
|
163
|
+
if (github && Date.now() - github.at < 60_000)
|
|
164
|
+
return github.view;
|
|
165
|
+
const view = (async () => {
|
|
166
|
+
if (readConfig().github?.off)
|
|
167
|
+
return { ready: false, note: "GitHub is off (huddle connectors github on)", issues: [] };
|
|
168
|
+
const g = await githubReady(root);
|
|
169
|
+
if (!g.ok)
|
|
170
|
+
return { ready: false, note: g.note, issues: [] };
|
|
171
|
+
const r = await runAsync(["gh", "issue", "list", "--state", "open", "--limit", "20", "--json", "number,title"], { cwd: root, timeoutMs: 20_000 });
|
|
172
|
+
if (r.code !== 0)
|
|
173
|
+
return { ready: false, note: (r.stderr || r.stdout).trim().split("\n")[0].slice(0, 160) || "gh failed", issues: [] };
|
|
174
|
+
try {
|
|
175
|
+
const list = JSON.parse(r.stdout);
|
|
176
|
+
const issues = list
|
|
177
|
+
.map((i) => ({ number: Number(i.number), title: String(i.title ?? "").slice(0, 200) }))
|
|
178
|
+
.filter((i) => Number.isInteger(i.number) && i.number > 0)
|
|
179
|
+
.slice(0, 20);
|
|
180
|
+
return { ready: true, note: g.note, issues };
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { ready: false, note: "couldn't read gh's answer", issues: [] };
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
github = { at: Date.now(), view };
|
|
187
|
+
return view;
|
|
188
|
+
};
|
|
189
|
+
const runDoc = (which) => {
|
|
190
|
+
const runs = runsNow();
|
|
191
|
+
const run = findRun(runs, which ?? undefined);
|
|
192
|
+
if (!run)
|
|
193
|
+
return null;
|
|
194
|
+
const doc = buildExport(run, runs.length, { level: "chat", logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
|
|
195
|
+
const made = run.events.find((e) => e.type === "plan.made");
|
|
196
|
+
const plain = made?.p?.plain && typeof made.p.plain === "object" ? made.p.plain : null;
|
|
197
|
+
// Whether this run can be taken back, and what that would revert (ui/undo.ts).
|
|
198
|
+
const undo = undoPlan(root, db.allEvents(), String(run.index));
|
|
199
|
+
return { ...doc, plain, report: readReport(root, run.id), undo: { ok: undo.ok, why: undo.why ?? "", commits: undo.commits ?? [], end: undo.end ?? "" } };
|
|
200
|
+
};
|
|
201
|
+
const allowedHost = (h, port) => {
|
|
202
|
+
if (h === `127.0.0.1:${port}` || h === `localhost:${port}`)
|
|
203
|
+
return true;
|
|
204
|
+
if (!opts.phone || !h)
|
|
205
|
+
return false;
|
|
206
|
+
// Checked on every request: the Wi-Fi can change while the dashboard is open.
|
|
207
|
+
return extraHosts.has(h.toLowerCase()) || addresses().some((ip) => h === `${ip}:${port}`);
|
|
208
|
+
};
|
|
209
|
+
const server = http.createServer(async (req, res) => {
|
|
210
|
+
const url = new URL(req.url ?? "/", "http://local");
|
|
211
|
+
const port = server.address().port;
|
|
212
|
+
if (!allowedHost(req.headers.host, port))
|
|
213
|
+
return json(res, 403, { error: "local only" });
|
|
214
|
+
try {
|
|
215
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
216
|
+
if (!keyOk(url.searchParams.get("t"))) {
|
|
217
|
+
res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
|
|
218
|
+
return res.end("Open the link `huddle app` printed (it carries a key for this session).");
|
|
219
|
+
}
|
|
220
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
|
|
221
|
+
return res.end(appPage(token));
|
|
222
|
+
}
|
|
223
|
+
if (!url.pathname.startsWith("/api/"))
|
|
224
|
+
return json(res, 404, { error: "not found" });
|
|
225
|
+
// The token: a header for fetches, ?t= for the event stream and downloads.
|
|
226
|
+
const given = req.headers["x-huddle-token"] ?? url.searchParams.get("t");
|
|
227
|
+
if (!keyOk(given))
|
|
228
|
+
return json(res, 401, { error: "no key" });
|
|
229
|
+
const route = `${req.method} ${url.pathname}`;
|
|
230
|
+
if (route === "GET /api/state")
|
|
231
|
+
return json(res, 200, state());
|
|
232
|
+
if (route === "GET /api/run") {
|
|
233
|
+
const doc = runDoc(url.searchParams.get("n"));
|
|
234
|
+
return doc ? json(res, 200, doc) : json(res, 404, { error: "no such run" });
|
|
235
|
+
}
|
|
236
|
+
if (route === "GET /api/export") {
|
|
237
|
+
const runs = runsNow();
|
|
238
|
+
const run = findRun(runs, url.searchParams.get("n") ?? undefined);
|
|
239
|
+
if (!run)
|
|
240
|
+
return json(res, 404, { error: "no such run" });
|
|
241
|
+
const format = EXPORT_FORMATS.includes(url.searchParams.get("format") ?? "") ? url.searchParams.get("format") : "html";
|
|
242
|
+
const level = EXPORT_LEVELS.includes(url.searchParams.get("level") ?? "") ? url.searchParams.get("level") : "chat";
|
|
243
|
+
const doc = buildExport(run, runs.length, { level, logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
|
|
244
|
+
const type = format === "json" ? "application/json" : format === "html" ? "text/html" : "text/markdown";
|
|
245
|
+
res.writeHead(200, {
|
|
246
|
+
"content-type": `${type}; charset=utf-8`,
|
|
247
|
+
"content-disposition": `attachment; filename="huddle-run-${run.index}-${level}.${format}"`,
|
|
248
|
+
});
|
|
249
|
+
return res.end(renderExport(doc, format));
|
|
250
|
+
}
|
|
251
|
+
if (route === "GET /api/events") {
|
|
252
|
+
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
|
|
253
|
+
let last = "";
|
|
254
|
+
const tick = () => {
|
|
255
|
+
const sig = signature();
|
|
256
|
+
if (sig !== last)
|
|
257
|
+
res.write(`data: ${JSON.stringify({ sig: (last = sig) })}\n\n`);
|
|
258
|
+
else
|
|
259
|
+
res.write(": ping\n\n");
|
|
260
|
+
};
|
|
261
|
+
tick();
|
|
262
|
+
const t = setInterval(tick, 1000);
|
|
263
|
+
req.on("close", () => clearInterval(t));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (route === "GET /api/github")
|
|
267
|
+
return json(res, 200, await githubView());
|
|
268
|
+
if (route === "POST /api/undo") {
|
|
269
|
+
const body = await readBody(req);
|
|
270
|
+
// Never while a run is going: it's merging onto the branch this would revert on.
|
|
271
|
+
if (readLive(root))
|
|
272
|
+
return json(res, 409, { error: "a run is going — undo once it's finished" });
|
|
273
|
+
const plan = undoPlan(root, db.allEvents(), String(body.n ?? ""));
|
|
274
|
+
if (!plan.ok)
|
|
275
|
+
return json(res, 409, { error: plan.why });
|
|
276
|
+
// The commits you were shown are the ones reverted, or nothing is.
|
|
277
|
+
if (body.end && body.end !== plan.end)
|
|
278
|
+
return json(res, 409, { error: "this run changed since you looked — look again" });
|
|
279
|
+
const done = undoRun(root, db, plan);
|
|
280
|
+
return done.ok ? json(res, 200, done) : json(res, 409, { error: done.why });
|
|
281
|
+
}
|
|
282
|
+
if (route === "POST /api/agents") {
|
|
283
|
+
agents = null;
|
|
284
|
+
await probe();
|
|
285
|
+
return json(res, 200, state());
|
|
286
|
+
}
|
|
287
|
+
if (route === "POST /api/goal") {
|
|
288
|
+
const body = await readBody(req);
|
|
289
|
+
const goal = String(body.goal ?? "").trim();
|
|
290
|
+
if (!goal)
|
|
291
|
+
return json(res, 400, { error: "say what to build" });
|
|
292
|
+
if (readLive(root))
|
|
293
|
+
return json(res, 409, { error: "a run is already going — talk to it, or stop it first" });
|
|
294
|
+
startRun(goal);
|
|
295
|
+
return json(res, 200, { ok: true });
|
|
296
|
+
}
|
|
297
|
+
if (route === "POST /api/say" || route === "POST /api/answer" || route === "POST /api/stop") {
|
|
298
|
+
const body = await readBody(req);
|
|
299
|
+
if (!readLive(root))
|
|
300
|
+
return json(res, 409, { error: "nothing is running" });
|
|
301
|
+
const c = route === "POST /api/stop"
|
|
302
|
+
? { type: "stop" }
|
|
303
|
+
: route === "POST /api/say"
|
|
304
|
+
? { type: "say", text: String(body.text ?? "").slice(0, 4000) }
|
|
305
|
+
: { type: "answer", id: String(body.id ?? ""), line: String(body.line ?? "").slice(0, 2000) };
|
|
306
|
+
if (c.type === "say" && !c.text.trim())
|
|
307
|
+
return json(res, 400, { error: "empty" });
|
|
308
|
+
postCommand(root, c);
|
|
309
|
+
return json(res, 200, { ok: true });
|
|
310
|
+
}
|
|
311
|
+
return json(res, 404, { error: "not found" });
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
return json(res, 500, { error: e?.message ?? String(e) });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
const port = await new Promise((resolve, reject) => {
|
|
318
|
+
server.once("error", reject);
|
|
319
|
+
server.listen(opts.port ?? 0, opts.phone ? "0.0.0.0" : "127.0.0.1", () => resolve(server.address().port));
|
|
320
|
+
});
|
|
321
|
+
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
322
|
+
writeAppInfo(root, { pid: process.pid, url });
|
|
323
|
+
const close = () => {
|
|
324
|
+
server.closeAllConnections?.();
|
|
325
|
+
server.close();
|
|
326
|
+
clearAppInfo(root);
|
|
327
|
+
db.close();
|
|
328
|
+
};
|
|
329
|
+
const lanUrls = () => (opts.phone ? addresses().map((ip) => `http://${ip}:${port}/?t=${token}`) : []);
|
|
330
|
+
const allowHost = (h) => void extraHosts.add(h.toLowerCase());
|
|
331
|
+
return { url, port, token, lanUrls, allowHost, close };
|
|
332
|
+
}
|
|
333
|
+
/** `huddle app [--port n] [--no-open]` */
|
|
334
|
+
export async function runApp(args) {
|
|
335
|
+
// Found live (0.1.5): `huddle app --help` opened a dashboard. A flag it doesn't know prints this instead.
|
|
336
|
+
const known = new Set(["--port", "--no-open", "--phone", "--anywhere"]);
|
|
337
|
+
const odd = args.find((a, k) => a.startsWith("-") && !known.has(a) && args[k - 1] !== "--port");
|
|
338
|
+
if (odd) {
|
|
339
|
+
console.log(`${C.bold("huddle app")} the dashboard in your browser, for the repo you're in
|
|
340
|
+
--phone open it on your phone too (same Wi-Fi): scan the QR code
|
|
341
|
+
--anywhere on your phone from any network (needs cloudflared)
|
|
342
|
+
--no-open don't open the browser --port <n> pick the port`);
|
|
343
|
+
if (odd !== "--help" && odd !== "-h") {
|
|
344
|
+
console.log(C.red(`✗ unknown: ${odd}`));
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
}
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const { isGitRepo } = await import("../sandbox/worktree.js");
|
|
350
|
+
const { ensureInit, repoRoot } = await import("../ui/app.js");
|
|
351
|
+
if (!isGitRepo(process.cwd()) || !ensureInit()) {
|
|
352
|
+
console.log(C.red("✗ Not a git repo.") + " Run `huddle app` inside a project (`git init` makes one).");
|
|
353
|
+
process.exitCode = 1;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const i = args.indexOf("--port");
|
|
357
|
+
const port = i >= 0 ? Number(args[i + 1]) : undefined;
|
|
358
|
+
const anywhere = args.includes("--anywhere");
|
|
359
|
+
const phone = anywhere || args.includes("--phone");
|
|
360
|
+
const app = await startApp({ root: repoRoot(), port, phone });
|
|
361
|
+
console.log(`${C.green("✓")} Huddle is open at ${C.bold(app.url)}`);
|
|
362
|
+
console.log(C.faint(" runs keep going if you close the page · ctrl-c closes the dashboard"));
|
|
363
|
+
let tunnel = null;
|
|
364
|
+
if (phone) {
|
|
365
|
+
let link = app.lanUrls()[0] ?? null;
|
|
366
|
+
let how = "on the same Wi-Fi as this computer";
|
|
367
|
+
if (anywhere) {
|
|
368
|
+
const { hasCloudflared, openTunnel } = await import("../room/tunnel.js");
|
|
369
|
+
if (!hasCloudflared()) {
|
|
370
|
+
console.log(` ${C.yellow("!")} --anywhere needs cloudflared (free, no account): ${cloudflaredHint()}`);
|
|
371
|
+
console.log(C.faint(" using your Wi-Fi for now"));
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
const t = await openTunnel(app.port, (l) => console.log(C.faint(l)));
|
|
375
|
+
if (t) {
|
|
376
|
+
tunnel = t;
|
|
377
|
+
app.allowHost(new URL(t.url).host);
|
|
378
|
+
link = `${t.url}/?t=${app.token}`;
|
|
379
|
+
how = "from anywhere";
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
console.log("");
|
|
384
|
+
const also = link === app.lanUrls()[0] ? app.lanUrls().slice(1).map((u) => new URL(u).hostname) : [];
|
|
385
|
+
for (const l of phoneLines(link, how, process.stdout.isTTY === true && !process.env.NO_COLOR, also))
|
|
386
|
+
console.log(l);
|
|
387
|
+
}
|
|
388
|
+
if (!args.includes("--no-open"))
|
|
389
|
+
openBrowser(app.url);
|
|
390
|
+
const bye = () => {
|
|
391
|
+
tunnel?.close();
|
|
392
|
+
app.close();
|
|
393
|
+
process.exit(0);
|
|
394
|
+
};
|
|
395
|
+
process.on("SIGINT", bye);
|
|
396
|
+
process.on("SIGTERM", bye);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* What `huddle app --phone` prints: the code to scan, the link under it, and
|
|
400
|
+
* what sharing it means. `link` is null when this machine has no network.
|
|
401
|
+
*/
|
|
402
|
+
export function phoneLines(link, how, color, also = []) {
|
|
403
|
+
if (!link)
|
|
404
|
+
return [` ${C.yellow("!")} This computer is on no network, so a phone can't reach it. Join Wi-Fi, try again.`];
|
|
405
|
+
return [
|
|
406
|
+
` ${C.bold("On your phone")} ${C.faint(`(${how})`)}: point the camera at this code`,
|
|
407
|
+
"",
|
|
408
|
+
...qrLines(link, { color }).map((l) => " " + l),
|
|
409
|
+
"",
|
|
410
|
+
` or open ${C.bold(link)}`,
|
|
411
|
+
...(also.length ? [C.faint(` not working? try the same link with ${also.slice(0, 3).join(" or ")} in it`)] : []),
|
|
412
|
+
` ${C.yellow("!")} Anyone with this link can control this run. Ctrl-C stops sharing.`,
|
|
413
|
+
...(link.startsWith("http:") ? [C.faint(" Plain http on your Wi-Fi: fine on a network you trust. --anywhere is https.")] : []),
|
|
414
|
+
];
|
|
415
|
+
}
|
|
416
|
+
/** How to get cloudflared here, in one line. */
|
|
417
|
+
function cloudflaredHint() {
|
|
418
|
+
if (process.platform === "darwin")
|
|
419
|
+
return "brew install cloudflared";
|
|
420
|
+
if (process.platform === "win32")
|
|
421
|
+
return "winget install --id Cloudflare.cloudflared";
|
|
422
|
+
return "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
|
|
423
|
+
}
|
|
424
|
+
function openBrowser(url) {
|
|
425
|
+
const [cmd, ...a] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
426
|
+
try {
|
|
427
|
+
spawn(cmd, a, { stdio: "ignore", detached: true, windowsHide: true }).on("error", () => { }).unref();
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
/* no browser: the URL is printed */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
//# sourceMappingURL=server.js.map
|
package/dist/args.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// What `huddle <words>` means.
|
|
2
|
+
const COMMANDS = new Set("app dashboard plug init setup agents doctor join board view watch story brief runs export lessons up down peers add ctx context attach land connect disconnect guard mail relay version update upgrade connectors connector undo help".split(" "));
|
|
3
|
+
/**
|
|
4
|
+
* `huddle build a temperature converter` / `huddle "fix the login bug"`: a goal
|
|
5
|
+
* in plain words, without `join` or quotes. Found live (0.1.2 test): "as a
|
|
6
|
+
* normal user, I would never write it like that". A command name, a flag or a
|
|
7
|
+
* single unknown word stays what it was (an unknown command is still an error).
|
|
8
|
+
*/
|
|
9
|
+
export function asGoal(args) {
|
|
10
|
+
const [cmd, ...rest] = args;
|
|
11
|
+
if (!cmd || cmd.startsWith("-") || COMMANDS.has(cmd))
|
|
12
|
+
return null;
|
|
13
|
+
const words = [cmd, ...rest.filter((a) => !a.startsWith("--"))].join(" ").trim().split(/\s+/);
|
|
14
|
+
if (cmd === "build" || cmd === "make" || (cmd === "fix" && rest.length > 0) || words.length >= 3 || /\s/.test(cmd))
|
|
15
|
+
return ["join", ...(cmd === "build" ? rest : args)];
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=args.js.map
|
package/dist/attach/connect.js
CHANGED
|
@@ -19,7 +19,7 @@ import readline from "node:readline";
|
|
|
19
19
|
import { ADAPTERS } from "../adapters/index.js";
|
|
20
20
|
import { C } from "../ui/term.js";
|
|
21
21
|
import { CLI_PATH, ensureLive, liveFiles, mainRoot } from "./live.js";
|
|
22
|
-
import {
|
|
22
|
+
import { liveInstructions } from "./plug.js";
|
|
23
23
|
const TOML_START = "# >>> huddle connect (remove with `huddle disconnect`) >>>";
|
|
24
24
|
const TOML_END = "# <<< huddle connect <<<";
|
|
25
25
|
// Codex's top-level keys must come before any [table], so the instructions get
|
|
@@ -73,7 +73,7 @@ export function wiresFor(adapter, repo, opts = {}) {
|
|
|
73
73
|
file: ".cursor/rules/huddle.mdc",
|
|
74
74
|
kind: "rule",
|
|
75
75
|
why: "tells Cursor to check in with Huddle before editing",
|
|
76
|
-
content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${
|
|
76
|
+
content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${liveInstructions(repo)}\n`,
|
|
77
77
|
});
|
|
78
78
|
return out;
|
|
79
79
|
}
|
|
@@ -152,7 +152,7 @@ export function applyWire(w, current, repo) {
|
|
|
152
152
|
// config-reference). A user's own top-level value wins (TOML forbids two).
|
|
153
153
|
const top = base.split(/^\s*\[/m)[0];
|
|
154
154
|
const own = /^\s*developer_instructions\s*=/m.test(top);
|
|
155
|
-
const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(
|
|
155
|
+
const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(liveInstructions(repo))}\n${TOML_TOP_END}\n\n`;
|
|
156
156
|
return { text: head + (base.trim() ? base.replace(/\n*$/, "\n\n") : "") + codexBlock(livePlug(repo, w.agent)) + "\n" };
|
|
157
157
|
}
|
|
158
158
|
case "claude-hook": {
|
package/dist/attach/plug.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// no task id: the session says hello to the repo's live room (starting it if
|
|
5
5
|
// needed), and its tools are about sharing the repo safely with the others.
|
|
6
6
|
import { execFileSync } from "node:child_process";
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
10
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
@@ -20,6 +21,12 @@ Before you edit ANY file:
|
|
|
20
21
|
Notes from other sessions arrive in "notes_for_you" on any huddle_* reply — read them and answer with huddle_note. Record lasting decisions with huddle_remember.
|
|
21
22
|
The shared screen holds what your user put up for everyone (logs, docs, screenshots): huddle_context lists it, huddle_context_read / huddle_context_search open it, huddle_context_note pins what you found.
|
|
22
23
|
`.trim();
|
|
24
|
+
/**
|
|
25
|
+
* LIVE_INSTRUCTIONS plus where the last team run's hand-off brief is
|
|
26
|
+
* (ui/brief.ts). An attach session may sit in a worktree, so the path is the
|
|
27
|
+
* main checkout's, absolute.
|
|
28
|
+
*/
|
|
29
|
+
export const liveInstructions = (repo) => `${LIVE_INSTRUCTIONS}\nIf ${path.join(repo, ".huddle", "BRIEF.md")} exists, read it before changing code: the hand-off from the last Huddle team run in this repo (goal, decisions, contract, open issues).`;
|
|
23
30
|
const tools = [
|
|
24
31
|
{
|
|
25
32
|
name: "huddle_peers",
|
|
@@ -147,7 +154,7 @@ export async function runLivePlug() {
|
|
|
147
154
|
}
|
|
148
155
|
throw new Error(`unknown tool: ${name}`);
|
|
149
156
|
};
|
|
150
|
-
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions:
|
|
157
|
+
const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: liveInstructions(repo) });
|
|
151
158
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
152
159
|
tools: [...tools.map((t) => ({ ...t })), ...screen.map(({ handler: _h, ...t }) => t)],
|
|
153
160
|
}));
|