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
@@ -1,18 +1,131 @@
1
1
  // The live view — the full-screen dashboard from the launch film.
2
2
  //
3
- // ⬢ huddle 1 agents 2 plan 3 board room: <goal> ● live ⏱ 1m12s
4
- // ┌ ● claude anthropic ─ working 40s · 31k ┐┌ ● codex openai ─ idle ───────┐
5
- // │ ▸ Add greet(name) with tests ││ ✓ merged Add farewell… │
6
- // │ plans: CommonJS greet.js … ││ │
7
- // └─────────────────────────────────────────┘└────────────────────────────────┘
8
- // ◆ harsh/codex → room what shape is the Order type?
3
+ // ⬢ huddle 1 agents 2 plan 3 board BUILD · ● live · ⏱ 1m12s
4
+ // ╭─ ● claude working 40s · 31k tok ──────╮╭─ ● codex idle · 1 merged ───╮
5
+ // │ ▸ Add greet(name) with tests 60% ││ ✓ merged Add farewell… │
6
+ // │ plans: CommonJS greet.js … ││ │
7
+ // ╰────────────────────────────────────────╯╰───────────────────────────────╯
8
+ // ◆ codex → claude: what shape is the Order type?
9
9
  // 1 2 3 switch · q back to the prompt · ctrl-c stop agents
10
10
  //
11
11
  // It only reads: the Room's board and memory, the run's live stats, and the
12
- // activity feed. Pressing q hands the terminal back to the prompt.
13
- import { C, dot, pad, truncate, width, AGENT_COLOR } from "./term.js";
12
+ // activity feed. Pressing q hands the terminal back to the prompt. Every row
13
+ // is cut to the screen's width — a row that wraps scrolls the whole frame.
14
+ import { BARS, C, bar, dot, pad, truncate, width, AGENT_COLOR } from "./term.js";
14
15
  import { fmtDuration, fmtTokens } from "../adapters/usage.js";
