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
@@ -1,34 +1,45 @@
1
1
  // `huddle agents --all`: every connector Huddle knows — built in, or declared
2
2
  // in agents.json — with whether it's on this machine and how to get it.
3
3
  // `huddle agents install <id>` installs one, always after asking.
4
+ import os from "node:os";
4
5
  import readline from "node:readline/promises";
5
- import { spawnSync } from "node:child_process";
6
- import { ADAPTERS, AGENTS_FILE_ERRORS, BUILTIN_ADAPTERS, probe } from "../adapters/index.js";
6
+ import { ADAPTERS, AGENTS_FILE_ERRORS, BUILTIN_ADAPTERS, LEGACY, installCommand, noInstallHint, probe } from "../adapters/index.js";
7
7
  import { agentsFiles } from "../adapters/custom.js";
8
- import { C, dot, pad } from "./term.js";
8
+ import { runInteractive, setAgentChoice, skippedAgents } from "./app.js";
9
+ import { C, dot, lineWidth, pad, wrap } from "./term.js";
9
10
  function all() {
10
11
  const seen = new Map();
11
12
  for (const a of [...BUILTIN_ADAPTERS, ...ADAPTERS])
12
13
  seen.set(a.id, a); // the roster's version wins (agents.json overrides)
13
14
  return [...seen.values()];
14
15
  }
15
- const tilde = (p) => p.replace(process.env.HOME ?? "\0", "~");
16
+ const tilde = (p) => p.replace(os.homedir(), "~");
17
+ const stateText = (r) => r.state === "ready" ? C.green("ready") : r.state === "not_authed" ? C.yellow("not signed in") : C.faint("not installed");
18
+ /** One agent's row: state, whether you use it, and the next command when there is one. */
19
+ function printRow(a, r, skipped) {
20
+ const from = a.source ? C.faint(` · ${tilde(a.source)}`) : "";
21
+ const choice = skipped ? C.faint("skipped") : C.violet("used");
22
+ console.log(` ${dot(a.id)} ${pad(C.bold(a.id), 13)} ${pad(C.faint(a.vendor), 13)} ${pad(stateText(r), 14)} ${from ? pad(choice, 8) + from : choice}`);
23
+ if (skipped)
24
+ return;
25
+ const cmd = installCommand(a);
26
+ // Hints wrap under the row (80 columns when piped), never past the edge.
27
+ const hint = (t) => wrap(t, (lineWidth() ?? 80) - 7, " ").forEach((l) => console.log(` ${C.faint(l)}`));
28
+ if (r.state === "not_installed")
29
+ hint(cmd ? "$ " + cmd : noInstallHint(a));
30
+ else if (r.state === "not_authed")
31
+ hint(r.detail);
32
+ }
16
33
  export function printCatalog() {
17
34
  console.log(`\n${C.bold("Connectors")} ${C.faint("· any CLI that runs headless and speaks MCP can join")}\n`);
18
- for (const a of all()) {
19
- const r = probe(a);
20
- const state = r.state === "ready" ? C.green("ready") : r.state === "not_authed" ? C.yellow("not signed in") : C.faint("not installed");
21
- const from = a.source ? C.faint(` · ${tilde(a.source)}`) : "";
22
- console.log(` ${dot(a.id)} ${pad(C.bold(a.id), 13)} ${pad(C.faint(a.vendor), 13)} ${pad(state, 14)}${from}`);
23
- if (r.state === "not_installed" && a.install.cmd)
24
- console.log(` ${C.faint("$ " + a.install.cmd)}`);
25
- else if (r.state === "not_authed")
26
- console.log(` ${C.faint(r.detail)}`);
27
- }
35
+ const skip = skippedAgents();
36
+ for (const a of all())
37
+ printRow(a, probe(a), skip.has(a.id));
28
38
  for (const e of AGENTS_FILE_ERRORS)
29
39
  console.log(` ${C.yellow("!")} ${C.faint(e)}`);
30
40
  const f = agentsFiles(null);
31
41
  console.log(`\n ${C.faint("install one:")} huddle agents install <id>`);
42
+ console.log(` ${C.faint("choose:")} huddle agents use <id> ${C.faint("·")} huddle agents skip <id>`);
32
43
  console.log(` ${C.faint("add your own:")} ${tilde(f.user)} ${C.faint("(see docs/ADAPTERS.md)")}\n`);
33
44
  }
