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,149 @@
|
|
|
1
|
+
// The board as agents see it mid-task: for every task, who owns it, where it
|
|
2
|
+
// is, the owner's last huddle_progress note and how old things are — so an
|
|
3
|
+
// agent can tell a teammate that's nearly done from one that went quiet, and
|
|
4
|
+
// see who took over whose work. Built from the task table plus Room events
|
|
5
|
+
// (task.claimed / task.progress / task.takeover / task.split); no new tables.
|
|
6
|
+
const ago = (s) => (s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 360) / 10}h`);
|
|
7
|
+
const parse = (s) => {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(s || "{}");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export function taskViews(db, now = Date.now()) {
|
|
16
|
+
const evs = db.eventsOfType(["task.claimed", "task.progress", "task.takeover", "task.split"], 0, 1000);
|
|
17
|
+
const claimed = new Map();
|
|
18
|
+
const note = new Map();
|
|
19
|
+
const touched = new Map();
|
|
20
|
+
const takeover = new Map();
|
|
21
|
+
const split = new Map();
|
|
22
|
+
const reported = new Set();
|
|
23
|
+
for (const e of evs) {
|
|
24
|
+
if (!e.task_id)
|
|
25
|
+
continue;
|
|
26
|
+
const p = parse(e.payload);
|
|
27
|
+
touched.set(e.task_id, e.ts);
|
|
28
|
+
if (e.type === "task.claimed")
|
|
29
|
+
claimed.set(e.task_id, e.ts);
|
|
30
|
+
if (e.type === "task.progress")
|
|
31
|
+
reported.add(e.task_id);
|
|
32
|
+
if (e.type === "task.progress" && p.note)
|
|
33
|
+
note.set(e.task_id, { text: String(p.note), ts: e.ts });
|
|
34
|
+
if (e.type === "task.takeover" && p.kind !== "split") {
|
|
35
|
+
takeover.set(e.task_id, { from: String(p.from ?? ""), reason: String(p.reason ?? "") });
|
|
36
|
+
claimed.set(e.task_id, e.ts); // the new owner's clock starts now
|
|
37
|
+
}
|
|
38
|
+
if (e.type === "task.split" && p.parent)
|
|
39
|
+
split.set(e.task_id, String(p.parent));
|
|
40
|
+
}
|
|
41
|
+
const secs = (ts) => Math.max(0, Math.round((now - ts) / 1000));
|
|
42
|
+
return db.listTasks().map((t) => {
|
|
43
|
+
const n = note.get(t.id);
|
|
44
|
+
const tk = takeover.get(t.id);
|
|
45
|
+
const c = claimed.get(t.id);
|
|
46
|
+
return {
|
|
47
|
+
id: t.id,
|
|
48
|
+
title: t.title,
|
|
49
|
+
status: t.status,
|
|
50
|
+
owner: t.claimed_by,
|
|
51
|
+
progress_pct: t.progress_pct,
|
|
52
|
+
reported: reported.has(t.id) || t.progress_pct > 0,
|
|
53
|
+
last_note: n?.text ?? null,
|
|
54
|
+
last_update_s: secs(Math.max(n?.ts ?? 0, touched.get(t.id) ?? 0, t.updated_at)),
|
|
55
|
+
age_s: t.status === "backlog" || c === undefined ? null : secs(c),
|
|
56
|
+
depends_on: JSON.parse(t.depends_on),
|
|
57
|
+
files: JSON.parse(t.files ?? "[]"),
|
|
58
|
+
...(tk ? { taken_over_from: tk.from, takeover_reason: tk.reason } : {}),
|
|
59
|
+
...(split.has(t.id) ? { split_from: split.get(t.id) } : {}),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** One readable line per task: "codex · in_progress 40% · 3m · “wired the parser” (20s ago)". */
|
|
64
|
+
export function progressLine(v) {
|
|
65
|
+
const who = v.owner ?? "unclaimed";
|
|
66
|
+
// An agent that never called huddle_progress shows "no report", not a made-up 0%.
|
|
67
|
+
const pct = v.status === "in_progress" || v.status === "claimed" ? (v.reported ? ` ${v.progress_pct}%` : " (no progress report)") : "";
|
|
68
|
+
const age = v.age_s !== null && v.status !== "merged" ? ` · ${ago(v.age_s)} on it` : "";
|
|
69
|
+
const last = v.last_note ? ` · “${v.last_note.slice(0, 120)}” (${ago(v.last_update_s)} ago)` : "";
|
|
70
|
+
const tk = v.taken_over_from ? ` · took over from ${v.taken_over_from}` : "";
|
|
71
|
+
const sp = v.split_from ? ` · split from ${v.split_from}` : "";
|
|
72
|
+
return `[${v.id}] ${v.title} — ${who} · ${v.status}${pct}${age}${last}${tk}${sp}`;
|
|
73
|
+
}
|
|
74
|
+
/** What huddle_progress hands back: everyone else's in-flight work, at a glance. */
|
|
75
|
+
export function teamProgress(db, exceptTask) {
|
|
76
|
+
return taskViews(db)
|
|
77
|
+
.filter((v) => v.id !== exceptTask && ["claimed", "in_progress", "review"].includes(v.status))
|
|
78
|
+
.map(progressLine);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The launch prompt's "WORKING ALONGSIDE YOU" section: the other tasks in
|
|
82
|
+
* flight right now, who is on them, and their files, so an agent knows WHO
|
|
83
|
+
* to tell when it settles something they also rely on or expose. Found live
|
|
84
|
+
* (capstone round 2): 0 agent-to-agent messages in a 9-task run; the CLI and
|
|
85
|
+
* API were built side by side over the same service, and neither agent knew
|
|
86
|
+
* the other was mid-task without polling huddle_board. Empty when nobody else is working.
|
|
87
|
+
*/
|
|
88
|
+
export function alongsideBrief(db, taskId, agentId) {
|
|
89
|
+
const others = db
|
|
90
|
+
.listTasks()
|
|
91
|
+
.filter((t) => t.id !== taskId && t.claimed_by && t.claimed_by !== agentId && ["claimed", "in_progress"].includes(t.status))
|
|
92
|
+
.filter((t) => !/^QA round \d+:/.test(t.title));
|
|
93
|
+
if (!others.length)
|
|
94
|
+
return "";
|
|
95
|
+
const lines = others.map((t) => {
|
|
96
|
+
const files = JSON.parse(t.files ?? "[]").slice(0, 6);
|
|
97
|
+
return `- ${t.claimed_by}: “${t.title}” (task ${t.id}${files.length ? ` · ${files.join(", ")}` : ""})`;
|
|
98
|
+
});
|
|
99
|
+
return (`\n\nWORKING ALONGSIDE YOU right now, each in its own worktree:\n${lines.join("\n")}\n` +
|
|
100
|
+
`When you settle something one of them also relies on or exposes (a function or data shape, an error message, an output format, a test helper), ` +
|
|
101
|
+
`huddle_send(to="<agent>", message=…) as soon as you decide it, not at the end. Unsure what theirs does? huddle_ask(…, to="<agent>"). ` +
|
|
102
|
+
`Nothing they need? Don't send.`);
|
|
103
|
+
}
|
|
104
|
+
/** Was this message Huddle's automatic help offer (sent in a free agent's name), not words the agent wrote? */
|
|
105
|
+
export function isHelpOffer(from, body) {
|
|
106
|
+
return body.startsWith(`${from} is free and can take part of your task `);
|
|
107
|
+
}
|
|
108
|
+
/** The note an idle agent (or the scheduler on its behalf) drops in a busy teammate's inbox. */
|
|
109
|
+
export function helpOffer(from, taskId, title) {
|
|
110
|
+
return (`${from} is free and can take part of your task "${title}" (${taskId}). ` +
|
|
111
|
+
`If a piece of it is separable — ideally in files you won't keep editing — call ` +
|
|
112
|
+
`huddle_split(task_id="${taskId}", title=…, spec=…, files=[…]) and Huddle hands that part to a free agent; then don't do that part yourself. ` +
|
|
113
|
+
`If it isn't worth splitting, ignore this.`);
|
|
114
|
+
}
|
|
115
|
+
/** Parts one task may be split into (each huddle_split, or a Huddle pairing, hands one to the room). */
|
|
116
|
+
export const MAX_SPLITS = 3;
|
|
117
|
+
/**
|
|
118
|
+
* Put a part of `parent` on the board as its own task — huddle_split and
|
|
119
|
+
* Huddle's pairing both go through here. The part's files leave the parent's
|
|
120
|
+
* file list and the parent's leases on them are dropped: otherwise the part
|
|
121
|
+
* waits for the very task it was split off (busy files block a backlog task),
|
|
122
|
+
* and the two never run side by side.
|
|
123
|
+
*/
|
|
124
|
+
export function addSplitPart(db, parent, by, o) {
|
|
125
|
+
const parts = db.eventsOfType(["task.split"], 0, 1000).filter((e) => {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(e.payload || "{}").parent === parent.id;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}).length;
|
|
133
|
+
if (parts >= MAX_SPLITS)
|
|
134
|
+
return { error: `"${parent.title}" was already split ${parts} times — finish the rest yourself` };
|
|
135
|
+
const files = o.files.map(String).filter(Boolean).slice(0, 50);
|
|
136
|
+
const part = db.addTask({
|
|
137
|
+
title: o.title,
|
|
138
|
+
spec: `${o.spec.slice(0, 4000)}\n\n(Split off "${parent.title}" by ${by}, who keeps the rest of it — do only this part.)`,
|
|
139
|
+
files,
|
|
140
|
+
prefers: o.prefers ?? JSON.parse(parent.prefers),
|
|
141
|
+
depends_on: o.afterMine ? [parent.id] : [],
|
|
142
|
+
});
|
|
143
|
+
if (files.length)
|
|
144
|
+
db.handOverFiles(parent.id, files);
|
|
145
|
+
db.publish("task.split", { task_id: part.id, agent_id: by, payload: { parent: parent.id, parent_title: parent.title, from: by, title: o.title, files } });
|
|
146
|
+
db.remember({ kind: "fact", body: `${by} split "${o.title}" (${part.id}) off "${parent.title}" for a free agent`, author: by });
|
|
147
|
+
return { task: part };
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The host's side of the relay: keeps one outbound WebSocket to the relay and
|
|
2
|
+
// serves members through it with the exact same shared API as the direct door.
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
export function openRelayLink(room, relayUrl, log) {
|
|
6
|
+
const base = relayUrl.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
7
|
+
const roomId = crypto.randomBytes(12).toString("base64url");
|
|
8
|
+
const key = crypto.randomBytes(24).toString("hex");
|
|
9
|
+
const subs = new Map(); // member connection → unsubscribe
|
|
10
|
+
let ws;
|
|
11
|
+
let closed = false;
|
|
12
|
+
let backoff = 1000;
|
|
13
|
+
const connect = (first) => {
|
|
14
|
+
ws = new WebSocket(`${base}/host/${roomId}?key=${key}`);
|
|
15
|
+
ws.on("open", () => {
|
|
16
|
+
backoff = 1000;
|
|
17
|
+
first(true);
|
|
18
|
+
});
|
|
19
|
+
ws.on("message", async (raw) => {
|
|
20
|
+
let m;
|
|
21
|
+
try {
|
|
22
|
+
m = JSON.parse(String(raw));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const send = (f, close) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ t: "to", cid: m.cid, f, ...(close ? { close } : {}) }));
|
|
28
|
+
if (m.t === "gone") {
|
|
29
|
+
subs.get(m.cid)?.();
|
|
30
|
+
subs.delete(m.cid);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (m.t !== "from")
|
|
34
|
+
return;
|
|
35
|
+
const f = m.f ?? {};
|
|
36
|
+
if (f.t === "req") {
|
|
37
|
+
const r = await room.handleShared({ method: String(f.method), path: String(f.path), token: f.token, body: f.body });
|
|
38
|
+
send({ t: "res", id: f.id, status: r.status, body: r.body });
|
|
39
|
+
}
|
|
40
|
+
else if (f.t === "sub") {
|
|
41
|
+
const stop = room.subscribe(String(f.token ?? ""), (e) => send({ t: "event", e }), () => send({ t: "closed", code: 4403 }, 4403));
|
|
42
|
+
if (!stop)
|
|
43
|
+
send({ t: "closed", code: 4401 }, 4401);
|
|
44
|
+
else
|
|
45
|
+
subs.set(m.cid, stop);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
ws.on("close", () => {
|
|
49
|
+
for (const stop of subs.values())
|
|
50
|
+
stop();
|
|
51
|
+
subs.clear();
|
|
52
|
+
if (closed)
|
|
53
|
+
return;
|
|
54
|
+
setTimeout(() => connect(() => { }), backoff);
|
|
55
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
56
|
+
});
|
|
57
|
+
ws.on("error", () => first(false));
|
|
58
|
+
};
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let done = false;
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
if (!done)
|
|
63
|
+
(done = true), resolve(null);
|
|
64
|
+
}, 10_000);
|
|
65
|
+
connect((ok) => {
|
|
66
|
+
if (done)
|
|
67
|
+
return;
|
|
68
|
+
done = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (!ok) {
|
|
71
|
+
closed = true;
|
|
72
|
+
log(` couldn't reach the relay at ${relayUrl}`);
|
|
73
|
+
return resolve(null);
|
|
74
|
+
}
|
|
75
|
+
resolve({
|
|
76
|
+
address: `${base}/r/${roomId}`,
|
|
77
|
+
close: () => {
|
|
78
|
+
closed = true;
|
|
79
|
+
ws.close(1000);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=relaylink.js.map
|