huddle-cli 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -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 +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Takeovers: agents take on other agents' tasks.
|
|
2
|
+
//
|
|
3
|
+
// Rescue (scheduler-driven): a task whose agent crashed, stayed red after its
|
|
4
|
+
// fix round, or went silent is re-offered to a DIFFERENT agent, which continues
|
|
5
|
+
// in the original worktree — the first agent's edits are already there — with
|
|
6
|
+
// a brief: why it stopped, the test output, and what the first agent posted.
|
|
7
|
+
// One takeover per task; if the rescuer can't land it either, it's parked for
|
|
8
|
+
// you with the branch kept.
|
|
9
|
+
//
|
|
10
|
+
// Split (agent-driven): a busy agent hands a part of its task to the room
|
|
11
|
+
// (huddle_split); a free agent picks it up like any backlog task. The
|
|
12
|
+
// scheduler nudges busy agents when a teammate is free (huddle_offer_help).
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
const cap = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
|
|
15
|
+
const parse = (s) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(s || "{}");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/** Minutes without output or a huddle_* call before an agent counts as silent. HUDDLE_SILENCE_MIN overrides. */
|
|
24
|
+
export const DEFAULT_SILENCE_MS = 10 * 60_000;
|
|
25
|
+
/** How long a task runs before a free agent offers to take part of it. HUDDLE_HELP_AFTER_SEC overrides; 0 = at once, "off" = never. */
|
|
26
|
+
export const DEFAULT_HELP_AFTER_MS = 60_000;
|
|
27
|
+
export function silenceMsFromEnv() {
|
|
28
|
+
const v = Number(process.env.HUDDLE_SILENCE_MIN);
|
|
29
|
+
return v > 0 ? v * 60_000 : DEFAULT_SILENCE_MS;
|
|
30
|
+
}
|
|
31
|
+
export function helpAfterMsFromEnv() {
|
|
32
|
+
const raw = process.env.HUDDLE_HELP_AFTER_SEC;
|
|
33
|
+
if (raw === undefined || raw === "")
|
|
34
|
+
return DEFAULT_HELP_AFTER_MS;
|
|
35
|
+
if (/^(off|no|never|-)/i.test(raw))
|
|
36
|
+
return null;
|
|
37
|
+
const v = Number(raw);
|
|
38
|
+
return Number.isFinite(v) && v >= 0 ? v * 1000 : DEFAULT_HELP_AFTER_MS;
|
|
39
|
+
}
|
|
40
|
+
function tail(file, bytes) {
|
|
41
|
+
try {
|
|
42
|
+
const st = fs.statSync(file);
|
|
43
|
+
const fd = fs.openSync(file, "r");
|
|
44
|
+
const n = Math.min(bytes, st.size);
|
|
45
|
+
const buf = Buffer.alloc(n);
|
|
46
|
+
fs.readSync(fd, buf, 0, n, st.size - n);
|
|
47
|
+
fs.closeSync(fd);
|
|
48
|
+
return buf.toString("utf8").trim();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** What the rescuer is told: why the first agent stopped, and everything it left behind. */
|
|
55
|
+
export function takeoverBrief(db, o) {
|
|
56
|
+
const evs = db.eventsOfType(["plan.announced", "task.progress"], o.since - 1, 400).filter((e) => e.task_id === o.taskId);
|
|
57
|
+
const posted = [];
|
|
58
|
+
for (const e of evs) {
|
|
59
|
+
const p = parse(e.payload);
|
|
60
|
+
if (e.type === "plan.announced" && e.agent_id === o.from)
|
|
61
|
+
posted.push(` plan: ${cap(String(p.intent ?? ""), 300)}${Array.isArray(p.touches) && p.touches.length ? ` — touches ${p.touches.slice(0, 12).join(", ")}` : ""}`);
|
|
62
|
+
if (e.type === "task.progress" && p.note)
|
|
63
|
+
posted.push(` progress ${p.pct}%: ${cap(String(p.note), 300)}`);
|
|
64
|
+
}
|
|
65
|
+
const notes = db
|
|
66
|
+
.recall()
|
|
67
|
+
.filter((m) => m.author === o.from && m.created_at >= o.since)
|
|
68
|
+
.reverse()
|
|
69
|
+
.slice(-6)
|
|
70
|
+
.map((m) => ` ${m.kind}: ${cap(m.body, 300)}${m.why ? ` (why: ${cap(m.why, 160)})` : ""}`);
|
|
71
|
+
const summary = db.getHandoff(o.taskId)?.summary;
|
|
72
|
+
const left = [...posted.slice(-10), ...notes, ...(summary ? [` done summary: ${cap(summary, 400)}`] : [])];
|
|
73
|
+
const log = o.testOutput === undefined && o.logPath ? tail(o.logPath, 1200) : "";
|
|
74
|
+
return `
|
|
75
|
+
|
|
76
|
+
TAKEOVER — ${o.from} started this task and couldn't finish it: ${o.reason}.
|
|
77
|
+
You are taking it over. ${o.from}'s work is ALREADY in your current directory (branch ${o.branch}, uncommitted edits included):
|
|
78
|
+
continue from it — don't start over. Look first (git status, git diff), keep what's right, fix what's wrong, finish the task,
|
|
79
|
+
and make the whole test suite pass. There is one takeover per task: if this doesn't land, it goes to the user.
|
|
80
|
+
|
|
81
|
+
WHAT ${o.from} POSTED:
|
|
82
|
+
${left.length ? left.join("\n") : " (nothing — it never called huddle_progress / huddle_remember)"}
|
|
83
|
+
${o.testOutput !== undefined ? `\nLAST TEST OUTPUT (tail):\n${cap(o.testOutput || "(no output)", 3000)}\n` : ""}${log ? `\nEND OF ${o.from}'S LOG (tail):\n${log}\n` : ""}`;
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=takeover.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Runs before anything else in cli.ts (it's the first import, and ES modules
|
|
2
|
+
// evaluate in import order): refuse old Node with a plain sentence instead of
|
|
3
|
+
// a crash deep inside a dependency. No imports beyond node: builtins, so this
|
|
4
|
+
// file works on any Node that can parse the CLI at all.
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
export const MIN_NODE = 20;
|
|
9
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
10
|
+
if (major < MIN_NODE) {
|
|
11
|
+
console.error(`huddle needs Node.js ${MIN_NODE} or newer — this is ${process.version}.\n` +
|
|
12
|
+
` Update Node (https://nodejs.org, or \`nvm install ${MIN_NODE}\`), then: npm install -g huddle-cli`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
/** Our own package.json — found by walking up, so it works from dist/ and from the test build. */
|
|
16
|
+
export function packageInfo() {
|
|
17
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
for (let i = 0; i < 5; i++) {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
21
|
+
if (pkg.name === "huddle-cli")
|
|
22
|
+
return { name: pkg.name, version: pkg.version };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* not here — keep walking */
|
|
26
|
+
}
|
|
27
|
+
dir = path.dirname(dir);
|
|
28
|
+
}
|
|
29
|
+
return { name: "huddle-cli", version: "unknown" };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=preflight.js.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The relay: a tiny, stateless switchboard so rooms work across any networks.
|
|
2
|
+
//
|
|
3
|
+
// Both sides dial OUT to it (which works through NAT, campus Wi-Fi, anything):
|
|
4
|
+
// host wss://relay/host/<room>?key=<secret> — one per room
|
|
5
|
+
// member wss://relay/join/<room> — anyone with the address
|
|
6
|
+
// The relay only forwards frames between a room's members and its host. It
|
|
7
|
+
// can't let anyone in: every request is checked by the host's Room (invite
|
|
8
|
+
// codes, GitHub proofs, member tokens). Nothing is stored; when the host
|
|
9
|
+
// disconnects, the room is gone.
|
|
10
|
+
//
|
|
11
|
+
// Run it anywhere Node runs: huddle relay --port 8080
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
15
|
+
const MAX_FRAME = 512 * 1024;
|
|
16
|
+
const MAX_MEMBERS = 64;
|
|
17
|
+
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
18
|
+
export function startRelay(port, log = console.log) {
|
|
19
|
+
const rooms = new Map();
|
|
20
|
+
const server = http.createServer((req, res) => {
|
|
21
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify({ huddle: "relay", rooms: rooms.size }));
|
|
23
|
+
});
|
|
24
|
+
const wss = new WebSocketServer({ server, maxPayload: MAX_FRAME });
|
|
25
|
+
wss.on("connection", (ws, req) => {
|
|
26
|
+
const url = new URL(req.url ?? "/", "http://relay");
|
|
27
|
+
const [, kind, roomId] = url.pathname.split("/");
|
|
28
|
+
if (!roomId || !/^[\w-]{8,64}$/.test(roomId))
|
|
29
|
+
return ws.close(4400, "bad room id");
|
|
30
|
+
if (kind === "host") {
|
|
31
|
+
const key = url.searchParams.get("key") ?? "";
|
|
32
|
+
const existing = rooms.get(roomId);
|
|
33
|
+
if (existing && existing.keyHash !== sha(key))
|
|
34
|
+
return ws.close(4403, "room id taken");
|
|
35
|
+
if (existing)
|
|
36
|
+
existing.host.close(4409, "replaced by a new host connection");
|
|
37
|
+
const link = { host: ws, keyHash: sha(key), members: existing?.members ?? new Map() };
|
|
38
|
+
rooms.set(roomId, link);
|
|
39
|
+
log(`room ${roomId.slice(0, 6)}… host connected`);
|
|
40
|
+
ws.on("message", (raw) => {
|
|
41
|
+
let f;
|
|
42
|
+
try {
|
|
43
|
+
f = JSON.parse(String(raw));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// host → one member
|
|
49
|
+
if (f.t === "to" && typeof f.cid === "string") {
|
|
50
|
+
const m = link.members.get(f.cid);
|
|
51
|
+
if (m?.readyState === WebSocket.OPEN)
|
|
52
|
+
m.send(JSON.stringify(f.f));
|
|
53
|
+
if (f.close)
|
|
54
|
+
m?.close(f.close, "closed by room");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
ws.on("close", () => {
|
|
58
|
+
if (rooms.get(roomId)?.host !== ws)
|
|
59
|
+
return;
|
|
60
|
+
rooms.delete(roomId);
|
|
61
|
+
for (const m of link.members.values()) {
|
|
62
|
+
if (m.readyState === WebSocket.OPEN)
|
|
63
|
+
m.send(JSON.stringify({ t: "closed", code: 1001 }));
|
|
64
|
+
m.close(1001, "room closed");
|
|
65
|
+
}
|
|
66
|
+
log(`room ${roomId.slice(0, 6)}… closed`);
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (kind === "join") {
|
|
71
|
+
const link = rooms.get(roomId);
|
|
72
|
+
if (!link)
|
|
73
|
+
return ws.close(4404, "no such room (is the host's Huddle open?)");
|
|
74
|
+
if (link.members.size >= MAX_MEMBERS)
|
|
75
|
+
return ws.close(4429, "room is full");
|
|
76
|
+
const cid = crypto.randomBytes(8).toString("hex");
|
|
77
|
+
link.members.set(cid, ws);
|
|
78
|
+
ws.on("message", (raw) => {
|
|
79
|
+
let f;
|
|
80
|
+
try {
|
|
81
|
+
f = JSON.parse(String(raw));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (link.host.readyState === WebSocket.OPEN)
|
|
87
|
+
link.host.send(JSON.stringify({ t: "from", cid, f }));
|
|
88
|
+
});
|
|
89
|
+
ws.on("close", () => {
|
|
90
|
+
link.members.delete(cid);
|
|
91
|
+
if (link.host.readyState === WebSocket.OPEN)
|
|
92
|
+
link.host.send(JSON.stringify({ t: "gone", cid }));
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
ws.close(4400, "unknown path");
|
|
97
|
+
});
|
|
98
|
+
wss.on("error", () => { }); // listen errors are reported below
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
server.once("error", (e) => reject(new Error(e?.code === "EADDRINUSE" ? `port ${port} is already in use — try --port <another>` : String(e?.message ?? e))));
|
|
101
|
+
server.listen(port, "0.0.0.0", () => {
|
|
102
|
+
const actual = server.address().port;
|
|
103
|
+
resolve({
|
|
104
|
+
port: actual,
|
|
105
|
+
close: () => {
|
|
106
|
+
wss.close();
|
|
107
|
+
server.close();
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=server.js.map
|