34
45
  /** Install a connector's CLI (and offer its sign-in) — only after a yes. */
@@ -38,32 +49,29 @@ export async function installConnector(id) {
38
49
  console.log(C.red(`✗ no connector called ${id}.`) + " See `huddle agents --all`.");
39
50
  return false;
40
51
  }
41
- if (!a.install.cmd) {
42
- console.log(`${a.id} has no install command — install ${a.bin} the way its docs say${a.homepage ? `: ${a.homepage}` : "."}`);
52
+ const cmd = installCommand(a);
53
+ if (!cmd) {
54
+ console.log(`${a.id}: ${noInstallHint(a)}`);
43
55
  return false;
44
56
  }
45
57
  if (!process.stdin.isTTY) {
46
- console.log(`To install ${a.id}, run:\n ${a.install.cmd}`);
58
+ console.log(`To install ${a.id}, run:\n ${cmd}`);
47
59
  return false;
48
60
  }
49
61
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
62
  try {
51
- console.log(`\n ${dot(a.id)} ${C.bold(a.id)} ${C.faint("$ " + a.install.cmd)}`);
63
+ console.log(`\n ${dot(a.id)} ${C.bold(a.id)} ${C.faint("$ " + cmd)}`);
52
64
  const ans = (await rl.question(`Install ${a.id}? ${C.faint("[Y/n]")} `)).trim().toLowerCase();
53
65
  if (ans && !ans.startsWith("y"))
54
66
  return false;
55
- rl.pause();
56
- const ok = spawnSync("sh", ["-c", a.install.cmd], { stdio: "inherit" }).status === 0;
57
- rl.resume();
67
+ const ok = runInteractive(rl, cmd);
58
68
  console.log(ok ? C.green(`✓ ${a.id} installed`) : C.red(`✗ ${a.id} install failed — see output above`));
59
69
  if (ok && a.install.login) {
60
70
  const go = (await rl.question(`Sign in to ${a.id} now? ${C.faint("[Y/n]")} `)).trim().toLowerCase();
61
71
  if (!go || go.startsWith("y")) {
62
72
  if (a.install.loginNote)
63
73
  console.log(C.faint(` ${a.install.loginNote}`));
64
- rl.pause();
65
- spawnSync(a.install.login[0], a.install.login.slice(1), { stdio: "inherit" });
66
- rl.resume();
74
+ runInteractive(rl, a.install.login);
67
75
  }
68
76
  }
69
77
  return ok;
@@ -72,4 +80,34 @@ export async function installConnector(id) {
72
80
  rl.close();
73
81
  }
74
82
  }
83
+ /** `huddle agents`: the agents Huddle would use here, their state, and your use/skip choice. */
84
+ export function printAgents() {
85
+ const skip = skippedAgents();
86
+ console.log(`\n${C.bold("Your agents")}\n`);
87
+ for (const a of ADAPTERS) {
88
+ const r = probe(a);
89
+ // Opt-in connectors stay out of the list until their CLI is on this machine.
90
+ if (LEGACY.has(a.id) && r.state === "not_installed")
91
+ continue;
92
+ printRow(a, r, skip.has(a.id));
93
+ }
94
+ console.log(`\n ${C.faint("choose:")} huddle agents use claude,codex ${C.faint("·")} huddle agents skip cursor`);
95
+ console.log(` ${C.faint("set up the rest:")} huddle setup`);
96
+ console.log(` ${C.faint("every connector:")} huddle agents --all\n`);
97
+ }
98
+ /** `huddle agents use|skip <ids>`: remembered in ~/.huddle/prefs.json. */
99
+ export function chooseAgents(verb, list) {
100
+ const ids = list.flatMap((x) => x.split(",")).map((s) => s.trim()).filter(Boolean);
101
+ const known = new Set(all().map((a) => a.id));
102
+ const unknown = ids.filter((id) => !known.has(id));
103
+ if (!ids.length || unknown.length) {
104
+ console.log(C.red(`✗ no agent called ${unknown.join(", ") || "(none given)"}.`) + ` Known: ${[...known].join(", ")}`);
105
+ return false;
106
+ }
107
+ setAgentChoice(ids, verb === "use");
108
+ console.log(verb === "use"
109
+ ? `${C.green("✓")} using ${ids.join(", ")} ${C.faint("— `huddle setup` installs or signs in any that aren't ready")}`
110
+ : `${C.green("✓")} skipping ${ids.join(", ")} ${C.faint("— never installed, asked about, or put to work. Undo: huddle agents use " + ids.join(","))}`);
111
+ return true;
112
+ }
75
113
  //# sourceMappingURL=catalog.js.map
@@ -1,18 +1,61 @@
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.
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.
13
14
  import { C, 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
+ }
15
56
  const hexColor = (id) => C.hex(AGENT_COLOR[id.split("/").pop().split("#")[0]] ?? "#8A857C");
57
+ /** The shared screen's 📎/📌 rows in the view's glyphs (emoji are two cells wide and vary by font). */
58
+ const screenGlyphs = (l) => l.replace(/^📎/u, C.violet("+")).replace(/^📌/u, C.violet("◆"));
16
59
  export class Dashboard {
17
60
  src;
18
61
  tab = "agents";
@@ -82,105 +125,205 @@ export class Dashboard {
82
125
  render() {
83
126
  if (!this.open)
84
127
  return;
85
- const W = process.stdout.columns || 100;
86
- const H = process.stdout.rows || 30;
128
+ const lines = this.frame(process.stdout.columns || 100, process.stdout.rows || 30);
129
+ process.stdout.write("\x1b[H" + lines.map((l) => "\x1b[2K" + l).join("\n"));
130
+ }
131
+ /** One full screen, `H` rows each at most `W` cells (also how tests and samples render it). */
132
+ frame(W, H, tab = this.tab, now = Date.now()) {
87
133
  const db = this.src.db();
88
134
  const stats = this.src.stats();
89
- const goal = db?.currentSession()?.goal ?? "no goal yet — press q and type one";
135
+ const busy = !!stats && [...stats.agents.values()].some((a) => a.busySince);
136
+ const phase = runPhase(db, busy);
137
+ let goal = "";
138
+ try {
139
+ goal = db?.currentSession()?.goal ?? "";
140
+ }
141
+ catch {
142
+ /* a closed db */
143
+ }
90
144
  const tabs = ["agents", "plan", "board"]
91
- .map((t, i) => (t === this.tab ? C.bold(C.violet(` ${i + 1} ${t} `)) : C.faint(` ${i + 1} ${t} `)))
145
+ .map((t, i) => (t === tab ? C.bold(C.violet(`[${i + 1} ${t}]`)) : C.faint(` ${i + 1} ${t} `)))
92
146
  .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"));
147
+ const clock = stats ? `${C.faint("⏱")} ${fmtDuration((stats.endedAt ?? now) - stats.startedAt)}` : "";
148
+ const state = phase === "DONE"
149
+ ? C.green("✓ done")
150
+ : phase === "WAITING"
151
+ ? C.faint("○ waiting for a run")
152
+ : `${C.bold(C.violet(phase))} ${C.faint("·")} ${busy ? C.green("● live") : C.faint("○ between steps")}`;
153
+ const right = [state, clock].filter(Boolean).join(C.faint(" · "));
95
154
  const bar = ` ${C.violet("⬢")} ${C.bold("huddle")} ${tabs}`;
96
- const top = bar + " ".repeat(Math.max(1, W - width(bar) - width(right) - 1)) + right;
155
+ // The goal takes whatever room is left between the tabs and the status.
156
+ const room = W - width(bar) - width(right) - 4;
157
+ const goalS = goal && room >= 12 ? C.faint(truncate(goal.replace(/\s+/g, " "), room)) : "";
158
+ const gap = Math.max(1, W - width(bar) - width(goalS) - width(right) - 3);
159
+ const top = goalS ? `${bar} ${goalS}${" ".repeat(gap)}${right}` : `${bar}${" ".repeat(Math.max(1, W - width(bar) - width(right) - 1))}${right}`;
97
160
  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);
161
+ const body = tab === "agents" ? this.agents(W, bodyH, stats, db, now) : tab === "plan" ? this.plan(W, bodyH, db) : this.board(W, bodyH, db);
99
162
  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"));
163
+ const strip = lastRoom ? ` ${lastRoom.text}` : C.faint(" the room is quiet");
164
+ const hint = C.faint(` ${this.src.hint ?? "1 2 3 switch · q back to the prompt · ctrl-c stop agents"} `) + this.src.modeChip();
165
+ const lines = [top, C.faint("─".repeat(W)), ...body.slice(0, bodyH)];
166
+ while (lines.length < H - 2)
167
+ lines.push("");
168
+ lines.push(strip, hint);
169
+ return lines.slice(0, H).map((l) => truncate(l.replace(/[\r\n]+/g, " "), W));
106
170
  }
107
171
  /** Tab 1: a pane per agent — what each one is doing right now. */
108
- agents(W, H, stats) {
172
+ agents(W, H, stats, db, now) {
109
173
  const ids = stats ? [...stats.agents.keys()] : [...new Set(this.feed.filter((a) => a.agent).map((a) => a.agent))];
110
174
  if (!ids.length)
111
- return [C.faint(" no agents working — give Huddle a goal")];
175
+ return ["", C.faint(` ${this.src.empty ?? "no agents working — give Huddle a goal"}`)];
176
+ let views = [];
177
+ try {
178
+ views = db ? taskViews(db, now) : [];
179
+ }
180
+ catch {
181
+ /* a closed db: no pinned task line */
182
+ }
112
183
  const cols = W >= 110 && ids.length > 1 ? 2 : 1;
113
184
  const rows = Math.ceil(ids.length / cols);
114
185
  const paneW = Math.floor(W / cols);
115
186
  const paneH = Math.max(4, Math.floor(H / rows));
116
187
  const out = [];
117
188
  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)));
189
+ const panes = ids.slice(r * cols, r * cols + cols).map((id) => this.pane(id, paneW, paneH, stats?.agents.get(id), views, now));
119
190
  for (let i = 0; i < paneH; i++)
120
191
  out.push(panes.map((p) => p[i] ?? " ".repeat(paneW)).join(""));
121
192
  }
