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.
Files changed (61) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/QUICKSTART.md +102 -0
  3. package/README.md +23 -4
  4. package/dist/adapters/amp.js +1 -1
  5. package/dist/adapters/antigravity.js +2 -0
  6. package/dist/adapters/claude.js +19 -3
  7. package/dist/adapters/codex.js +6 -1
  8. package/dist/adapters/cursor.js +12 -5
  9. package/dist/adapters/custom.js +4 -2
  10. package/dist/adapters/droid.js +1 -0
  11. package/dist/adapters/goose.js +2 -0
  12. package/dist/adapters/index.js +44 -18
  13. package/dist/adapters/kiro.js +1 -1
  14. package/dist/adapters/opencode.js +1 -1
  15. package/dist/attach/connect.js +4 -1
  16. package/dist/attach/guard.js +21 -2
  17. package/dist/attach/live.js +5 -1
  18. package/dist/attach/plug.js +14 -3
  19. package/dist/attach/screen.js +150 -0
  20. package/dist/attach/session.js +7 -2
  21. package/dist/cli.js +108 -40
  22. package/dist/mcp/context-tools.js +79 -0
  23. package/dist/mcp/server.js +38 -11
  24. package/dist/orchestrator/ask.js +13 -10
  25. package/dist/orchestrator/brain.js +13 -3
  26. package/dist/orchestrator/council.js +427 -86
  27. package/dist/orchestrator/goal.js +196 -0
  28. package/dist/orchestrator/handoff.js +2 -2
  29. package/dist/orchestrator/launcher.js +29 -13
  30. package/dist/orchestrator/learn.js +105 -0
  31. package/dist/orchestrator/planner.js +25 -7
  32. package/dist/orchestrator/qa.js +62 -14
  33. package/dist/orchestrator/radar.js +719 -0
  34. package/dist/orchestrator/scheduler.js +498 -39
  35. package/dist/orchestrator/takeover.js +155 -0
  36. package/dist/platform.js +273 -0
  37. package/dist/room/context.js +1285 -0
  38. package/dist/room/contract.js +132 -0
  39. package/dist/room/db.js +79 -16
  40. package/dist/room/followup.js +233 -0
  41. package/dist/room/identity.js +2 -2
  42. package/dist/room/lessons.js +275 -0
  43. package/dist/room/live.js +75 -20
  44. package/dist/room/mail.js +212 -0
  45. package/dist/room/progress.js +40 -1
  46. package/dist/room/server.js +169 -39
  47. package/dist/room/tunnel.js +6 -5
  48. package/dist/sandbox/sync.js +4 -4
  49. package/dist/sandbox/worktree.js +145 -14
  50. package/dist/ui/app.js +554 -133
  51. package/dist/ui/catalog.js +62 -24
  52. package/dist/ui/dashboard.js +194 -51
  53. package/dist/ui/doctor.js +15 -11
  54. package/dist/ui/lessons.js +81 -0
  55. package/dist/ui/paste.js +515 -0
  56. package/dist/ui/remote.js +11 -5
  57. package/dist/ui/setup.js +152 -0
  58. package/dist/ui/story.js +666 -69
  59. package/dist/ui/term.js +151 -16
  60. package/dist/ui/watch.js +145 -48
  61. 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
- /** Visible width of a string (ignores ANSI escapes). */
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
- return [...s.replace(/\x1b\[[0-9;]*m/g, "")].length;
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
- if (seen >= n - 1)
55
- return out + "…\x1b[0m";
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
- /** A set of lines that re-render in place while async work settles. */
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 (process.stdout.isTTY)
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 (!process.stdout.isTTY)
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
- if (this.drawn)
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
- process.stdout.write("\x1b[2K" + l + "\n");
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 (!process.stdout.isTTY) {
193
+ if (!this.out.isTTY) {
107
194
  for (const l of this.render(" "))
108
- process.stdout.write(l + "\n");
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
- /** One persisted event → a line for an agent's pane (agent set) or the room strip. */
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: `▸ ${clip(p.title)}` };
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}% ${clip(p.note)}` };
41
+ return { agent: who, text: `${C.faint(`${p.pct}%`)} ${clip(p.note)}` };
38
42
  case "plan.announced":
39
- return { agent: who, text: `plans: ${clip(p.summary ?? p.plan ?? p.intent ?? p.title)}` };
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: `◎ reviewing ${p.author}'s “${clip(p.title, 60)}”` };
45
+ return { agent: who, text: `${C.violet("◎")} reviewing ${p.author}'s “${clip(p.title, 60)}”` };
42
46
  case "run.reviewed":
43
- return { agent: who, text: p.skipped ? `review skipped (${clip(p.notes, 40)})` : p.approve ? `✓ approved ${p.author}'s work` : `✗ asked ${p.author} for changes: ${clip(p.notes)}` };
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: `✓ merged ${clip(p.title, 80)}` };
56
+ return { agent: who, text: `${C.green("✓ merged")} ${clip(p.title, 80)}` };
46
57
  case "run.failed":
47
- return { agent: who, text: `✗ failed ${clip(p.title, 60)} — ${clip(p.reason ?? p.error, 80)}` };
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
- case "memory.added":
51
- return { text: `◆ ${p.author ?? who ?? "memory"}: ${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
+ }
52
77
  case "council.proposal":
53
- return { agent: who, text: `proposes: ${clip(p.text)}` };
78
+ return { agent: who, text: `${C.faint("proposes:")} ${clip(p.text)}` };
54
79
  case "council.critique":
55
- return { agent: who, text: `flags: ${clip(p.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: `◇ council plan ready` };
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: `◎ QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
113
+ return { text: `${C.violet("◎")} QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
60
114
  case "qa.bug":
61
- return { text: `🐞 ${who ?? "QA"} found: ${clip(p.title ?? p.body)}` };
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: `✓ QA round ${p.round ?? ""} 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;
64
128
  default:
65
129
  if (e.type.startsWith("takeover") || e.type === "run.takeover")
66
- return { text: `⇄ ${p.to ?? who} took over ${p.from}'s “${clip(p.title, 60)}” (${clip(p.reason, 40)})` };
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
- return { startedAt: starts[0]?.ts ?? since, agents };
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
- function repoRoot(dir) {
107
- const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8" });
108
- return r.status === 0 ? r.stdout.trim() : dir;
109
- }
110
- export async function runView(args) {
111
- const root = repoRoot(path.resolve(args.find((a) => !a.startsWith("-")) ?? process.cwd()));
112
- const file = path.join(root, ".huddle", "huddle.db");
113
- if (!fs.existsSync(file)) {
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
- const feed = (list) => {
134
- for (const e of list.slice(seen)) {
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 = list.length;
221
+ seen = events.length;
147
222
  };
148
- feed(events);
149
- const poll = setInterval(() => {
150
- events = db.allEvents();
151
- stats = statsOf(db, events);
152
- feed(events);
153
- }, 1000);
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.0",
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": "rm -rf .test-build && tsc -p tsconfig.test.json && HUDDLE_NO_AGENTS_FILE=1 node --test --test-concurrency=1 .test-build/test/*.test.js",
34
- "e2e": "npm run build && rm -rf .test-build && tsc -p tsconfig.test.json && node .test-build/test/e2e/run.js"
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",