huddle-cli 0.1.0 → 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/QUICKSTART.md +102 -0
- package/README.md +23 -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 +6 -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/attach/connect.js +4 -1
- package/dist/attach/guard.js +21 -2
- package/dist/attach/live.js +5 -1
- package/dist/attach/plug.js +14 -3
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +7 -2
- package/dist/cli.js +108 -40
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +38 -11
- package/dist/orchestrator/ask.js +13 -10
- package/dist/orchestrator/brain.js +13 -3
- package/dist/orchestrator/council.js +427 -86
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +2 -2
- package/dist/orchestrator/launcher.js +29 -13
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +25 -7
- package/dist/orchestrator/qa.js +62 -14
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +498 -39
- package/dist/orchestrator/takeover.js +155 -0
- package/dist/platform.js +273 -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 +212 -0
- package/dist/room/progress.js +40 -1
- package/dist/room/server.js +169 -39
- package/dist/room/tunnel.js +6 -5
- package/dist/sandbox/sync.js +4 -4
- package/dist/sandbox/worktree.js +145 -14
- package/dist/ui/app.js +554 -133
- package/dist/ui/catalog.js +62 -24
- package/dist/ui/dashboard.js +194 -51
- package/dist/ui/doctor.js +15 -11
- package/dist/ui/lessons.js +81 -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 +666 -69
- package/dist/ui/term.js +151 -16
- package/dist/ui/watch.js +145 -48
- package/package.json +6 -3
package/dist/ui/term.js
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
// Terminal primitives: color, width-aware padding, the rounded box, spinners.
|
|
2
2
|
// Huddle lives in the terminal only, so this file is the whole "UI toolkit".
|
|
3
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
|
+
}
|
|
4
39
|
const sgr = (open) => (s) => (color ? `\x1b[${open}m${s}\x1b[0m` : s);
|
|
5
40
|
const hex = (h) => {
|
|
6
41
|
const n = parseInt(h.slice(1), 16);
|
|
@@ -33,9 +68,21 @@ export const AGENT_COLOR = {
|
|
|
33
68
|
};
|
|
34
69
|
/** Colored dot for an agent; "claude#2" gets Claude's color. */
|
|
35
70
|
export const dot = (agentId) => hex(AGENT_COLOR[agentId.split("#")[0]] ?? "#8A857C")("●");
|
|
36
|
-
/**
|
|
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). */
|
|
37
81
|
export function width(s) {
|
|
38
|
-
|
|
82
|
+
let n = 0;
|
|
83
|
+
for (const ch of s.replace(/\x1b\[[0-9;]*m/g, ""))
|
|
84
|
+
n += cells(ch);
|
|
85
|
+
return n;
|
|
39
86
|
}
|
|
40
87
|
/** Cut to `n` visible characters, keeping color codes intact. */
|
|
41
88
|
export function truncate(s, n) {
|
|
@@ -51,14 +98,42 @@ export function truncate(s, n) {
|
|
|
51
98
|
continue;
|
|
52
99
|
}
|
|
53
100
|
const ch = String.fromCodePoint(s.codePointAt(i));
|
|
54
|
-
|
|
55
|
-
|
|
101
|
+
const w = cells(ch);
|
|
102
|
+
if (seen + w > n - 1)
|
|
103
|
+
return out + "…" + (out.includes("\x1b[") ? "\x1b[0m" : "");
|
|
56
104
|
out += ch;
|
|
57
|
-
seen
|
|
105
|
+
seen += w;
|
|
58
106
|
i += ch.length;
|
|
59
107
|
}
|
|
60
108
|
return out;
|
|
61
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
|
+
}
|
|
62
137
|
export function pad(s, n) {
|
|
63
138
|
return s + " ".repeat(Math.max(0, n - width(s)));
|
|
64
139
|
}
|
|
@@ -71,44 +146,104 @@ export function box(lines, opts = {}) {
|
|
|
71
146
|
const mid = lines.map((l) => border("│") + " " + pad(l, inner - 2) + " " + border("│"));
|
|
72
147
|
return [top, ...mid, bot].join("\n");
|
|
73
148
|
}
|
|
74
|
-
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
75
|
-
/**
|
|
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
|
+
*/
|
|
76
157
|
export class LiveLines {
|
|
77
158
|
render;
|
|
159
|
+
out;
|
|
78
160
|
lines;
|
|
79
161
|
frame = 0;
|
|
80
162
|
timer;
|
|
81
163
|
drawn = false;
|
|
82
|
-
constructor(render) {
|
|
164
|
+
constructor(render, out = process.stdout) {
|
|
83
165
|
this.render = render;
|
|
166
|
+
this.out = out;
|
|
84
167
|
this.lines = [];
|
|
85
168
|
}
|
|
86
169
|
start() {
|
|
87
170
|
this.draw();
|
|
88
|
-
if (
|
|
171
|
+
if (this.out.isTTY)
|
|
89
172
|
this.timer = setInterval(() => this.draw(), 80);
|
|
90
173
|
return this;
|
|
91
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
|
+
}
|
|
92
179
|
draw() {
|
|
93
|
-
if (!
|
|
180
|
+
if (!this.out.isTTY)
|
|
94
181
|
return; // non-TTY: draw once at stop()
|
|
95
|
-
const next = this.render(C.violet(FRAMES[this.frame++ % FRAMES.length]));
|
|
96
|
-
|
|
97
|
-
process.stdout.write(`\x1b[${this.lines.length}A`);
|
|
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` : "";
|
|
98
184
|
for (const l of next)
|
|
99
|
-
|
|
185
|
+
s += "\x1b[2K" + l + "\n";
|
|
186
|
+
this.out.write(s); // one write: no half-drawn frame
|
|
100
187
|
this.lines = next;
|
|
101
188
|
this.drawn = true;
|
|
102
189
|
}
|
|
103
190
|
stop() {
|
|
104
191
|
if (this.timer)
|
|
105
192
|
clearInterval(this.timer);
|
|
106
|
-
if (!
|
|
193
|
+
if (!this.out.isTTY) {
|
|
107
194
|
for (const l of this.render(" "))
|
|
108
|
-
|
|
195
|
+
this.out.write(l + "\n");
|
|
109
196
|
return;
|
|
110
197
|
}
|
|
111
198
|
this.draw();
|
|
112
199
|
}
|
|
113
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
|
+
}
|
|
114
249
|
//# sourceMappingURL=term.js.map
|
package/dist/ui/watch.js
CHANGED
|
@@ -23,47 +23,111 @@ 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.pair":
|
|
111
|
+
return { agent: who, text: `${C.violet("⇉")} pairs with ${p.with} on “${clip(p.title, 60)}”: ${clip(p.part, 80)}` };
|
|
58
112
|
case "qa.round":
|
|
59
|
-
return { text:
|
|
113
|
+
return { text: `${C.violet("◎")} QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
|
|
60
114
|
case "qa.bug":
|
|
61
|
-
return { text:
|
|
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") };
|
|
62
118
|
case "qa.clean":
|
|
63
|
-
return { text:
|
|
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;
|
|
64
128
|
default:
|
|
65
129
|
if (e.type.startsWith("takeover") || e.type === "run.takeover")
|
|
66
|
-
return { text:
|
|
130
|
+
return { text: `${C.yellow("⤳")} ${p.to ?? who} took over ${p.from}'s “${clip(p.title, 60)}” (${clip(p.reason, 40)})` };
|
|
67
131
|
return null;
|
|
68
132
|
}
|
|
69
133
|
}
|
|
@@ -86,12 +150,21 @@ export function statsOf(db, events) {
|
|
|
86
150
|
get(a.id);
|
|
87
151
|
for (const e of run) {
|
|
88
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;
|
|
89
158
|
const id = e.agent_id ?? p.agent;
|
|
90
159
|
if (!id || ROOM_AUTHORS.has(id))
|
|
91
160
|
continue;
|
|
92
161
|
const a = get(id);
|
|
93
162
|
if (e.type === "run.start" || e.type === "run.review")
|
|
94
163
|
a.busySince = e.ts;
|
|
164
|
+
else if (e.type === "qa.done") {
|
|
165
|
+
a.busySince = null;
|
|
166
|
+
a.qa = (a.qa ?? 0) + 1;
|
|
167
|
+
}
|
|
95
168
|
else if (e.type === "run.merged" || e.type === "run.failed" || e.type === "run.reviewed") {
|
|
96
169
|
a.busySince = null;
|
|
97
170
|
if (e.type === "run.merged")
|
|
@@ -101,37 +174,39 @@ export function statsOf(db, events) {
|
|
|
101
174
|
a.tokens += Number(p.tokens) || 0;
|
|
102
175
|
}
|
|
103
176
|
}
|
|
104
|
-
|
|
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 };
|
|
105
182
|
}
|
|
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);
|
|
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;
|
|
124
191
|
const dash = new Dashboard({
|
|
125
192
|
db: () => db,
|
|
126
193
|
stats: () => stats,
|
|
127
194
|
modeChip: () => C.faint("watching · read-only"),
|
|
128
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",
|
|
129
197
|
});
|
|
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
198
|
let seen = 0; // events come back ordered by (ts, rowid), so new ones are a suffix
|
|
133
|
-
|
|
134
|
-
|
|
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)) {
|
|
135
210
|
if (e.ts < since)
|
|
136
211
|
continue;
|
|
137
212
|
const a = activityOf(e);
|
|
@@ -143,14 +218,36 @@ export async function runView(args) {
|
|
|
143
218
|
if (a)
|
|
144
219
|
dash.push(a);
|
|
145
220
|
}
|
|
146
|
-
seen =
|
|
221
|
+
seen = events.length;
|
|
147
222
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
154
251
|
readline.emitKeypressEvents(process.stdin);
|
|
155
252
|
process.stdin.setRawMode(true);
|
|
156
253
|
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.1",
|
|
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",
|