16
+ import { taskViews } from "../room/progress.js";
17
+ import { getContract } from "../room/contract.js";
18
+ import { screenPanel } from "../room/context.js";
19
+ const PHASE_OF = {
20
+ "council.proposal": "COUNCIL",
21
+ "council.critique": "COUNCIL",
22
+ "council.adopt": "COUNCIL",
23
+ "council.flaw": "COUNCIL",
24
+ "council.ratify": "COUNCIL",
25
+ "council.plan": "PLAN",
26
+ "plan.made": "PLAN",
27
+ "contract.set": "PLAN",
28
+ "run.start": "BUILD",
29
+ "qa.round": "QA",
30
+ };
31
+ /**
32
+ * The run's phase: the latest phase-marking event since the session began,
33
+ * and DONE once every task on the board is merged or failed and no agent is busy.
34
+ */
35
+ export function runPhase(db, busy) {
36
+ if (!db)
37
+ return "WAITING";
38
+ let tasks = [];
39
+ let since = 0;
40
+ try {
41
+ tasks = db.listTasks();
42
+ since = (db.currentSession()?.created_at ?? 1) - 1;
43
+ }
44
+ catch {
45
+ return "WAITING"; // a closed db
46
+ }
47
+ const last = db.eventsOfType(Object.keys(PHASE_OF), since, 1).at(-1);
48
+ let phase = last ? PHASE_OF[last.type] : "WAITING";
49
+ // A QA round's fix tasks are BUILD work, but the run is still in QA.
50
+ if (phase !== "QA" && phase !== "WAITING" && db.eventsOfType(["qa.round"], since, 1).length)
51
+ phase = "QA";
52
+ if (!busy && tasks.length && tasks.every((t) => t.status === "merged" || t.status === "failed"))
53
+ return "DONE";
54
+ return phase;
55
+ }
56
+ /** QA's own "QA round 2: check the build" rows: checks, not work to merge. */
57
+ export const isQaCheck = (t) => /^QA round \d+:/.test(t.title) && /"qa"/.test(t.prefers ?? "");
58
+ /** Merged / failed / all of the board's work tasks. */
59
+ export function taskCounts(tasks) {
60
+ const work = tasks.filter((t) => !isQaCheck(t));
61
+ return { done: work.filter((t) => t.status === "merged").length, total: work.length, failed: work.filter((t) => t.status === "failed").length };
62
+ }
63
+ const COUNCIL_STEP = { "council.proposal": 1, "council.critique": 2, "council.adopt": 2, "council.flaw": 2, "council.ratify": 4 };
64
+ /** The run's progress from the Room alone (huddle view, the ctrl-t view). */
65
+ export function runProgress(db, busy) {
66
+ const phase = runPhase(db, busy);
67
+ if (!db || phase === "WAITING")
68
+ return { phase, done: 0, total: 0, failed: 0 };
69
+ try {
70
+ const since = (db.currentSession()?.created_at ?? 1) - 1;
71
+ const p = { phase, ...taskCounts(db.listTasks()) };
72
+ if (phase === "COUNCIL")
73
+ p.step = COUNCIL_STEP[db.eventsOfType(Object.keys(COUNCIL_STEP), since, 1).at(-1)?.type ?? ""] ?? 1;
74
+ const rounds = db.eventsOfType(["qa.round"], since, 50).length;
75
+ if (rounds)
76
+ p.round = rounds;
77
+ return p;
78
+ }
79
+ catch {
80
+ return { phase, done: 0, total: 0, failed: 0 }; // a closed db
81
+ }
82
+ }
83
+ /**
84
+ * The overall run bar, one line of at most `W` cells:
85
+ * BUILD ███████░░░░░ 6/9 tasks · 12m03s
86
+ * COUNCIL ██████░░░░ step 3/4 · 2m10s
87
+ * QA ████████████░ round 2 · 9/10 tasks · 1 failed · 31m
88
+ * ✓ DONE █████████████ 10/10 tasks · 41m
89
+ * The bar gives way first, then the word "tasks"; with bars off (NO_COLOR,
90
+ * piped) it's the words alone: "BUILD 6/9 tasks · 12m03s". "" while waiting.
91
+ */
92
+ export function progressLine(p, ms, W, o = {}) {
93
+ if (p.phase === "WAITING")
94
+ return "";
95
+ const done = p.phase === "DONE";
96
+ const head = done ? (p.failed ? C.yellow("◎ DONE") : C.green("✓ DONE")) : C.bold(C.violet(p.phase));
97
+ const frac = p.phase === "COUNCIL" ? (p.step ?? 1) / 4 : p.total ? p.done / p.total : done ? 1 : null;
98
+ const tasks = (word) => (p.total ? `${p.done}/${p.total}${word ? " tasks" : ""}` : p.phase === "PLAN" ? "planning" : "");
99
+ const label = (word) => p.phase === "COUNCIL" ? `step ${p.step ?? 1}/4` : [p.phase === "QA" && p.round ? `round ${p.round}` : "", tasks(word)].filter(Boolean).join(" · ");
100
+ const text = (word) => [label(word), p.failed ? C.red(`${p.failed} failed`) : "", ms === null ? "" : C.faint(fmtDuration(ms))].filter(Boolean).join(C.faint(" · "));
101
+ const tone = done ? (p.failed ? "failed" : "done") : "run";
102
+ for (const word of [true, false]) {
103
+ const w = Math.min(o.barMax ?? 24, W - width(head) - width(text(word)) - 2);
104
+ if (w >= 4 && (o.on ?? BARS.on))
105
+ return `${head} ${bar(frac, w, { tone, on: o.on })} ${text(word)}`;
106
+ }
107
+ return truncate(`${head} ${text(W >= 40)}`, W);
108
+ }
109
+ /**
110
+ * A task's mini bar and its number, the same everywhere: "████░░░░ 60%";
111
+ * never reported "········ —"; merged a full green bar and ✓; failed red.
112
+ * With bars off it's the number alone ("60%", "—", "✓").
113
+ */
114
+ export function taskMeter(pct, w, status) {
115
+ const tone = status === "merged" ? "done" : status === "failed" ? "failed" : "run";
116
+ const num = status === "merged" ? C.green(" ✓") : pct === null ? C.faint(" —") : (status === "failed" ? C.red : C.violet)(`${String(Math.round(pct)).padStart(3)}%`);
117
+ const b = bar(pct === null ? null : pct / 100, w, { tone });
118
+ return b ? `${b} ${num}` : num;
119
+ }
120
+ /** "est 5m" for a task with an estimate, yellow "over est 5m" once it runs past it; "" with none. */
121
+ export function estHint(estS, ranMs) {
122
+ if (!estS || estS <= 0)
123
+ return "";
124
+ return ranMs > estS * 1000 ? C.yellow(`over est ${fmtDuration(estS * 1000)}`) : C.faint(`est ${fmtDuration(estS * 1000)}`);
125
+ }
15
126
  const hexColor = (id) => C.hex(AGENT_COLOR[id.split("/").pop().split("#")[0]] ?? "#8A857C");
