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/ui/watch.js
CHANGED
|
@@ -23,47 +23,115 @@ const json = (s) => {
|
|
|
23
23
|
const clip = (s, n = 160) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
24
24
|
/** Writers in the room that aren't agents — no pane of their own. */
|
|
25
25
|
const ROOM_AUTHORS = new Set(["council", "huddle", "brain", "qa"]);
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* One persisted event → a line for an agent's pane (agent set) or the room strip.
|
|
28
|
+
* The same words and glyphs as the run's own output: ▸ start, ✓ done, ✗ failed or
|
|
29
|
+
* a bug, ◎ a review or buddy check, ◆ the contract or memory, ⚠ radar, ⇉ a pair.
|
|
30
|
+
*/
|
|
27
31
|
export function activityOf(e) {
|
|
28
32
|
const p = json(e.payload);
|
|
29
33
|
const who = e.agent_id ?? p.agent;
|
|
30
34
|
switch (e.type) {
|
|
31
35
|
case "run.start":
|
|
32
|
-
return { agent: who, text:
|
|
36
|
+
return { agent: who, text: `${C.violet("▸")} ${clip(p.title)}` };
|
|
33
37
|
case "task.progress":
|
|
34
38
|
// Progress rows carry the task, not always the agent; runView resolves the owner.
|
|
35
39
|
if (!clip(p.note))
|
|
36
40
|
return null;
|
|
37
|
-
return { agent: who, text: `${p.pct}
|
|
41
|
+
return { agent: who, text: `${C.faint(`${p.pct}%`)} ${clip(p.note)}` };
|
|
38
42
|
case "plan.announced":
|
|
39
|
-
return { agent: who, text:
|
|
43
|
+
return { agent: who, text: `${C.faint("plans:")} ${clip(p.summary ?? p.plan ?? p.intent ?? p.title)}` };
|
|
40
44
|
case "run.review":
|
|
41
|
-
return { agent: who, text:
|
|
45
|
+
return { agent: who, text: `${C.violet("◎")} reviewing ${p.author}'s “${clip(p.title, 60)}”` };
|
|
42
46
|
case "run.reviewed":
|
|
43
|
-
return {
|
|
47
|
+
return {
|
|
48
|
+
agent: who,
|
|
49
|
+
text: p.skipped
|
|
50
|
+
? C.faint(`review skipped (${clip(p.notes, 40)})`)
|
|
51
|
+
: p.approve
|
|
52
|
+
? `${C.green("✓ approved")} ${p.author}'s work`
|
|
53
|
+
: `${C.yellow("✎ asked")} ${p.author} for changes: ${clip(p.notes)}`,
|
|
54
|
+
};
|
|
44
55
|
case "run.merged":
|
|
45
|
-
return { agent: who, text:
|
|
56
|
+
return { agent: who, text: `${C.green("✓ merged")} ${clip(p.title, 80)}` };
|
|
46
57
|
case "run.failed":
|
|
47
|
-
return { agent: who, text:
|
|
48
|
-
case "agent.message":
|
|
58
|
+
return { agent: who, text: `${C.red("✗ failed")} ${clip(p.title, 60)} — ${clip(p.reason ?? p.error, 80)}` };
|
|
59
|
+
case "agent.message": {
|
|
60
|
+
// Huddle relaying a buddy check: say it as the teammate's note it is.
|
|
61
|
+
const b = /^buddy check from (\S+) (?:at )?([^:\n]*):\s*([\s\S]*)$/i.exec(String(p.body ?? ""));
|
|
62
|
+
if (p.from === "huddle" && b)
|
|
63
|
+
return { text: `${C.violet("◎")} ${b[1]} → ${p.to} ${C.faint(`(buddy note, ${b[2].trim()})`)}: ${clip(b[3])}` };
|
|
49
64
|
return { text: `${p.from} → ${p.to}: ${clip(p.body)}` };
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
}
|
|
66
|
+
case "memory.added": {
|
|
67
|
+
const body = String(p.body ?? "");
|
|
68
|
+
// Each of these is already its own line: a merged task, a QA bug, a message.
|
|
69
|
+
if (/^Task \S+ done:|^QA \(\S+\) found:|^\S+ → \S+:/.test(body))
|
|
70
|
+
return null;
|
|
71
|
+
const author = p.author ?? who ?? "memory";
|
|
72
|
+
const risk = /^council risk(?: \(\w+\))?:\s*/i.exec(body);
|
|
73
|
+
if (risk)
|
|
74
|
+
return { text: `${C.yellow("!")} ${author} risk: ${clip(body.slice(risk[0].length))}` };
|
|
75
|
+
return { text: `${C.violet("◆")} ${author}: ${clip(body.replace(new RegExp(`^${author}:\\s*`), ""))}` };
|
|
76
|
+
}
|
|
52
77
|
case "council.proposal":
|
|
53
|
-
return { agent: who, text:
|
|
78
|
+
return { agent: who, text: `${C.faint("proposes:")} ${clip(p.text)}` };
|
|
54
79
|
case "council.critique":
|
|
55
|
-
return { agent: who, text:
|
|
80
|
+
return { agent: who, text: `${C.faint("flags:")} ${clip(p.text)}` };
|
|
81
|
+
case "council.adopt":
|
|
82
|
+
return { agent: who, text: `${C.green("+ adopts")} ${p.of}'s idea: ${clip(p.body)}` };
|
|
83
|
+
case "council.flaw":
|
|
84
|
+
return { agent: who, text: `${C.yellow("! flaw in")} ${p.of}'s idea: ${clip(p.body)}` };
|
|
85
|
+
case "council.ratify":
|
|
86
|
+
return {
|
|
87
|
+
agent: who,
|
|
88
|
+
text: p.verdict === "object" ? `${C.yellow("objects")} to the contract: ${clip((p.objections ?? []).join(" · "))}` : `${C.green("✓ ratifies")} the contract`,
|
|
89
|
+
};
|
|
56
90
|
case "council.plan":
|
|
57
|
-
return { text:
|
|
91
|
+
return { text: `${C.violet("◇")} council plan ready${p.synthesizer?.id ? ` · written by ${p.synthesizer.id}` : ""}` };
|
|
92
|
+
case "contract.set":
|
|
93
|
+
return p.text ? { text: `${C.violet("◆")} contract v1 by ${p.by ?? "council"} · the names every task codes to` } : null;
|
|
94
|
+
case "contract.amended":
|
|
95
|
+
return { text: `${C.violet("◆")} contract v${p.version} by ${p.by}: ${clip(`${p.section}: ${p.change}`)}` };
|
|
96
|
+
case "run.radar":
|
|
97
|
+
return { agent: who, text: `${C.yellow("⚠ radar:")} ${p.kind === "overlap" ? "overlap" : "name mismatch"} with ${p.with} · ${clip(p.detail)}` };
|
|
98
|
+
case "run.checkpoint": {
|
|
99
|
+
const ok = /^LOOKS GOOD/i.test(String(p.notes ?? "").trim());
|
|
100
|
+
return {
|
|
101
|
+
agent: who,
|
|
102
|
+
text: `${C.violet("◎")} buddy check on ${p.author}'s work ${p.pct ? `at ${p.pct}%` : "mid-task"}: ${ok ? C.green("looks good") : `${p.late ? "(came back after it landed) " : ""}${clip(p.notes)}`}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
case "note.closed":
|
|
106
|
+
return {
|
|
107
|
+
agent: p.to ?? who,
|
|
108
|
+
text: `${C.violet("◎")} ${p.kind === "radar" ? "radar finding" : `${p.by}'s buddy note`} ${p.how === "ignored" ? C.yellow("ignored") : C.green(p.how === "replied" ? "answered" : "addressed in code")}: ${clip(p.body, 100)}`,
|
|
109
|
+
};
|
|
110
|
+
case "run.busy":
|
|
111
|
+
return { agent: who, text: `${C.faint("⧗ running a long command")} · quiet ${Math.round((Number(p.quietMs) || 0) / 60_000) || "<1"} min, not silent: ${clip(p.why, 80)}` };
|
|
112
|
+
case "run.budget":
|
|
113
|
+
return { text: `${C.yellow("! token budget")} ${p.level === "hard" ? "ran over — agents stopped" : "nearly spent — no new work"}${(p.left ?? []).length ? ` · left undone: ${clip((p.left ?? []).join(" · "), 80)}` : ""}` };
|
|
114
|
+
case "run.pair":
|
|
115
|
+
return { agent: who, text: `${C.violet("⇉")} pairs with ${p.with} on “${clip(p.title, 60)}”: ${clip(p.part, 80)}` };
|
|
58
116
|
case "qa.round":
|
|
59
|
-
return { text:
|
|
117
|
+
return { text: `${C.violet("◎")} QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
|
|
60
118
|
case "qa.bug":
|
|
61
|
-
return { text:
|
|
119
|
+
return { text: `${C.yellow("✗")} ${who ?? "QA"} found a bug: ${clip(p.title ?? p.body)}` };
|
|
120
|
+
case "qa.done":
|
|
121
|
+
return { agent: who, text: p.ok ? `${C.green("✓ QA done")} · ${p.bugs ?? 0} bug(s) filed` : C.yellow("! QA didn't finish") };
|
|
62
122
|
case "qa.clean":
|
|
63
|
-
return { text:
|
|
123
|
+
return { text: `${C.green("✓")} QA round ${p.round ?? ""} clean` };
|
|
124
|
+
case "qa.suspect":
|
|
125
|
+
return { text: `${C.yellow("?")} QA round ${p.round ?? ""} clean, but quick: a second agent checks` };
|
|
126
|
+
case "context.added":
|
|
127
|
+
return { text: `${C.violet("+")} ${p.by ?? "you"} put ${p.id} on the screen: “${clip(p.title, 60)}”` };
|
|
128
|
+
case "context.note":
|
|
129
|
+
return { text: `${C.violet("◆")} ${p.by ?? who} on ${p.id}: ${clip(p.body)}` };
|
|
130
|
+
case "context.read":
|
|
131
|
+
return who ? { agent: who, text: `${C.faint("read")} ${p.id} “${clip(p.title, 50)}”${p.from ? ` lines ${p.from}-${p.to}` : ""}` } : null;
|
|
64
132
|
default:
|
|
65
133
|
if (e.type.startsWith("takeover") || e.type === "run.takeover")
|
|
66
|
-
return { text:
|
|
134
|
+
return { text: `${C.yellow("⤳")} ${p.to ?? who} took over ${p.from}'s “${clip(p.title, 60)}” (${clip(p.reason, 40)})` };
|
|
67
135
|
return null;
|
|
68
136
|
}
|
|
69
137
|
}
|
|
@@ -86,12 +154,21 @@ export function statsOf(db, events) {
|
|
|
86
154
|
get(a.id);
|
|
87
155
|
for (const e of run) {
|
|
88
156
|
const p = json(e.payload);
|
|
157
|
+
// A QA round: its checkers are busy until each one's qa.done.
|
|
158
|
+
if (e.type === "qa.round")
|
|
159
|
+
for (const q of (p.agents ?? []))
|
|
160
|
+
if (!ROOM_AUTHORS.has(q))
|
|
161
|
+
get(q).busySince = e.ts;
|
|
89
162
|
const id = e.agent_id ?? p.agent;
|
|
90
163
|
if (!id || ROOM_AUTHORS.has(id))
|
|
91
164
|
continue;
|
|
92
165
|
const a = get(id);
|
|
93
166
|
if (e.type === "run.start" || e.type === "run.review")
|
|
94
167
|
a.busySince = e.ts;
|
|
168
|
+
else if (e.type === "qa.done") {
|
|
169
|
+
a.busySince = null;
|
|
170
|
+
a.qa = (a.qa ?? 0) + 1;
|
|
171
|
+
}
|
|
95
172
|
else if (e.type === "run.merged" || e.type === "run.failed" || e.type === "run.reviewed") {
|
|
96
173
|
a.busySince = null;
|
|
97
174
|
if (e.type === "run.merged")
|
|
@@ -101,37 +178,39 @@ export function statsOf(db, events) {
|
|
|
101
178
|
a.tokens += Number(p.tokens) || 0;
|
|
102
179
|
}
|
|
103
180
|
}
|
|
104
|
-
|
|
181
|
+
// Over: nobody busy and every task landed or failed. The clock stops at the last event.
|
|
182
|
+
const busy = [...agents.values()].some((a) => a.busySince);
|
|
183
|
+
const tasks = db.listTasks();
|
|
184
|
+
const over = !busy && tasks.length > 0 && tasks.every((t) => t.status === "merged" || t.status === "failed");
|
|
185
|
+
return { startedAt: starts[0]?.ts ?? since, ...(over && run.length ? { endedAt: run[run.length - 1].ts } : {}), agents };
|
|
105
186
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
console.log(C.red("✗ No Huddle room here yet.") + ` Start a run in ${root} (huddle, or huddle join "<goal>"), then run huddle view.`);
|
|
115
|
-
process.exit(1);
|
|
116
|
-
}
|
|
117
|
-
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
118
|
-
console.log(C.red("✗ huddle view needs a terminal.") + " For a plain-text replay use: huddle story");
|
|
119
|
-
process.exit(1);
|
|
120
|
-
}
|
|
121
|
-
const db = new RoomDB(file);
|
|
122
|
-
let events = db.allEvents();
|
|
123
|
-
let stats = statsOf(db, events);
|
|
187
|
+
/**
|
|
188
|
+
* The read-only view on a Room db: the dashboard, fed this run's events so
|
|
189
|
+
* far. refresh() picks up what another process wrote since. (runView polls
|
|
190
|
+
* it; the samples and tests call frame() on it directly.)
|
|
191
|
+
*/
|
|
192
|
+
export function viewOf(db) {
|
|
193
|
+
let events = [];
|
|
194
|
+
let stats = null;
|
|
124
195
|
const dash = new Dashboard({
|
|
125
196
|
db: () => db,
|
|
126
197
|
stats: () => stats,
|
|
127
198
|
modeChip: () => C.faint("watching · read-only"),
|
|
128
199
|
hint: "1 2 3 switch · q close the view (agents keep working)",
|
|
200
|
+
empty: "waiting for a run — agents show up here as they pick up tasks",
|
|
129
201
|
});
|
|
130
|
-
// Replay this run's feed so the panes aren't empty when you open late.
|
|
131
|
-
const since = db.currentSession()?.created_at ?? 0;
|
|
132
202
|
let seen = 0; // events come back ordered by (ts, rowid), so new ones are a suffix
|
|
133
|
-
|
|
134
|
-
|
|
203
|
+
let since = -1;
|
|
204
|
+
const refresh = () => {
|
|
205
|
+
events = db.allEvents();
|
|
206
|
+
stats = statsOf(db, events);
|
|
207
|
+
// A new run (a new session) starts the panes over.
|
|
208
|
+
const s = db.currentSession()?.created_at ?? 0;
|
|
209
|
+
if (s !== since) {
|
|
210
|
+
since = s;
|
|
211
|
+
seen = 0;
|
|
212
|
+
}
|
|
213
|
+
for (const e of events.slice(seen)) {
|
|
135
214
|
if (e.ts < since)
|
|
136
215
|
continue;
|
|
137
216
|
const a = activityOf(e);
|
|
@@ -143,14 +222,36 @@ export async function runView(args) {
|
|
|
143
222
|
if (a)
|
|
144
223
|
dash.push(a);
|
|
145
224
|
}
|
|
146
|
-
seen =
|
|
225
|
+
seen = events.length;
|
|
147
226
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
227
|
+
refresh();
|
|
228
|
+
return { dash, refresh };
|
|
229
|
+
}
|
|
230
|
+
function repoRoot(dir) {
|
|
231
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8" });
|
|
232
|
+
return r.status === 0 ? r.stdout.trim() : dir;
|
|
233
|
+
}
|
|
234
|
+
export async function runView(args) {
|
|
235
|
+
const root = repoRoot(path.resolve(args.find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
236
|
+
const file = path.join(root, ".huddle", "huddle.db");
|
|
237
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
238
|
+
if (!fs.existsSync(file))
|
|
239
|
+
console.log(C.red("✗ No Huddle room here yet.") + ` Start a run in ${root} (huddle, or huddle join "<goal>"), then run huddle view.`);
|
|
240
|
+
else
|
|
241
|
+
console.log(C.red("✗ huddle view needs a terminal.") + " For a plain-text replay use: huddle story");
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
// Opened before the first run here: wait for its room instead of giving up.
|
|
245
|
+
if (!fs.existsSync(file)) {
|
|
246
|
+
console.log(`${C.faint("○")} No room in ${root} yet. ${C.faint('Waiting for a run to start here (huddle, or huddle join "<goal>") · ctrl-c to stop')}`);
|
|
247
|
+
await new Promise((resolve) => {
|
|
248
|
+
const t = setInterval(() => fs.existsSync(file) && (clearInterval(t), resolve()), 1000);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const db = new RoomDB(file);
|
|
252
|
+
// Replay this run's feed so the panes aren't empty when you open late.
|
|
253
|
+
const { dash, refresh } = viewOf(db);
|
|
254
|
+
const poll = setInterval(refresh, 1000);
|
|
154
255
|
readline.emitKeypressEvents(process.stdin);
|
|
155
256
|
process.stdin.setRawMode(true);
|
|
156
257
|
await new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huddle-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "The multiplayer layer for AI coding agents. One command. Every agent. One repo. Zero collisions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"dist/**/*.js",
|
|
11
11
|
"install.sh",
|
|
12
|
+
"QUICKSTART.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
12
14
|
"README.md",
|
|
13
15
|
"LICENSE"
|
|
14
16
|
],
|
|
@@ -30,8 +32,9 @@
|
|
|
30
32
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
31
33
|
"prepare": "npm run build",
|
|
32
34
|
"prepublishOnly": "npm run typecheck && npm test",
|
|
33
|
-
"test": "
|
|
34
|
-
"e2e": "npm run build &&
|
|
35
|
+
"test": "node scripts/test.mjs",
|
|
36
|
+
"e2e": "npm run build && node scripts/test.mjs e2e",
|
|
37
|
+
"evolve": "npm run build && node scripts/test.mjs evolve"
|
|
35
38
|
},
|
|
36
39
|
"keywords": [
|
|
37
40
|
"ai",
|