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.
Files changed (73) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/QUICKSTART.md +135 -0
  3. package/README.md +26 -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 +9 -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/app/live.js +142 -0
  16. package/dist/app/page.js +701 -0
  17. package/dist/app/server.js +262 -0
  18. package/dist/args.js +18 -0
  19. package/dist/attach/connect.js +7 -4
  20. package/dist/attach/guard.js +21 -2
  21. package/dist/attach/live.js +5 -1
  22. package/dist/attach/plug.js +21 -3
  23. package/dist/attach/screen.js +150 -0
  24. package/dist/attach/session.js +7 -2
  25. package/dist/cli.js +134 -42
  26. package/dist/mcp/context-tools.js +79 -0
  27. package/dist/mcp/server.js +57 -11
  28. package/dist/orchestrator/ask.js +13 -10
  29. package/dist/orchestrator/brain.js +16 -4
  30. package/dist/orchestrator/budget.js +283 -0
  31. package/dist/orchestrator/busy.js +106 -0
  32. package/dist/orchestrator/council.js +434 -87
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +2 -2
  35. package/dist/orchestrator/launcher.js +31 -13
  36. package/dist/orchestrator/learn.js +101 -0
  37. package/dist/orchestrator/planner.js +54 -9
  38. package/dist/orchestrator/qa.js +71 -15
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +575 -50
  41. package/dist/orchestrator/takeover.js +155 -0
  42. package/dist/platform.js +377 -0
  43. package/dist/room/context.js +1285 -0
  44. package/dist/room/contract.js +132 -0
  45. package/dist/room/db.js +79 -16
  46. package/dist/room/followup.js +233 -0
  47. package/dist/room/identity.js +2 -2
  48. package/dist/room/lessons.js +275 -0
  49. package/dist/room/live.js +75 -20
  50. package/dist/room/mail.js +225 -0
  51. package/dist/room/progress.js +40 -1
  52. package/dist/room/server.js +220 -39
  53. package/dist/room/tunnel.js +6 -5
  54. package/dist/room/user.js +54 -0
  55. package/dist/sandbox/sync.js +4 -4
  56. package/dist/sandbox/worktree.js +145 -14
  57. package/dist/ui/app.js +963 -159
  58. package/dist/ui/brief.js +494 -0
  59. package/dist/ui/catalog.js +62 -24
  60. package/dist/ui/dashboard.js +269 -55
  61. package/dist/ui/doctor.js +15 -11
  62. package/dist/ui/export.js +1194 -0
  63. package/dist/ui/lessons.js +81 -0
  64. package/dist/ui/narrator.js +56 -0
  65. package/dist/ui/paste.js +515 -0
  66. package/dist/ui/remote.js +11 -5
  67. package/dist/ui/setup.js +152 -0
  68. package/dist/ui/story.js +716 -94
  69. package/dist/ui/talk.js +176 -0
  70. package/dist/ui/term.js +182 -16
  71. package/dist/ui/transcript.js +434 -0
  72. package/dist/ui/watch.js +149 -48
  73. 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
- /** 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.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: `◎ QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
117
+ return { text: `${C.violet("◎")} QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
60
118
  case "qa.bug":
61
- return { text: `🐞 ${who ?? "QA"} found: ${clip(p.title ?? p.body)}` };
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: `✓ QA round ${p.round ?? ""} clean` };
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: `⇄ ${p.to ?? who} took over ${p.from}'s “${clip(p.title, 60)}” (${clip(p.reason, 40)})` };
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
- return { startedAt: starts[0]?.ts ?? since, agents };
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
- 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);
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
- const feed = (list) => {
134
- for (const e of list.slice(seen)) {
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 = list.length;
225
+ seen = events.length;
147
226
  };
148
- feed(events);
149
- const poll = setInterval(() => {
150
- events = db.allEvents();
151
- stats = statsOf(db, events);
152
- feed(events);
153
- }, 1000);
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.0",
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": "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",