127
+ /** The shared screen's 📎/📌 rows in the view's glyphs (emoji are two cells wide and vary by font). */
128
+ const screenGlyphs = (l) => l.replace(/^📎/u, C.violet("+")).replace(/^📌/u, C.violet("◆"));
16
129
  export class Dashboard {
17
130
  src;
18
131
  tab = "agents";
@@ -82,105 +195,206 @@ export class Dashboard {
82
195
  render() {
83
196
  if (!this.open)
84
197
  return;
85
- const W = process.stdout.columns || 100;
86
- const H = process.stdout.rows || 30;
198
+ const lines = this.frame(process.stdout.columns || 100, process.stdout.rows || 30);
199
+ process.stdout.write("\x1b[H" + lines.map((l) => "\x1b[2K" + l).join("\n"));
200
+ }
201
+ /** One full screen, `H` rows each at most `W` cells (also how tests and samples render it). */
202
+ frame(W, H, tab = this.tab, now = Date.now()) {
87
203
  const db = this.src.db();
88
204
  const stats = this.src.stats();
89
- const goal = db?.currentSession()?.goal ?? "no goal yet — press q and type one";
205
+ const busy = !!stats && [...stats.agents.values()].some((a) => a.busySince);
206
+ let goal = "";
207
+ try {
208
+ goal = db?.currentSession()?.goal ?? "";
209
+ }
210
+ catch {
211
+ /* a closed db */
212
+ }
90
213
  const tabs = ["agents", "plan", "board"]
91
- .map((t, i) => (t === this.tab ? C.bold(C.violet(` ${i + 1} ${t} `)) : C.faint(` ${i + 1} ${t} `)))
214
+ .map((t, i) => (t === tab ? C.bold(C.violet(`[${i + 1} ${t}]`)) : C.faint(` ${i + 1} ${t} `)))
92
215
  .join(" ");
93
- const right = `${C.faint("room:")} ${truncate(goal, Math.max(10, W - 70))} ` +
94
- (stats ? `${C.green("● live")} ${C.faint("⏱")} ${fmtDuration(Date.now() - stats.startedAt)}` : C.faint("○ idle"));
95
- const bar = ` ${C.violet("⬢")} ${C.bold("huddle")} ${tabs}`;
96
- const top = bar + " ".repeat(Math.max(1, W - width(bar) - width(right) - 1)) + right;
216
+ // The overall bar: the run's own live numbers when it's this process's run, else the Room's.
217
+ const prog = stats?.run ?? runProgress(db, busy);
218
+ const ms = stats ? (stats.endedAt ?? now) - stats.startedAt : null;
219
+ // On a narrow screen the live chip is just its dot: the bar needs the room.
220
+ const chip = prog.phase === "DONE" || prog.phase === "WAITING" ? "" : busy ? C.green(W >= 100 ? "● live" : "●") : C.faint(W >= 100 ? "○ between steps" : "○");
221
+ const head = ` ${C.violet("⬢")} ${C.bold("huddle")} ${tabs}`;
222
+ const right = prog.phase === "WAITING"
223
+ ? C.faint("○ waiting for a run")
224
+ : [progressLine(prog, ms, Math.max(12, W - width(head) - width(chip) - 5), { barMax: W >= 110 ? 12 : 10 }), chip].filter(Boolean).join(C.faint(" · "));
225
+ // The goal takes whatever room is left between the tabs and the status.
226
+ const room = W - width(head) - width(right) - 4;
227
+ const goalS = goal && room >= 12 ? C.faint(truncate(goal.replace(/\s+/g, " "), room)) : "";
228
+ const gap = Math.max(1, W - width(head) - width(goalS) - width(right) - 3);
229
+ const top = goalS ? `${head} ${goalS}${" ".repeat(gap)}${right}` : `${head}${" ".repeat(Math.max(1, W - width(head) - width(right) - 1))}${right}`;
97
230
  const bodyH = H - 4;
98
- const body = this.tab === "agents" ? this.agents(W, bodyH, stats) : this.tab === "plan" ? this.plan(W, bodyH, db) : this.board(W, bodyH, db);
231
+ const body = tab === "agents" ? this.agents(W, bodyH, stats, db, now) : tab === "plan" ? this.plan(W, bodyH, db) : this.board(W, bodyH, db);
99
232
  const lastRoom = [...this.feed].reverse().find((a) => !a.agent);
100
- const strip = lastRoom ? ` ${truncate(lastRoom.text, W - 2)}` : C.faint(" the room is quiet");
101
- const hint = C.faint(` ${this.src.hint ?? "1 2 3 switch · q back to the prompt · ctrl-c stop agents"} ${this.src.modeChip()}`);
102
- const lines = [truncate(top, W), C.faint("─".repeat(W)), ...body.slice(0, bodyH), strip, hint];
103
- while (lines.length < H)
104
- lines.splice(lines.length - 2, 0, "");
105
- process.stdout.write("\x1b[H" + lines.slice(0, H).map((l) => "\x1b[2K" + truncate(l, W)).join("\n"));
233
+ const strip = lastRoom ? ` ${lastRoom.text}` : C.faint(" the room is quiet");
234
+ const hint = C.faint(` ${this.src.hint ?? "1 2 3 switch · q back to the prompt · ctrl-c stop agents"} `) + this.src.modeChip();
235
+ const lines = [top, C.faint("─".repeat(W)), ...body.slice(0, bodyH)];
236
+ while (lines.length < H - 2)
237
+ lines.push("");
238
+ lines.push(strip, hint);
239
+ return lines.slice(0, H).map((l) => truncate(l.replace(/[\r\n]+/g, " "), W));
106
240
  }
107
241
  /** Tab 1: a pane per agent — what each one is doing right now. */
108
- agents(W, H, stats) {
242
+ agents(W, H, stats, db, now) {
109
243
  const ids = stats ? [...stats.agents.keys()] : [...new Set(this.feed.filter((a) => a.agent).map((a) => a.agent))];
110
244
  if (!ids.length)
111
- return [C.faint(" no agents working — give Huddle a goal")];
245
+ return ["", C.faint(` ${this.src.empty ?? "no agents working — give Huddle a goal"}`)];
246
+ let views = [];
247
+ let ests = new Map();
248
+ try {
249
+ views = db ? taskViews(db, now) : [];
250
+ ests = new Map((db?.listTasks() ?? []).map((t) => [t.id, t.est_seconds]));
251
+ }
252
+ catch {
253
+ /* a closed db: no pinned task line */
254
+ }
112
255
  const cols = W >= 110 && ids.length > 1 ? 2 : 1;
113
256
  const rows = Math.ceil(ids.length / cols);
114
257
  const paneW = Math.floor(W / cols);
115
258
  const paneH = Math.max(4, Math.floor(H / rows));
116
259
  const out = [];
117
260
  for (let r = 0; r < rows; r++) {
118
- const panes = ids.slice(r * cols, r * cols + cols).map((id) => this.pane(id, paneW, paneH, stats?.agents.get(id)));
261
+ const panes = ids.slice(r * cols, r * cols + cols).map((id) => this.pane(id, paneW, paneH, stats?.agents.get(id), views, now, ests));
119
262
  for (let i = 0; i < paneH; i++)
120
263
  out.push(panes.map((p) => p[i] ?? " ".repeat(paneW)).join(""));
121
264
  }
122
265
  return out;
123
266
  }
124
- pane(id, w, h, a) {
267
+ pane(id, w, h, a, views, now, ests) {
125
268
  const color = hexColor(id);
126
269
  const status = a?.busySince
127
- ? `${C.violet("working")} ${fmtDuration(Date.now() - a.busySince)}${a.liveTokens > 0 ? " · " + fmtTokens(a.liveTokens) + " tok" : ""}`
128
- : a && (a.tasks || a.reviews)
129
- ? C.green(`done · ${a.tasks} task${a.tasks === 1 ? "" : "s"}${a.reviews ? ` + ${a.reviews} review` : ""}`)
270
+ ? `${C.violet("working")} ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? " · " + fmtTokens(a.liveTokens) + " tok" : ""}`
271
+ : a && (a.tasks || a.reviews || a.qa)
272
+ ? C.green(`idle · ${a.tasks} merged${a.reviews ? ` · ${a.reviews} review${a.reviews === 1 ? "" : "s"}` : ""}${a.qa ? ` · ${a.qa} QA` : ""}`)
130
273
  : C.faint("idle");
131
- const title = ` ${dot(id)} ${C.bold(id)} ${status} `;
132
274
  const inner = w - 2;
275
+ const title = truncate(` ${dot(id)} ${C.bold(id)} ${status} `, inner - 1);
133
276
  const topLine = color("╭─") + title + color("─".repeat(Math.max(0, inner - 1 - width(title))) + "╮");
134
- const lines = this.feed.filter((x) => x.agent === id).slice(-(h - 2));
277
+ // The task it's on stays pinned at the top: title, %, how long since it last said anything.
278
+ const on = a?.busySince ? views.find((v) => v.owner === id && (v.status === "claimed" || v.status === "in_progress")) : undefined;
279
+ const pinned = [];
280
+ if (on) {
281
+ const meter = taskMeter(on.reported ? on.progress_pct : null, inner >= 70 ? 10 : 6);
282
+ const said = on.reported ? (on.last_note ? C.faint(` · note ${fmtDuration(on.last_update_s * 1000)} ago`) : "") : C.faint(" · no report yet");
283
+ const est = estHint(ests.get(on.id), (on.age_s ?? 0) * 1000);
284
+ const tail = ` ${meter}${said}${est ? C.faint(" · ") + est : ""}`;
285
+ pinned.push(`${C.violet("▸")} ${C.bold(truncate(on.title, Math.max(8, inner - 4 - width(tail))))}${tail}`);
286
+ }
287
+ const room = h - 2 - pinned.length;
288
+ const lines = [...pinned, ...this.feed.filter((x) => x.agent === id).slice(-room).map((x) => x.text)];
135
289
  const body = Array.from({ length: h - 2 }, (_, i) => {
136
- const l = lines[i] ? " " + truncate(lines[i].text, inner - 2) : "";
290
+ const l = lines[i] ? " " + truncate(lines[i], inner - 2) : "";
137
291
  return color("│") + pad(l, inner) + color("│");
138
292
  });
139
293
  return [topLine, ...body, color("╰" + "─".repeat(inner) + "╯")];
140
294
  }
141
- /** Tab 2: the plan on the left, the room talking on the right. */
295
+ /** Tab 2: the plan, and the room talking (side by side when there's room, stacked when not). */
142
296
  plan(W, H, db) {
143
- const leftW = Math.floor(W * 0.55);
144
297
  const left = [C.violet(" PLAN")];
145
- const tasks = db?.listTasks() ?? [];
298
+ let tasks = [];
299
+ try {
300
+ tasks = db?.listTasks() ?? [];
301
+ }
302
+ catch {
303
+ /* a closed db */
304
+ }
146
305
  const idx = new Map(tasks.map((t, i) => [t.id, i + 1]));
147
306
  tasks.forEach((t, i) => {
148
307
  const deps = JSON.parse(t.depends_on || "[]");
149
308
  const after = deps.length ? C.faint(` after ${deps.map((d) => idx.get(d)).join(",")}`) : "";
150
309
  const who = t.claimed_by ? ` ${dot(t.claimed_by)} ${C.faint(t.claimed_by)}` : "";
151
- left.push(` ${C.faint(String(i + 1) + ".")} ${t.title}${after}${who}`);
152
- left.push(C.faint(` ${t.status}`));
310
+ left.push(` ${mark(t.status, !!t.claimed_by)} ${C.faint(String(i + 1).padStart(2) + ".")} ${t.title}${after}${who}`);
153
311
  });
154
312
  if (!tasks.length)
155
313
  left.push(C.faint(" no plan yet"));
314
+ let contract = null;
315
+ try {
316
+ contract = db ? getContract(db) : null;
317
+ }
318
+ catch {
319
+ /* a closed db: no contract line */
320
+ }
321
+ if (contract && (contract.text || contract.amendments.length)) {
322
+ left.push("", `${C.violet(" CONTRACT")} ${C.faint(`v${contract.version} · ${contract.amendments.length} amendment(s)`)}`);
323
+ for (const a of contract.amendments.slice(-3))
324
+ left.push(C.faint(` v${a.version} ${a.by}: ${a.section}: ${a.change}`));
325
+ }
156
326
  const right = [C.violet(" ROOM") + C.faint(" · agents & people talking")];
157
327
  for (const a of this.feed.filter((x) => !x.agent).slice(-(H - 1)))
158
328
  right.push(" " + a.text);
159
- return columns(left, right, leftW, W, H);
329
+ if (right.length === 1)
330
+ right.push(C.faint(" nothing said yet"));
331
+ return columns(left, right, W, H, true);
160
332
  }
161
- /** Tab 3: progress on the left, shared memory on the right. */
333
+ /** Tab 3: progress, and the shared screen + memory. */
162
334
  board(W, H, db) {
163
- const leftW = Math.floor(W * 0.55);
164
335
  const left = [C.violet(" BOARD") + C.faint(" · live progress")];
165
- for (const t of db?.listTasks() ?? []) {
166
- const bw = 14;
167
- const filled = Math.round((t.progress_pct / 100) * bw);
168
- const barS = t.status === "merged" ? C.green("█".repeat(bw)) : C.violet("█".repeat(filled)) + C.faint("░".repeat(bw - filled));
169
- const st = t.status === "merged" ? C.green("✓") : t.status === "failed" ? C.red("✗") : t.status === "needs_you" ? C.yellow("!") : t.claimed_by ? C.violet("▸") : C.faint("○");
170
- left.push(` ${st} ${barS} ${String(t.progress_pct).padStart(3)}% ${t.title}`);
171
- if (t.claimed_by)
172
- left.push(C.faint(` ${t.claimed_by}`));
173
- }
174
- const right = [C.violet(" SHARED MEMORY") + C.faint(" · everyone reads & writes")];
175
- for (const m of (db?.recall() ?? []).slice(0, H - 1)) {
176
- right.push(` ${C.faint("•")} ${m.body}`);
336
+ let views = [];
337
+ try {
338
+ views = db ? taskViews(db) : [];
339
+ }
340
+ catch {
341
+ /* a closed db */
342
+ }
343
+ const bw = W >= 100 ? 14 : 8;
344
+ for (const t of views) {
345
+ // Never reported (no huddle_progress yet): say so instead of showing a made-up 0%.
346
+ const silent = ["claimed", "in_progress"].includes(t.status) && !t.reported;
347
+ const who = t.owner ? ` ${dot(t.owner)} ${C.faint(t.owner)}` : "";
348
+ left.push(` ${mark(t.status, !!t.owner)} ${taskMeter(silent ? null : t.progress_pct, bw, t.status)} ${t.title}${who}`);
349
+ }
350
+ if (!views.length)
351
+ left.push(C.faint(" no tasks yet"));
352
+ const right = [];
353
+ // The shared screen: what the user put up, and the notes pinned on it.
354
+ let screen = [];
355
+ let memory = [];
356
+ try {
357
+ screen = db ? screenPanel(db, Math.min(8, Math.floor(H / 2))) : [];
358
+ memory = db?.recall() ?? [];
359
+ }
360
+ catch {
361
+ /* a closed db */
362
+ }
363
+ if (screen.length)
364
+ right.push(C.violet(" SCREEN") + C.faint(" · pastes & files every agent reads"), ...screen.map((l) => " " + screenGlyphs(l)), "");
365
+ right.push(C.violet(" SHARED MEMORY") + C.faint(" · everyone reads & writes"));
366
+ for (const m of memory.slice(0, H - 1)) {
367
+ right.push(` ${C.violet("◆")} ${m.author ? C.faint(m.author + ": ") : ""}${m.body}`);
177
368
  if (m.why)
178
369
  right.push(C.faint(` why: ${m.why}`));
179
370
  }
180
- return columns(left, right, leftW, W, H);
371
+ if (!memory.length)
372
+ right.push(C.faint(" nothing yet"));
373
+ return columns(left, right, W, H);
181
374
  }
182
375
  }
183
- function columns(left, right, leftW, W, H) {
376
+ /** A task's state as one glyph, the same ones the run's output uses. */
377
+ function mark(status, claimed) {
378
+ if (status === "merged")
379
+ return C.green("✓");
380
+ if (status === "failed")
381
+ return C.red("✗");
382
+ if (status === "needs_you")
383
+ return C.yellow("!");
384
+ if (status === "review")
385
+ return C.violet("◎");
386
+ return claimed ? C.violet("▸") : C.faint("○");
387
+ }
388
+ /** Two columns side by side on a wide screen; on a narrow one, the left on top of the right. */
389
+ function columns(left, right, W, H, latest = false) {
390
+ if (W < 100) {
391
+ const top = left.slice(0, Math.max(3, Math.min(left.length, H - Math.min(right.length, Math.floor(H / 2)) - 1)));
392
+ const n = Math.max(0, H - top.length - 1);
393
+ // A feed keeps its newest lines (its header stays).
394
+ const rest = latest && right.length > n ? [right[0], ...right.slice(1).slice(-(n - 1))].slice(0, n) : right.slice(0, n);
395
+ return [...top, C.faint("─".repeat(W)), ...rest].map((l) => truncate(l, W));
396
+ }
397
+ const leftW = Math.floor(W * 0.55);
184
398
  const out = [];
185
399
  for (let i = 0; i < H; i++) {
186
400
  const l = truncate(left[i] ?? "", leftW - 1);
package/dist/ui/doctor.js CHANGED
@@ -6,7 +6,8 @@ import { spawnSync } from "node:child_process";
6
6
  import { LEGACY } from "../adapters/index.js";
7
7
  import { packageInfo } from "../preflight.js";
8
8
  import { sqliteStatus } from "../room/sqlite.js";
9
- import { connectAgents } from "./app.js";
9
+ import { connectAgents, installLine } from "./app.js";
10
+ import { gitFix } from "../platform.js";
10
11
  import { C, dot } from "./term.js";
11
12
  export async function runDoctor() {
12
13
  let blocked = false;
@@ -23,7 +24,7 @@ export async function runDoctor() {
23
24
  if (gitNum === null) {
24
25
  blocked = true;
25
26
  console.log(C.red("\n✗ git isn't installed.") + " Huddle gives every agent its own git worktree.");
26
- console.log(C.faint(" macOS: xcode-select --install · Debian/Ubuntu: sudo apt install git · https://git-scm.com"));
27
+ console.log(C.faint(" " + gitFix()));
27
28
  }
28
29
  if (!db.ok) {
29
30
  blocked = true;
@@ -35,20 +36,23 @@ export async function runDoctor() {
35
36
  return blocked ? 1 : 0;
36
37
  }
37
38
  /** What to do when agents are missing or signed out. Returns true if at least one is ready. */
38
- function nextSteps(results) {
39
+ export function nextSteps(results, print = console.log) {
39
40
  const ready = results.filter((r) => r.state === "ready");
41
+ const pending = results.filter((r) => r.state === "not_authed" || (r.state === "not_installed" && !LEGACY.has(r.adapter.id)));
40
42
  if (ready.length) {
41
- console.log(C.faint(`\n ${ready.length} agent(s) ready — cd into a repo and run: huddle`));
43
+ print(C.faint(`\n ${ready.length} agent(s) ready — cd into a repo and run: huddle`));
44
+ if (pending.length)
45
+ print(C.faint(` set up the rest (${pending.map((r) => r.adapter.id).join(", ")}): huddle setup`));
42
46
  return true;
43
47
  }
44
48
  const missing = results.filter((r) => r.state === "not_installed" && !LEGACY.has(r.adapter.id));
45
- console.log(`\n${C.yellow("No agents ready yet.")} Huddle runs the coding agents you already use, each signed in to its own account.`);
46
- if (missing.length) {
47
- console.log(C.faint(" Install one or more (or run `huddle` — it offers to, and asks first):"));
48
- for (const r of missing)
49
- console.log(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id.padEnd(12))} ${C.faint("$")} ${r.adapter.install.cmd}`);
50
- }
51
- console.log(C.faint(" Then sign in with the agent itself — Huddle never sees your credentials — and run `huddle doctor` again."));
49
+ print(`\n${C.yellow("No agents ready yet.")} Huddle runs the coding agents you already use,`);
50
+ print("each signed in to its own account.");
51
+ print(` ${C.bold("huddle setup")} ${C.faint("installs and signs in the ones you pick (asks first). Or by hand:")}`);
52
+ for (const r of missing)
53
+ print(` ${dot(r.adapter.id)} ${C.bold(r.adapter.id.padEnd(12))} ${C.faint("$")} ${installLine(r.adapter)}`);
54
+ print(C.faint(" Then sign in with the agent itself — Huddle never sees your credentials —"));
55
+ print(C.faint(" and run `huddle doctor` again."));
52
56
  return false;
53
57
  }
54
58
  //# sourceMappingURL=doctor.js.map