122
193
  return out;
123
194
  }
124
- pane(id, w, h, a) {
195
+ pane(id, w, h, a, views, now) {
125
196
  const color = hexColor(id);
126
197
  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` : ""}`)
198
+ ? `${C.violet("working")} ${fmtDuration(now - a.busySince)}${a.liveTokens > 0 ? " · " + fmtTokens(a.liveTokens) + " tok" : ""}`
199
+ : a && (a.tasks || a.reviews || a.qa)
200
+ ? C.green(`idle · ${a.tasks} merged${a.reviews ? ` · ${a.reviews} review${a.reviews === 1 ? "" : "s"}` : ""}${a.qa ? ` · ${a.qa} QA` : ""}`)
130
201
  : C.faint("idle");
131
- const title = ` ${dot(id)} ${C.bold(id)} ${status} `;
132
202
  const inner = w - 2;
203
+ const title = truncate(` ${dot(id)} ${C.bold(id)} ${status} `, inner - 1);
133
204
  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));
205
+ // The task it's on stays pinned at the top: title, %, how long since it last said anything.
206
+ const on = a?.busySince ? views.find((v) => v.owner === id && (v.status === "claimed" || v.status === "in_progress")) : undefined;
207
+ const pinned = [];
208
+ if (on) {
209
+ const pct = on.reported ? C.violet(`${on.progress_pct}%`) : C.faint("no report yet");
210
+ const said = on.last_note ? C.faint(` · note ${fmtDuration(on.last_update_s * 1000)} ago`) : "";
211
+ const tail = ` ${pct}${said}`;
212
+ pinned.push(`${C.violet("▸")} ${C.bold(truncate(on.title, Math.max(8, inner - 4 - width(tail))))}${tail}`);
213
+ }
214
+ const room = h - 2 - pinned.length;
215
+ const lines = [...pinned, ...this.feed.filter((x) => x.agent === id).slice(-room).map((x) => x.text)];
135
216
  const body = Array.from({ length: h - 2 }, (_, i) => {
136
- const l = lines[i] ? " " + truncate(lines[i].text, inner - 2) : "";
217
+ const l = lines[i] ? " " + truncate(lines[i], inner - 2) : "";
137
218
  return color("│") + pad(l, inner) + color("│");
138
219
  });
