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
package/dist/ui/term.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Terminal primitives: color, width-aware padding, the rounded box, spinners.
|
|
2
|
+
// Huddle lives in the terminal only, so this file is the whole "UI toolkit".
|
|
3
|
+
const color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
4
|
+
/**
|
|
5
|
+
* A console that can't draw Huddle's glyphs: the old Windows console host
|
|
6
|
+
* (conhost: cmd.exe or PowerShell outside Windows Terminal) shows ⤳, ⇉,
|
|
7
|
+
* braille spinners and emoji as boxes or "?". HUDDLE_ASCII=1 forces plain
|
|
8
|
+
* ASCII anywhere; HUDDLE_ASCII=0 turns it off. Windows Terminal sets
|
|
9
|
+
* WT_SESSION, VS Code's terminal sets TERM_PROGRAM, ConEmu sets ConEmuANSI.
|
|
10
|
+
*/
|
|
11
|
+
export function legacyConsole(env = process.env, platform = process.platform) {
|
|
12
|
+
if (env.HUDDLE_ASCII === "1")
|
|
13
|
+
return true;
|
|
14
|
+
if (env.HUDDLE_ASCII === "0")
|
|
15
|
+
return false;
|
|
16
|
+
return platform === "win32" && !env.WT_SESSION && !env.TERM_PROGRAM && env.ConEmuANSI !== "ON";
|
|
17
|
+
}
|
|
18
|
+
export const ASCII = legacyConsole();
|
|
19
|
+
const ASCII_MAP = {
|
|
20
|
+
"●": "*", "○": "o", "✓": "v", "✗": "x", "▸": ">", "◎": "@", "◆": "#", "◇": "#", "⚠": "!", "⇉": "=>", "⇢": "->",
|
|
21
|
+
"⤳": "~>", "⇄": "<>", "→": "->", "←": "<-", "↑": "^", "↓": "v", "…": "...", "·": "-", "—": "-", "–": "-", "“": '"', "”": '"',
|
|
22
|
+
"‘": "'", "’": "'", "─": "-", "━": "=", "│": "|", "╭": "+", "╮": "+", "╰": "+", "╯": "+", "┌": "+", "┐": "+", "└": "+", "┘": "+",
|
|
23
|
+
"█": "#", "░": ".", "▶": ">", "■": "#", "✎": "*", "⬢": "#", "⏱": "", "⚡": "!", "×": "x", "≥": ">=", "≤": "<=", "\uFE0F": "",
|
|
24
|
+
};
|
|
25
|
+
const ASCII_RE = new RegExp(`[${Object.keys(ASCII_MAP).join("")}]|[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}]\uFE0F?|\uFE0F|[\u2800-\u28FF]`, "gu");
|
|
26
|
+
/** Huddle's glyphs → ASCII, for legacy consoles (escape codes pass through). */
|
|
27
|
+
export function toAscii(s) {
|
|
28
|
+
return s.replace(ASCII_RE, (c) => ASCII_MAP[c] ?? ASCII_MAP[c.replace("\uFE0F", "")] ?? (c >= "\u2800" && c <= "\u28FF" ? "|" : "*"));
|
|
29
|
+
}
|
|
30
|
+
/** In a legacy console, every write to stdout/stderr goes through toAscii (once, at startup). */
|
|
31
|
+
export function useAsciiOutput(on = ASCII) {
|
|
32
|
+
if (!on)
|
|
33
|
+
return;
|
|
34
|
+
for (const stream of [process.stdout, process.stderr]) {
|
|
35
|
+
const write = stream.write.bind(stream);
|
|
36
|
+
stream.write = (chunk, ...rest) => write(typeof chunk === "string" ? toAscii(chunk) : chunk, ...rest);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const sgr = (open) => (s) => (color ? `\x1b[${open}m${s}\x1b[0m` : s);
|
|
40
|
+
const hex = (h) => {
|
|
41
|
+
const n = parseInt(h.slice(1), 16);
|
|
42
|
+
return sgr(`38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`);
|
|
43
|
+
};
|
|
44
|
+
// Brand palette — matches the teaser (teaser/launch40.html).
|
|
45
|
+
export const C = {
|
|
46
|
+
dim: sgr("2"),
|
|
47
|
+
bold: sgr("1"),
|
|
48
|
+
green: sgr("32"),
|
|
49
|
+
red: sgr("31"),
|
|
50
|
+
yellow: sgr("33"),
|
|
51
|
+
violet: hex("#7B6CF6"),
|
|
52
|
+
faint: hex("#8A857C"),
|
|
53
|
+
hex,
|
|
54
|
+
};
|
|
55
|
+
export const AGENT_COLOR = {
|
|
56
|
+
claude: "#D9774B",
|
|
57
|
+
codex: "#10A37F",
|
|
58
|
+
antigravity: "#4285F4",
|
|
59
|
+
gemini: "#4285F4",
|
|
60
|
+
cursor: "#8A8A8A",
|
|
61
|
+
opencode: "#E5C07B",
|
|
62
|
+
copilot: "#8957E5",
|
|
63
|
+
amp: "#F34E3F",
|
|
64
|
+
goose: "#B8B2A7",
|
|
65
|
+
qwen: "#615CED",
|
|
66
|
+
droid: "#EE6018",
|
|
67
|
+
kiro: "#9046FF",
|
|
68
|
+
};
|
|
69
|
+
/** Colored dot for an agent; "claude#2" gets Claude's color. */
|
|
70
|
+
export const dot = (agentId) => hex(AGENT_COLOR[agentId.split("#")[0]] ?? "#8A857C")("●");
|
|
71
|
+
/** Terminal cells for one character: emoji and CJK take two, combining marks none. */
|
|
72
|
+
function cells(ch) {
|
|
73
|
+
const c = ch.codePointAt(0);
|
|
74
|
+
if (c === 0xfe0f || c === 0x200d || (c >= 0x300 && c <= 0x36f))
|
|
75
|
+
return 0;
|
|
76
|
+
if ((c >= 0x1f300 && c <= 0x1faff) || (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xff00 && c <= 0xff60))
|
|
77
|
+
return 2;
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
/** Visible width of a string in terminal cells (ignores ANSI escapes). */
|
|
81
|
+
export function width(s) {
|
|
82
|
+
let n = 0;
|
|
83
|
+
for (const ch of s.replace(/\x1b\[[0-9;]*m/g, ""))
|
|
84
|
+
n += cells(ch);
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
/** Cut to `n` visible characters, keeping color codes intact. */
|
|
88
|
+
export function truncate(s, n) {
|
|
89
|
+
if (width(s) <= n)
|
|
90
|
+
return s;
|
|
91
|
+
let out = "";
|
|
92
|
+
let seen = 0;
|
|
93
|
+
for (let i = 0; i < s.length;) {
|
|
94
|
+
const esc = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
95
|
+
if (esc) {
|
|
96
|
+
out += esc[0];
|
|
97
|
+
i += esc[0].length;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
101
|
+
const w = cells(ch);
|
|
102
|
+
if (seen + w > n - 1)
|
|
103
|
+
return out + "…" + (out.includes("\x1b[") ? "\x1b[0m" : "");
|
|
104
|
+
out += ch;
|
|
105
|
+
seen += w;
|
|
106
|
+
i += ch.length;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Word-wrap plain or colored text to `n` cells; `indent` starts every line after the first. */
|
|
111
|
+
export function wrap(s, n, indent = "") {
|
|
112
|
+
const lead = /^ */.exec(s)[0];
|
|
113
|
+
if (!indent.startsWith(lead))
|
|
114
|
+
indent = lead + indent;
|
|
115
|
+
const out = [];
|
|
116
|
+
let line = lead;
|
|
117
|
+
for (const word of s.slice(lead.length).split(/ +/)) {
|
|
118
|
+
if (line.trim() && width(line) + 1 + width(word) > n) {
|
|
119
|
+
out.push(line);
|
|
120
|
+
line = indent + word;
|
|
121
|
+
}
|
|
122
|
+
else
|
|
123
|
+
line = line.trim() ? line + " " + word : line + word;
|
|
124
|
+
}
|
|
125
|
+
out.push(line);
|
|
126
|
+
return out.map((l) => truncate(l, n + 1));
|
|
127
|
+
}
|
|
128
|
+
/** Columns to fit printed lines to: the terminal's (minus one, so nothing auto-wraps), or none when piped. */
|
|
129
|
+
export function lineWidth(out = process.stdout) {
|
|
130
|
+
return out.isTTY ? Math.max(20, (out.columns || 80) - 1) : null;
|
|
131
|
+
}
|
|
132
|
+
/** One printed line, cut to the terminal (no-op when piped: logs keep everything). */
|
|
133
|
+
export function fit(s, out = process.stdout) {
|
|
134
|
+
const w = lineWidth(out);
|
|
135
|
+
return w === null ? s : s.split("\n").map((l) => truncate(l, w)).join("\n");
|
|
136
|
+
}
|
|
137
|
+
export function pad(s, n) {
|
|
138
|
+
return s + " ".repeat(Math.max(0, n - width(s)));
|
|
139
|
+
}
|
|
140
|
+
/** Rounded box with a colored border, Claude-Code style. */
|
|
141
|
+
export function box(lines, opts = {}) {
|
|
142
|
+
const border = opts.border ?? C.violet;
|
|
143
|
+
const inner = Math.max(opts.width ?? 0, ...lines.map(width)) + 2;
|
|
144
|
+
const top = border("╭" + "─".repeat(inner) + "╮");
|
|
145
|
+
const bot = border("╰" + "─".repeat(inner) + "╯");
|
|
146
|
+
const mid = lines.map((l) => border("│") + " " + pad(l, inner - 2) + " " + border("│"));
|
|
147
|
+
return [top, ...mid, bot].join("\n");
|
|
148
|
+
}
|
|
149
|
+
const FRAMES = ASCII ? ["|", "/", "-", "\\"] : ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
150
|
+
/**
|
|
151
|
+
* A set of lines that re-render in place while async work settles. Each row
|
|
152
|
+
* is cut to the terminal's width: a row that wraps takes two screen lines,
|
|
153
|
+
* so moving the cursor up "one per row" would land mid-list and every redraw
|
|
154
|
+
* would print the list again further down (seen on Windows: rows doubled and
|
|
155
|
+
* spliced, "API_KEYodex · openai").
|
|
156
|
+
*/
|
|
157
|
+
export class LiveLines {
|
|
158
|
+
render;
|
|
159
|
+
out;
|
|
160
|
+
lines;
|
|
161
|
+
frame = 0;
|
|
162
|
+
timer;
|
|
163
|
+
drawn = false;
|
|
164
|
+
constructor(render, out = process.stdout) {
|
|
165
|
+
this.render = render;
|
|
166
|
+
this.out = out;
|
|
167
|
+
this.lines = [];
|
|
168
|
+
}
|
|
169
|
+
start() {
|
|
170
|
+
this.draw();
|
|
171
|
+
if (this.out.isTTY)
|
|
172
|
+
this.timer = setInterval(() => this.draw(), 80);
|
|
173
|
+
return this;
|
|
174
|
+
}
|
|
175
|
+
/** One screen line per row: never wider than the terminal (minus one, so no auto-wrap). */
|
|
176
|
+
fit(l) {
|
|
177
|
+
return truncate(l.replace(/[\r\n]+/g, " "), Math.max(10, (this.out.columns || 80) - 1));
|
|
178
|
+
}
|
|
179
|
+
draw() {
|
|
180
|
+
if (!this.out.isTTY)
|
|
181
|
+
return; // non-TTY: draw once at stop()
|
|
182
|
+
const next = this.render(C.violet(FRAMES[this.frame++ % FRAMES.length])).map((l) => this.fit(l));
|
|
183
|
+
let s = this.drawn && this.lines.length ? `\x1b[${this.lines.length}A` : "";
|
|
184
|
+
for (const l of next)
|
|
185
|
+
s += "\x1b[2K" + l + "\n";
|
|
186
|
+
this.out.write(s); // one write: no half-drawn frame
|
|
187
|
+
this.lines = next;
|
|
188
|
+
this.drawn = true;
|
|
189
|
+
}
|
|
190
|
+
stop() {
|
|
191
|
+
if (this.timer)
|
|
192
|
+
clearInterval(this.timer);
|
|
193
|
+
if (!this.out.isTTY) {
|
|
194
|
+
for (const l of this.render(" "))
|
|
195
|
+
this.out.write(l + "\n");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
this.draw();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Lines pinned under scrolling output — the run's status block. log() prints
|
|
203
|
+
* a line above them and redraws them; nothing else should write to `out`
|
|
204
|
+
* while the dock is up. TTY only: piped output (a log file) gets the logged
|
|
205
|
+
* lines and never the dock.
|
|
206
|
+
*/
|
|
207
|
+
export class Dock {
|
|
208
|
+
render;
|
|
209
|
+
out;
|
|
210
|
+
n = 0;
|
|
211
|
+
timer;
|
|
212
|
+
up = false;
|
|
213
|
+
constructor(render, out = process.stdout) {
|
|
214
|
+
this.render = render;
|
|
215
|
+
this.out = out;
|
|
216
|
+
}
|
|
217
|
+
paint() {
|
|
218
|
+
const w = Math.max(10, (this.out.columns || 80) - 1);
|
|
219
|
+
const ls = this.render().map((l) => truncate(l.replace(/[\r\n]+/g, " "), w));
|
|
220
|
+
this.n = ls.length;
|
|
221
|
+
return ls.map((l) => "\x1b[2K" + l + "\n").join("");
|
|
222
|
+
}
|
|
223
|
+
clear() {
|
|
224
|
+
return this.n ? `\x1b[${this.n}A\x1b[J` : "";
|
|
225
|
+
}
|
|
226
|
+
start() {
|
|
227
|
+
if (!this.out.isTTY || this.up)
|
|
228
|
+
return this;
|
|
229
|
+
this.up = true;
|
|
230
|
+
this.out.write(this.paint());
|
|
231
|
+
this.timer = setInterval(() => this.out.write(this.clear() + this.paint()), 1000);
|
|
232
|
+
this.timer.unref?.();
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
log(line) {
|
|
236
|
+
if (!this.up)
|
|
237
|
+
return void this.out.write(line + "\n");
|
|
238
|
+
this.out.write(this.clear() + line + "\n" + this.paint());
|
|
239
|
+
}
|
|
240
|
+
stop() {
|
|
241
|
+
if (this.timer)
|
|
242
|
+
clearInterval(this.timer);
|
|
243
|
+
if (this.up)
|
|
244
|
+
this.out.write(this.clear());
|
|
245
|
+
this.up = false;
|
|
246
|
+
this.n = 0;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
//# sourceMappingURL=term.js.map
|
package/dist/ui/watch.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// `huddle view [repo]` — the live view (ctrl-t) from a second terminal.
|
|
2
|
+
//
|
|
3
|
+
// A run started anywhere — `huddle`, `huddle join "<goal>"`, a script, the
|
|
4
|
+
// e2e evals — writes everything to the repo's Room db. This opens the same
|
|
5
|
+
// full-screen dashboard on that db, read-only: agent panes, plan + room
|
|
6
|
+
// talk, board + shared memory. It never claims, edits or stops anything;
|
|
7
|
+
// q or ctrl-c just closes the view.
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import readline from "node:readline";
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { RoomDB } from "../room/db.js";
|
|
13
|
+
import { C } from "./term.js";
|
|
14
|
+
import { Dashboard } from "./dashboard.js";
|
|
15
|
+
const json = (s) => {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(s || "{}");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const clip = (s, n = 160) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
24
|
+
/** Writers in the room that aren't agents — no pane of their own. */
|
|
25
|
+
const ROOM_AUTHORS = new Set(["council", "huddle", "brain", "qa"]);
|
|
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
|
+
*/
|
|
31
|
+
export function activityOf(e) {
|
|
32
|
+
const p = json(e.payload);
|
|
33
|
+
const who = e.agent_id ?? p.agent;
|
|
34
|
+
switch (e.type) {
|
|
35
|
+
case "run.start":
|
|
36
|
+
return { agent: who, text: `${C.violet("▸")} ${clip(p.title)}` };
|
|
37
|
+
case "task.progress":
|
|
38
|
+
// Progress rows carry the task, not always the agent; runView resolves the owner.
|
|
39
|
+
if (!clip(p.note))
|
|
40
|
+
return null;
|
|
41
|
+
return { agent: who, text: `${C.faint(`${p.pct}%`)} ${clip(p.note)}` };
|
|
42
|
+
case "plan.announced":
|
|
43
|
+
return { agent: who, text: `${C.faint("plans:")} ${clip(p.summary ?? p.plan ?? p.intent ?? p.title)}` };
|
|
44
|
+
case "run.review":
|
|
45
|
+
return { agent: who, text: `${C.violet("◎")} reviewing ${p.author}'s “${clip(p.title, 60)}”` };
|
|
46
|
+
case "run.reviewed":
|
|
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
|
+
};
|
|
55
|
+
case "run.merged":
|
|
56
|
+
return { agent: who, text: `${C.green("✓ merged")} ${clip(p.title, 80)}` };
|
|
57
|
+
case "run.failed":
|
|
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])}` };
|
|
64
|
+
return { text: `${p.from} → ${p.to}: ${clip(p.body)}` };
|
|
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
|
+
}
|
|
77
|
+
case "council.proposal":
|
|
78
|
+
return { agent: who, text: `${C.faint("proposes:")} ${clip(p.text)}` };
|
|
79
|
+
case "council.critique":
|
|
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
|
+
};
|
|
90
|
+
case "council.plan":
|
|
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.pair":
|
|
111
|
+
return { agent: who, text: `${C.violet("⇉")} pairs with ${p.with} on “${clip(p.title, 60)}”: ${clip(p.part, 80)}` };
|
|
112
|
+
case "qa.round":
|
|
113
|
+
return { text: `${C.violet("◎")} QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
|
|
114
|
+
case "qa.bug":
|
|
115
|
+
return { text: `${C.yellow("✗")} ${who ?? "QA"} found a bug: ${clip(p.title ?? p.body)}` };
|
|
116
|
+
case "qa.done":
|
|
117
|
+
return { agent: who, text: p.ok ? `${C.green("✓ QA done")} · ${p.bugs ?? 0} bug(s) filed` : C.yellow("! QA didn't finish") };
|
|
118
|
+
case "qa.clean":
|
|
119
|
+
return { text: `${C.green("✓")} QA round ${p.round ?? ""} clean` };
|
|
120
|
+
case "qa.suspect":
|
|
121
|
+
return { text: `${C.yellow("?")} QA round ${p.round ?? ""} clean, but quick: a second agent checks` };
|
|
122
|
+
case "context.added":
|
|
123
|
+
return { text: `${C.violet("+")} ${p.by ?? "you"} put ${p.id} on the screen: “${clip(p.title, 60)}”` };
|
|
124
|
+
case "context.note":
|
|
125
|
+
return { text: `${C.violet("◆")} ${p.by ?? who} on ${p.id}: ${clip(p.body)}` };
|
|
126
|
+
case "context.read":
|
|
127
|
+
return who ? { agent: who, text: `${C.faint("read")} ${p.id} “${clip(p.title, 50)}”${p.from ? ` lines ${p.from}-${p.to}` : ""}` } : null;
|
|
128
|
+
default:
|
|
129
|
+
if (e.type.startsWith("takeover") || e.type === "run.takeover")
|
|
130
|
+
return { text: `${C.yellow("⤳")} ${p.to ?? who} took over ${p.from}'s “${clip(p.title, 60)}” (${clip(p.reason, 40)})` };
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Busy/tokens/tasks per agent, rebuilt from the run's events (another process owns the live stats). */
|
|
135
|
+
export function statsOf(db, events) {
|
|
136
|
+
const session = db.currentSession();
|
|
137
|
+
const since = session?.created_at ?? 0;
|
|
138
|
+
const run = events.filter((e) => e.ts >= since);
|
|
139
|
+
const starts = run.filter((e) => e.type === "run.start");
|
|
140
|
+
if (!starts.length && !db.listAgents().length)
|
|
141
|
+
return null;
|
|
142
|
+
const agents = new Map();
|
|
143
|
+
const get = (id) => {
|
|
144
|
+
if (!agents.has(id))
|
|
145
|
+
agents.set(id, { busySince: null, liveTokens: 0, tokens: 0, tasks: 0, reviews: 0 });
|
|
146
|
+
return agents.get(id);
|
|
147
|
+
};
|
|
148
|
+
for (const a of db.listAgents())
|
|
149
|
+
if (!ROOM_AUTHORS.has(a.id))
|
|
150
|
+
get(a.id);
|
|
151
|
+
for (const e of run) {
|
|
152
|
+
const p = json(e.payload);
|
|
153
|
+
// A QA round: its checkers are busy until each one's qa.done.
|
|
154
|
+
if (e.type === "qa.round")
|
|
155
|
+
for (const q of (p.agents ?? []))
|
|
156
|
+
if (!ROOM_AUTHORS.has(q))
|
|
157
|
+
get(q).busySince = e.ts;
|
|
158
|
+
const id = e.agent_id ?? p.agent;
|
|
159
|
+
if (!id || ROOM_AUTHORS.has(id))
|
|
160
|
+
continue;
|
|
161
|
+
const a = get(id);
|
|
162
|
+
if (e.type === "run.start" || e.type === "run.review")
|
|
163
|
+
a.busySince = e.ts;
|
|
164
|
+
else if (e.type === "qa.done") {
|
|
165
|
+
a.busySince = null;
|
|
166
|
+
a.qa = (a.qa ?? 0) + 1;
|
|
167
|
+
}
|
|
168
|
+
else if (e.type === "run.merged" || e.type === "run.failed" || e.type === "run.reviewed") {
|
|
169
|
+
a.busySince = null;
|
|
170
|
+
if (e.type === "run.merged")
|
|
171
|
+
a.tasks++;
|
|
172
|
+
if (e.type === "run.reviewed")
|
|
173
|
+
a.reviews++;
|
|
174
|
+
a.tokens += Number(p.tokens) || 0;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Over: nobody busy and every task landed or failed. The clock stops at the last event.
|
|
178
|
+
const busy = [...agents.values()].some((a) => a.busySince);
|
|
179
|
+
const tasks = db.listTasks();
|
|
180
|
+
const over = !busy && tasks.length > 0 && tasks.every((t) => t.status === "merged" || t.status === "failed");
|
|
181
|
+
return { startedAt: starts[0]?.ts ?? since, ...(over && run.length ? { endedAt: run[run.length - 1].ts } : {}), agents };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The read-only view on a Room db: the dashboard, fed this run's events so
|
|
185
|
+
* far. refresh() picks up what another process wrote since. (runView polls
|
|
186
|
+
* it; the samples and tests call frame() on it directly.)
|
|
187
|
+
*/
|
|
188
|
+
export function viewOf(db) {
|
|
189
|
+
let events = [];
|
|
190
|
+
let stats = null;
|
|
191
|
+
const dash = new Dashboard({
|
|
192
|
+
db: () => db,
|
|
193
|
+
stats: () => stats,
|
|
194
|
+
modeChip: () => C.faint("watching · read-only"),
|
|
195
|
+
hint: "1 2 3 switch · q close the view (agents keep working)",
|
|
196
|
+
empty: "waiting for a run — agents show up here as they pick up tasks",
|
|
197
|
+
});
|
|
198
|
+
let seen = 0; // events come back ordered by (ts, rowid), so new ones are a suffix
|
|
199
|
+
let since = -1;
|
|
200
|
+
const refresh = () => {
|
|
201
|
+
events = db.allEvents();
|
|
202
|
+
stats = statsOf(db, events);
|
|
203
|
+
// A new run (a new session) starts the panes over.
|
|
204
|
+
const s = db.currentSession()?.created_at ?? 0;
|
|
205
|
+
if (s !== since) {
|
|
206
|
+
since = s;
|
|
207
|
+
seen = 0;
|
|
208
|
+
}
|
|
209
|
+
for (const e of events.slice(seen)) {
|
|
210
|
+
if (e.ts < since)
|
|
211
|
+
continue;
|
|
212
|
+
const a = activityOf(e);
|
|
213
|
+
if (a && !a.agent && e.type === "task.progress" && e.task_id) {
|
|
214
|
+
const owner = db.getTask(e.task_id)?.claimed_by;
|
|
215
|
+
if (owner)
|
|
216
|
+
a.agent = owner;
|
|
217
|
+
}
|
|
218
|
+
if (a)
|
|
219
|
+
dash.push(a);
|
|
220
|
+
}
|
|
221
|
+
seen = events.length;
|
|
222
|
+
};
|
|
223
|
+
refresh();
|
|
224
|
+
return { dash, refresh };
|
|
225
|
+
}
|
|
226
|
+
function repoRoot(dir) {
|
|
227
|
+
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8" });
|
|
228
|
+
return r.status === 0 ? r.stdout.trim() : dir;
|
|
229
|
+
}
|
|
230
|
+
export async function runView(args) {
|
|
231
|
+
const root = repoRoot(path.resolve(args.find((a) => !a.startsWith("-")) ?? process.cwd()));
|
|
232
|
+
const file = path.join(root, ".huddle", "huddle.db");
|
|
233
|
+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
234
|
+
if (!fs.existsSync(file))
|
|
235
|
+
console.log(C.red("✗ No Huddle room here yet.") + ` Start a run in ${root} (huddle, or huddle join "<goal>"), then run huddle view.`);
|
|
236
|
+
else
|
|
237
|
+
console.log(C.red("✗ huddle view needs a terminal.") + " For a plain-text replay use: huddle story");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
// Opened before the first run here: wait for its room instead of giving up.
|
|
241
|
+
if (!fs.existsSync(file)) {
|
|
242
|
+
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')}`);
|
|
243
|
+
await new Promise((resolve) => {
|
|
244
|
+
const t = setInterval(() => fs.existsSync(file) && (clearInterval(t), resolve()), 1000);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
const db = new RoomDB(file);
|
|
248
|
+
// Replay this run's feed so the panes aren't empty when you open late.
|
|
249
|
+
const { dash, refresh } = viewOf(db);
|
|
250
|
+
const poll = setInterval(refresh, 1000);
|
|
251
|
+
readline.emitKeypressEvents(process.stdin);
|
|
252
|
+
process.stdin.setRawMode(true);
|
|
253
|
+
await new Promise((resolve) => {
|
|
254
|
+
const quit = (_s, key) => {
|
|
255
|
+
if (key?.ctrl && key.name === "c")
|
|
256
|
+
close();
|
|
257
|
+
};
|
|
258
|
+
const close = () => {
|
|
259
|
+
process.stdin.off("keypress", quit);
|
|
260
|
+
dash.hide();
|
|
261
|
+
resolve();
|
|
262
|
+
};
|
|
263
|
+
process.stdin.on("keypress", quit);
|
|
264
|
+
dash.show(undefined, close);
|
|
265
|
+
});
|
|
266
|
+
clearInterval(poll);
|
|
267
|
+
process.stdin.setRawMode(false);
|
|
268
|
+
db.close();
|
|
269
|
+
process.exit(0);
|
|
270
|
+
}
|
|
271
|
+
//# sourceMappingURL=watch.js.map
|
package/install.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Huddle installer.
|
|
3
|
+
# curl -fsSL <this file> | sh
|
|
4
|
+
# Installs the `huddle` CLI from npm (huddle-cli). Then run `huddle`: it finds
|
|
5
|
+
# your agents (claude, codex, antigravity, cursor) and offers to install or
|
|
6
|
+
# sign in to the missing ones — always asking first. Huddle never handles
|
|
7
|
+
# your agents' credentials; each one signs in on its own.
|
|
8
|
+
#
|
|
9
|
+
# HUDDLE_SOURCE=<spec> install something else, e.g. a local .tgz or github:Preet37/Huddle
|
|
10
|
+
set -e
|
|
11
|
+
|
|
12
|
+
violet() { printf '\033[38;2;123;108;246m%s\033[0m\n' "$1"; }
|
|
13
|
+
fail() { printf '\033[31m✗ %s\033[0m\n' "$1" >&2; exit 1; }
|
|
14
|
+
|
|
15
|
+
command -v node >/dev/null 2>&1 || fail "Node.js 20+ is required — https://nodejs.org (or: nvm install 22)"
|
|
16
|
+
major=$(node -p 'process.versions.node.split(".")[0]')
|
|
17
|
+
[ "$major" -ge 20 ] || fail "Node.js 20+ is required (found $(node -v)) — https://nodejs.org (or: nvm install 22)"
|
|
18
|
+
command -v npm >/dev/null 2>&1 || fail "npm is required (it ships with Node.js — https://nodejs.org)"
|
|
19
|
+
command -v git >/dev/null 2>&1 || fail "git is required — macOS: xcode-select --install · Linux: your package manager"
|
|
20
|
+
|
|
21
|
+
# A system-wide Node often has a root-owned global folder; npm would then
|
|
22
|
+
# fail with EACCES halfway through. Say so up front instead of using sudo.
|
|
23
|
+
prefix=$(npm prefix -g 2>/dev/null || true)
|
|
24
|
+
if [ -n "$prefix" ] && [ -d "$prefix/lib" ] && [ ! -w "$prefix/lib" ]; then
|
|
25
|
+
fail "npm can't write to $prefix/lib (global installs need root there).
|
|
26
|
+
Pick one, then re-run this installer:
|
|
27
|
+
• use a Node version manager (no root needed): https://github.com/nvm-sh/nvm
|
|
28
|
+
• or give npm a folder you own: npm config set prefix ~/.npm-global && export PATH=~/.npm-global/bin:\$PATH"
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
violet "↓ installing huddle…"
|
|
32
|
+
# (A github: source builds with devDependencies; the teaser renderer's Chromium isn't needed.)
|
|
33
|
+
PUPPETEER_SKIP_DOWNLOAD=1 npm install -g "${HUDDLE_SOURCE:-huddle-cli}"
|
|
34
|
+
|
|
35
|
+
echo
|
|
36
|
+
if command -v huddle >/dev/null 2>&1; then
|
|
37
|
+
violet "✓ huddle $(huddle --version) installed"
|
|
38
|
+
echo " cd into any git repo and run: huddle"
|
|
39
|
+
echo " check this machine any time: huddle doctor"
|
|
40
|
+
else
|
|
41
|
+
violet "✓ huddle installed in $prefix/bin"
|
|
42
|
+
echo " that folder isn't on your PATH yet — add it, e.g.:"
|
|
43
|
+
echo " export PATH=\"$prefix/bin:\$PATH\""
|
|
44
|
+
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "huddle-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The multiplayer layer for AI coding agents. One command. Every agent. One repo. Zero collisions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"huddle": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist/**/*.js",
|
|
11
|
+
"install.sh",
|
|
12
|
+
"QUICKSTART.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/Preet37/Huddle.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/Preet37/Huddle#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/Preet37/Huddle/issues"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
30
|
+
"dev": "tsc --watch",
|
|
31
|
+
"start": "node dist/cli.js",
|
|
32
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
|
|
33
|
+
"prepare": "npm run build",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm test",
|
|
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"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"ai",
|
|
41
|
+
"agents",
|
|
42
|
+
"multiplayer",
|
|
43
|
+
"mcp",
|
|
44
|
+
"claude",
|
|
45
|
+
"codex",
|
|
46
|
+
"gemini",
|
|
47
|
+
"cursor",
|
|
48
|
+
"orchestration"
|
|
49
|
+
],
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
53
|
+
"nanoid": "^5.0.0",
|
|
54
|
+
"ws": "^8.18.0"
|
|
55
|
+
},
|
|
56
|
+
"optionalDependencies": {
|
|
57
|
+
"better-sqlite3": "^11.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^22.0.0",
|
|
61
|
+
"@types/ws": "^8.5.0",
|
|
62
|
+
"puppeteer": "^24.43.1",
|
|
63
|
+
"typescript": "^5.6.0"
|
|
64
|
+
}
|
|
65
|
+
}
|