139
220
  return [topLine, ...body, color("╰" + "─".repeat(inner) + "╯")];
140
221
  }
141
- /** Tab 2: the plan on the left, the room talking on the right. */
222
+ /** Tab 2: the plan, and the room talking (side by side when there's room, stacked when not). */
142
223
  plan(W, H, db) {
143
- const leftW = Math.floor(W * 0.55);
144
224
  const left = [C.violet(" PLAN")];
145
- const tasks = db?.listTasks() ?? [];
225
+ let tasks = [];
226
+ try {
227
+ tasks = db?.listTasks() ?? [];
228
+ }
229
+ catch {
230
+ /* a closed db */
231
+ }
146
232
  const idx = new Map(tasks.map((t, i) => [t.id, i + 1]));
147
233
  tasks.forEach((t, i) => {
148
234
  const deps = JSON.parse(t.depends_on || "[]");
149
235
  const after = deps.length ? C.faint(` after ${deps.map((d) => idx.get(d)).join(",")}`) : "";
150
236
  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}`));
237
+ left.push(` ${mark(t.status, !!t.claimed_by)} ${C.faint(String(i + 1).padStart(2) + ".")} ${t.title}${after}${who}`);
153
238
  });
154
239
  if (!tasks.length)
155
240
  left.push(C.faint(" no plan yet"));
241
+ let contract = null;
242
+ try {
243
+ contract = db ? getContract(db) : null;
244
+ }
245
+ catch {
246
+ /* a closed db: no contract line */
247
+ }
248
+ if (contract && (contract.text || contract.amendments.length)) {
249
+ left.push("", `${C.violet(" CONTRACT")} ${C.faint(`v${contract.version} · ${contract.amendments.length} amendment(s)`)}`);
250
+ for (const a of contract.amendments.slice(-3))
251
+ left.push(C.faint(` v${a.version} ${a.by}: ${a.section}: ${a.change}`));
252
+ }
156
253
  const right = [C.violet(" ROOM") + C.faint(" · agents & people talking")];
157
254
  for (const a of this.feed.filter((x) => !x.agent).slice(-(H - 1)))
158
255
  right.push(" " + a.text);
159
- return columns(left, right, leftW, W, H);
256
+ if (right.length === 1)
257
+ right.push(C.faint(" nothing said yet"));
258
+ return columns(left, right, W, H, true);
160
259
  }
161
- /** Tab 3: progress on the left, shared memory on the right. */
260
+ /** Tab 3: progress, and the shared screen + memory. */
162
261
  board(W, H, db) {
163
- const leftW = Math.floor(W * 0.55);
164
262
  const left = [C.violet(" BOARD") + C.faint(" · live progress")];
165
- for (const t of db?.listTasks() ?? []) {
166
- const bw = 14;
263
+ let views = [];
264
+ try {
265
+ views = db ? taskViews(db) : [];
266
+ }
267
+ catch {
268
+ /* a closed db */
269
+ }
270
+ const bw = W >= 100 ? 14 : 8;
271
+ for (const t of views) {
167
272
  const filled = Math.round((t.progress_pct / 100) * bw);
168
273
  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}`);
274
+ // Never reported (no huddle_progress yet): say so instead of showing a made-up 0%.
275
+ const silent = ["claimed", "in_progress"].includes(t.status) && !t.reported;
276
+ const who = t.owner ? ` ${dot(t.owner)} ${C.faint(t.owner)}` : "";
277
+ left.push(` ${mark(t.status, !!t.owner)} ${barS} ${silent ? C.faint(" — ") : String(t.progress_pct).padStart(3) + "%"} ${t.title}${who}`);
278
+ }
279
+ if (!views.length)
280
+ left.push(C.faint(" no tasks yet"));
281
+ const right = [];
282
+ // The shared screen: what the user put up, and the notes pinned on it.
283
+ let screen = [];
284
+ let memory = [];
285
+ try {
286
+ screen = db ? screenPanel(db, Math.min(8, Math.floor(H / 2))) : [];
287
+ memory = db?.recall() ?? [];
288
+ }
289
+ catch {
290
+ /* a closed db */
291
+ }
292
+ if (screen.length)
293
+ right.push(C.violet(" SCREEN") + C.faint(" · pastes & files every agent reads"), ...screen.map((l) => " " + screenGlyphs(l)), "");
294
+ right.push(C.violet(" SHARED MEMORY") + C.faint(" · everyone reads & writes"));
295
+ for (const m of memory.slice(0, H - 1)) {
296
+ right.push(` ${C.violet("◆")} ${m.author ? C.faint(m.author + ": ") : ""}${m.body}`);
177
297
  if (m.why)
178
298
  right.push(C.faint(` why: ${m.why}`));
179
299
  }
180
- return columns(left, right, leftW, W, H);
300
+ if (!memory.length)
301
+ right.push(C.faint(" nothing yet"));
302
+ return columns(left, right, W, H);
181
303
  }
182
304
  }
183
- function columns(left, right, leftW, W, H) {
305
+ /** A task's state as one glyph, the same ones the run's output uses. */
306
+ function mark(status, claimed) {
307
+ if (status === "merged")
308
+ return C.green("✓");
309
+ if (status === "failed")
310
+ return C.red("✗");
311
+ if (status === "needs_you")
312
+ return C.yellow("!");
313
+ if (status === "review")
314
+ return C.violet("◎");
315
+ return claimed ? C.violet("▸") : C.faint("○");
316
+ }
317
+ /** Two columns side by side on a wide screen; on a narrow one, the left on top of the right. */
318
+ function columns(left, right, W, H, latest = false) {
319
+ if (W < 100) {
320
+ const top = left.slice(0, Math.max(3, Math.min(left.length, H - Math.min(right.length, Math.floor(H / 2)) - 1)));
321
+ const n = Math.max(0, H - top.length - 1);
322
+ // A feed keeps its newest lines (its header stays).
323
+ const rest = latest && right.length > n ? [right[0], ...right.slice(1).slice(-(n - 1))].slice(0, n) : right.slice(0, n);
324
+ return [...top, C.faint("─".repeat(W)), ...rest].map((l) => truncate(l, W));
325
+ }
326
+ const leftW = Math.floor(W * 0.55);
184
327
  const out = [];
185
328
  for (let i = 0; i < H; i++) {
186
329
  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
@@ -0,0 +1,81 @@
1
+ // `huddle lessons`: what Huddle learned across runs on this machine, and how
2
+ // to take a lesson back.
3
+ //
4
+ // huddle lessons list them, most-seen first
5
+ // huddle lessons --forget <id> drop one
6
+ // huddle lessons --clear [--yes] drop all (asks first)
7
+ import readline from "node:readline/promises";
8
+ import os from "node:os";
9
+ import { C } from "./term.js";
10
+ import { allLessons, clearLessons, forgetLesson, lessonsFile, lessonsOff, repoKey } from "../room/lessons.js";
11
+ const ago = (ts) => {
12
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
13
+ return s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)}m ago` : s < 129600 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
14
+ };
15
+ function scopeLabel(l, here) {
16
+ if (l.scope === "all")
17
+ return "every run";
18
+ if (l.scope.startsWith("agent:"))
19
+ return l.scope.slice(6);
20
+ return here && l.scope === `repo:${here}` ? "this repo" : `repo ${l.scope.slice(5, 13)}`;
21
+ }
22
+ export async function runLessons(rest, cwd = process.cwd()) {
23
+ const file = lessonsFile().replace(os.homedir(), "~");
24
+ const fi = rest.indexOf("--forget");
25
+ if (fi >= 0) {
26
+ const id = rest[fi + 1];
27
+ if (!id || id.startsWith("--")) {
28
+ console.log(C.red("✗ --forget needs a lesson id") + C.faint(" (the first column of `huddle lessons`)"));
29
+ process.exitCode = 1;
30
+ return;
31
+ }
32
+ if (!forgetLesson(id)) {
33
+ console.log(C.red(`✗ no lesson ${id}`) + C.faint(" — `huddle lessons` lists them"));
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ return void console.log(`${C.green("✓")} forgot ${id}`);
38
+ }
39
+ if (rest.includes("--clear")) {
40
+ const n = allLessons().length;
41
+ if (!n)
42
+ return void console.log(C.faint("No lessons to clear."));
43
+ if (!rest.includes("--yes")) {
44
+ if (!process.stdin.isTTY) {
45
+ console.log(C.yellow(`! this deletes all ${n} lesson(s) in ${file}.`) + " Run it again with --yes to confirm.");
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
50
+ const a = (await rl.question(`Delete all ${n} lesson(s) in ${file}? ${C.faint("[y/N]")} `)).trim().toLowerCase();
51
+ rl.close();
52
+ if (!a.startsWith("y"))
53
+ return void console.log(C.faint("Kept them."));
54
+ }
55
+ clearLessons();
56
+ return void console.log(`${C.green("✓")} cleared ${n} lesson(s)`);
57
+ }
58
+ const lessons = allLessons().sort((a, b) => b.hits - a.hits || b.created - a.created);
59
+ if (lessonsOff())
60
+ console.log(C.yellow("! HUDDLE_NO_LESSONS is set: runs neither use nor learn lessons right now."));
61
+ if (!lessons.length) {
62
+ console.log(`No lessons yet ${C.faint(`(${file})`)}.`);
63
+ console.log(C.faint(" Huddle learns them at the end of each run — a council turn that timed out, a file agents collided on, QA bugs — and agents add them with huddle_remember(kind=\"lesson\")."));
64
+ return;
65
+ }
66
+ let here = null;
67
+ try {
68
+ here = repoKey(cwd);
69
+ }
70
+ catch {
71
+ /* not in a repo */
72
+ }
73
+ console.log(`${C.violet("lessons")} ${C.faint(`· ${lessons.length} · ${file} · they go into agent and planner prompts`)}\n`);
74
+ for (const l of lessons) {
75
+ console.log(` ${C.bold(l.id)} ${l.text}`);
76
+ const used = l.last_used ? `used ${ago(l.last_used)}` : "not used yet";
77
+ console.log(C.faint(` ${scopeLabel(l, here)} · seen ${l.hits}× · ${used} · from ${l.source || "?"}${l.evidence ? ` · ${l.evidence}` : ""}`));
78
+ }
79
+ console.log(C.faint(`\n huddle lessons --forget <id> · --clear · HUDDLE_NO_LESSONS=1 turns them off`));
80
+ }
81
+ //# sourceMappingURL=lessons.js.map