huddle-cli 0.0.0-stage → 0.1.0

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -2
  3. package/dist/adapters/amp.js +40 -0
  4. package/dist/adapters/antigravity.js +52 -0
  5. package/dist/adapters/claude.js +70 -0
  6. package/dist/adapters/codex.js +59 -0
  7. package/dist/adapters/copilot.js +69 -0
  8. package/dist/adapters/cursor.js +85 -0
  9. package/dist/adapters/custom.js +340 -0
  10. package/dist/adapters/droid.js +57 -0
  11. package/dist/adapters/gemini.js +61 -0
  12. package/dist/adapters/goose.js +55 -0
  13. package/dist/adapters/index.js +135 -0
  14. package/dist/adapters/kiro.js +41 -0
  15. package/dist/adapters/opencode.js +63 -0
  16. package/dist/adapters/qwen.js +67 -0
  17. package/dist/adapters/types.js +6 -0
  18. package/dist/adapters/usage.js +141 -0
  19. package/dist/attach/connect.js +409 -0
  20. package/dist/attach/guard.js +64 -0
  21. package/dist/attach/live.js +222 -0
  22. package/dist/attach/plug.js +174 -0
  23. package/dist/attach/session.js +172 -0
  24. package/dist/cli.js +176 -0
  25. package/dist/mcp/server.js +240 -0
  26. package/dist/orchestrator/ask.js +51 -0
  27. package/dist/orchestrator/brain.js +164 -0
  28. package/dist/orchestrator/council.js +274 -0
  29. package/dist/orchestrator/handoff.js +117 -0
  30. package/dist/orchestrator/launcher.js +155 -0
  31. package/dist/orchestrator/planner.js +194 -0
  32. package/dist/orchestrator/qa.js +350 -0
  33. package/dist/orchestrator/scheduler.js +748 -0
  34. package/dist/orchestrator/takeover.js +85 -0
  35. package/dist/preflight.js +31 -0
  36. package/dist/relay/server.js +113 -0
  37. package/dist/room/db.js +617 -0
  38. package/dist/room/identity.js +122 -0
  39. package/dist/room/live.js +293 -0
  40. package/dist/room/progress.js +110 -0
  41. package/dist/room/relaylink.js +85 -0
  42. package/dist/room/server.js +752 -0
  43. package/dist/room/sqlite.js +156 -0
  44. package/dist/room/transport.js +107 -0
  45. package/dist/room/tunnel.js +85 -0
  46. package/dist/sandbox/sync.js +104 -0
  47. package/dist/sandbox/worktree.js +261 -0
  48. package/dist/types.js +4 -0
  49. package/dist/ui/app.js +1534 -0
  50. package/dist/ui/catalog.js +75 -0
  51. package/dist/ui/dashboard.js +192 -0
  52. package/dist/ui/doctor.js +54 -0
  53. package/dist/ui/logo.js +40 -0
  54. package/dist/ui/remote.js +332 -0
  55. package/dist/ui/story.js +932 -0
  56. package/dist/ui/term.js +114 -0
  57. package/dist/ui/watch.js +174 -0
  58. package/install.sh +44 -0
  59. package/package.json +60 -4
@@ -0,0 +1,114 @@
1
+ // Terminal primitives: color, width-aware padding, the rounded box, spinners.
2
+ // Huddle lives in the terminal only, so this file is the whole "UI toolkit".
3
+ const color = process.stdout.isTTY && !process.env.NO_COLOR;
4
+ const sgr = (open) => (s) => (color ? `\x1b[${open}m${s}\x1b[0m` : s);
5
+ const hex = (h) => {
6
+ const n = parseInt(h.slice(1), 16);
7
+ return sgr(`38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}`);
8
+ };
9
+ // Brand palette — matches the teaser (teaser/launch40.html).
10
+ export const C = {
11
+ dim: sgr("2"),
12
+ bold: sgr("1"),
13
+ green: sgr("32"),
14
+ red: sgr("31"),
15
+ yellow: sgr("33"),
16
+ violet: hex("#7B6CF6"),
17
+ faint: hex("#8A857C"),
18
+ hex,
19
+ };
20
+ export const AGENT_COLOR = {
21
+ claude: "#D9774B",
22
+ codex: "#10A37F",
23
+ antigravity: "#4285F4",
24
+ gemini: "#4285F4",
25
+ cursor: "#8A8A8A",
26
+ opencode: "#E5C07B",
27
+ copilot: "#8957E5",
28
+ amp: "#F34E3F",
29
+ goose: "#B8B2A7",
30
+ qwen: "#615CED",
31
+ droid: "#EE6018",
32
+ kiro: "#9046FF",
33
+ };
34
+ /** Colored dot for an agent; "claude#2" gets Claude's color. */
35
+ export const dot = (agentId) => hex(AGENT_COLOR[agentId.split("#")[0]] ?? "#8A857C")("●");
36
+ /** Visible width of a string (ignores ANSI escapes). */
37
+ export function width(s) {
38
+ return [...s.replace(/\x1b\[[0-9;]*m/g, "")].length;
39
+ }
40
+ /** Cut to `n` visible characters, keeping color codes intact. */
41
+ export function truncate(s, n) {
42
+ if (width(s) <= n)
43
+ return s;
44
+ let out = "";
45
+ let seen = 0;
46
+ for (let i = 0; i < s.length;) {
47
+ const esc = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
48
+ if (esc) {
49
+ out += esc[0];
50
+ i += esc[0].length;
51
+ continue;
52
+ }
53
+ const ch = String.fromCodePoint(s.codePointAt(i));
54
+ if (seen >= n - 1)
55
+ return out + "…\x1b[0m";
56
+ out += ch;
57
+ seen++;
58
+ i += ch.length;
59
+ }
60
+ return out;
61
+ }
62
+ export function pad(s, n) {
63
+ return s + " ".repeat(Math.max(0, n - width(s)));
64
+ }
65
+ /** Rounded box with a colored border, Claude-Code style. */
66
+ export function box(lines, opts = {}) {
67
+ const border = opts.border ?? C.violet;
68
+ const inner = Math.max(opts.width ?? 0, ...lines.map(width)) + 2;
69
+ const top = border("╭" + "─".repeat(inner) + "╮");
70
+ const bot = border("╰" + "─".repeat(inner) + "╯");
71
+ const mid = lines.map((l) => border("│") + " " + pad(l, inner - 2) + " " + border("│"));
72
+ return [top, ...mid, bot].join("\n");
73
+ }
74
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
75
+ /** A set of lines that re-render in place while async work settles. */
76
+ export class LiveLines {
77
+ render;
78
+ lines;
79
+ frame = 0;
80
+ timer;
81
+ drawn = false;
82
+ constructor(render) {
83
+ this.render = render;
84
+ this.lines = [];
85
+ }
86
+ start() {
87
+ this.draw();
88
+ if (process.stdout.isTTY)
89
+ this.timer = setInterval(() => this.draw(), 80);
90
+ return this;
91
+ }
92
+ draw() {
93
+ if (!process.stdout.isTTY)
94
+ 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`);
98
+ for (const l of next)
99
+ process.stdout.write("\x1b[2K" + l + "\n");
100
+ this.lines = next;
101
+ this.drawn = true;
102
+ }
103
+ stop() {
104
+ if (this.timer)
105
+ clearInterval(this.timer);
106
+ if (!process.stdout.isTTY) {
107
+ for (const l of this.render(" "))
108
+ process.stdout.write(l + "\n");
109
+ return;
110
+ }
111
+ this.draw();
112
+ }
113
+ }
114
+ //# sourceMappingURL=term.js.map
@@ -0,0 +1,174 @@
1
+ // `huddle view [repo]` — the live view (ctrl-t) from a second terminal.
2
+ //
3
+ // A run started anywhere — `huddle`, `huddle join "<goal>"`, a script, the
4
+ // e2e evals — writes everything to the repo's Room db. This opens the same
5
+ // full-screen dashboard on that db, read-only: agent panes, plan + room
6
+ // talk, board + shared memory. It never claims, edits or stops anything;
7
+ // q or ctrl-c just closes the view.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import readline from "node:readline";
11
+ import { spawnSync } from "node:child_process";
12
+ import { RoomDB } from "../room/db.js";
13
+ import { C } from "./term.js";
14
+ import { Dashboard } from "./dashboard.js";
15
+ const json = (s) => {
16
+ try {
17
+ return JSON.parse(s || "{}");
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ };
23
+ const clip = (s, n = 160) => String(s ?? "").replace(/\s+/g, " ").trim().slice(0, n);
24
+ /** Writers in the room that aren't agents — no pane of their own. */
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. */
27
+ export function activityOf(e) {
28
+ const p = json(e.payload);
29
+ const who = e.agent_id ?? p.agent;
30
+ switch (e.type) {
31
+ case "run.start":
32
+ return { agent: who, text: `▸ ${clip(p.title)}` };
33
+ case "task.progress":
34
+ // Progress rows carry the task, not always the agent; runView resolves the owner.
35
+ if (!clip(p.note))
36
+ return null;
37
+ return { agent: who, text: `${p.pct}% ${clip(p.note)}` };
38
+ case "plan.announced":
39
+ return { agent: who, text: `plans: ${clip(p.summary ?? p.plan ?? p.intent ?? p.title)}` };
40
+ case "run.review":
41
+ return { agent: who, text: `◎ reviewing ${p.author}'s “${clip(p.title, 60)}”` };
42
+ 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)}` };
44
+ case "run.merged":
45
+ return { agent: who, text: `✓ merged ${clip(p.title, 80)}` };
46
+ case "run.failed":
47
+ return { agent: who, text: `✗ failed ${clip(p.title, 60)} — ${clip(p.reason ?? p.error, 80)}` };
48
+ case "agent.message":
49
+ return { text: `${p.from} → ${p.to}: ${clip(p.body)}` };
50
+ case "memory.added":
51
+ return { text: `◆ ${p.author ?? who ?? "memory"}: ${clip(p.body)}` };
52
+ case "council.proposal":
53
+ return { agent: who, text: `proposes: ${clip(p.text)}` };
54
+ case "council.critique":
55
+ return { agent: who, text: `flags: ${clip(p.text)}` };
56
+ case "council.plan":
57
+ return { text: `◇ council plan ready` };
58
+ case "qa.round":
59
+ return { text: `◎ QA round ${p.round} · ${(p.agents ?? []).join(", ")}` };
60
+ case "qa.bug":
61
+ return { text: `🐞 ${who ?? "QA"} found: ${clip(p.title ?? p.body)}` };
62
+ case "qa.clean":
63
+ return { text: `✓ QA round ${p.round ?? ""} clean` };
64
+ default:
65
+ 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)})` };
67
+ return null;
68
+ }
69
+ }
70
+ /** Busy/tokens/tasks per agent, rebuilt from the run's events (another process owns the live stats). */
71
+ export function statsOf(db, events) {
72
+ const session = db.currentSession();
73
+ const since = session?.created_at ?? 0;
74
+ const run = events.filter((e) => e.ts >= since);
75
+ const starts = run.filter((e) => e.type === "run.start");
76
+ if (!starts.length && !db.listAgents().length)
77
+ return null;
78
+ const agents = new Map();
79
+ const get = (id) => {
80
+ if (!agents.has(id))
81
+ agents.set(id, { busySince: null, liveTokens: 0, tokens: 0, tasks: 0, reviews: 0 });
82
+ return agents.get(id);
83
+ };
84
+ for (const a of db.listAgents())
85
+ if (!ROOM_AUTHORS.has(a.id))
86
+ get(a.id);
87
+ for (const e of run) {
88
+ const p = json(e.payload);
89
+ const id = e.agent_id ?? p.agent;
90
+ if (!id || ROOM_AUTHORS.has(id))
91
+ continue;
92
+ const a = get(id);
93
+ if (e.type === "run.start" || e.type === "run.review")
94
+ a.busySince = e.ts;
95
+ else if (e.type === "run.merged" || e.type === "run.failed" || e.type === "run.reviewed") {
96
+ a.busySince = null;
97
+ if (e.type === "run.merged")
98
+ a.tasks++;
99
+ if (e.type === "run.reviewed")
100
+ a.reviews++;
101
+ a.tokens += Number(p.tokens) || 0;
102
+ }
103
+ }
104
+ return { startedAt: starts[0]?.ts ?? since, agents };
105
+ }
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);
124
+ const dash = new Dashboard({
125
+ db: () => db,
126
+ stats: () => stats,
127
+ modeChip: () => C.faint("watching · read-only"),
128
+ hint: "1 2 3 switch · q close the view (agents keep working)",
129
+ });
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
+ 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)) {
135
+ if (e.ts < since)
136
+ continue;
137
+ const a = activityOf(e);
138
+ if (a && !a.agent && e.type === "task.progress" && e.task_id) {
139
+ const owner = db.getTask(e.task_id)?.claimed_by;
140
+ if (owner)
141
+ a.agent = owner;
142
+ }
143
+ if (a)
144
+ dash.push(a);
145
+ }
146
+ seen = list.length;
147
+ };
148
+ feed(events);
149
+ const poll = setInterval(() => {
150
+ events = db.allEvents();
151
+ stats = statsOf(db, events);
152
+ feed(events);
153
+ }, 1000);
154
+ readline.emitKeypressEvents(process.stdin);
155
+ process.stdin.setRawMode(true);
156
+ await new Promise((resolve) => {
157
+ const quit = (_s, key) => {
158
+ if (key?.ctrl && key.name === "c")
159
+ close();
160
+ };
161
+ const close = () => {
162
+ process.stdin.off("keypress", quit);
163
+ dash.hide();
164
+ resolve();
165
+ };
166
+ process.stdin.on("keypress", quit);
167
+ dash.show(undefined, close);
168
+ });
169
+ clearInterval(poll);
170
+ process.stdin.setRawMode(false);
171
+ db.close();
172
+ process.exit(0);
173
+ }
174
+ //# sourceMappingURL=watch.js.map
package/install.sh ADDED
@@ -0,0 +1,44 @@
1
+ #!/bin/sh
2
+ # Huddle installer.
3
+ # curl -fsSL <this file> | sh
4
+ # Installs the `huddle` CLI from npm (huddle-cli). Then run `huddle`: it finds
5
+ # your agents (claude, codex, antigravity, cursor) and offers to install or
6
+ # sign in to the missing ones — always asking first. Huddle never handles
7
+ # your agents' credentials; each one signs in on its own.
8
+ #
9
+ # HUDDLE_SOURCE=<spec> install something else, e.g. a local .tgz or github:Preet37/Huddle
10
+ set -e
11
+
12
+ violet() { printf '\033[38;2;123;108;246m%s\033[0m\n' "$1"; }
13
+ fail() { printf '\033[31m✗ %s\033[0m\n' "$1" >&2; exit 1; }
14
+
15
+ command -v node >/dev/null 2>&1 || fail "Node.js 20+ is required — https://nodejs.org (or: nvm install 22)"
16
+ major=$(node -p 'process.versions.node.split(".")[0]')
17
+ [ "$major" -ge 20 ] || fail "Node.js 20+ is required (found $(node -v)) — https://nodejs.org (or: nvm install 22)"
18
+ command -v npm >/dev/null 2>&1 || fail "npm is required (it ships with Node.js — https://nodejs.org)"
19
+ command -v git >/dev/null 2>&1 || fail "git is required — macOS: xcode-select --install · Linux: your package manager"
20
+
21
+ # A system-wide Node often has a root-owned global folder; npm would then
22
+ # fail with EACCES halfway through. Say so up front instead of using sudo.
23
+ prefix=$(npm prefix -g 2>/dev/null || true)
24
+ if [ -n "$prefix" ] && [ -d "$prefix/lib" ] && [ ! -w "$prefix/lib" ]; then
25
+ fail "npm can't write to $prefix/lib (global installs need root there).
26
+ Pick one, then re-run this installer:
27
+ • use a Node version manager (no root needed): https://github.com/nvm-sh/nvm
28
+ • or give npm a folder you own: npm config set prefix ~/.npm-global && export PATH=~/.npm-global/bin:\$PATH"
29
+ fi
30
+
31
+ violet "↓ installing huddle…"
32
+ # (A github: source builds with devDependencies; the teaser renderer's Chromium isn't needed.)
33
+ PUPPETEER_SKIP_DOWNLOAD=1 npm install -g "${HUDDLE_SOURCE:-huddle-cli}"
34
+
35
+ echo
36
+ if command -v huddle >/dev/null 2>&1; then
37
+ violet "✓ huddle $(huddle --version) installed"
38
+ echo " cd into any git repo and run: huddle"
39
+ echo " check this machine any time: huddle doctor"
40
+ else
41
+ violet "✓ huddle installed in $prefix/bin"
42
+ echo " that folder isn't on your PATH yet — add it, e.g.:"
43
+ echo " export PATH=\"$prefix/bin:\$PATH\""
44
+ fi
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "huddle-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The multiplayer layer for AI coding agents. One command. Every agent. One repo. Zero collisions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "huddle": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist/**/*.js",
11
+ "install.sh",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/Preet37/Huddle.git"
21
+ },
22
+ "homepage": "https://github.com/Preet37/Huddle#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/Preet37/Huddle/issues"
25
+ },
26
+ "scripts": {
27
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
28
+ "dev": "tsc --watch",
29
+ "start": "node dist/cli.js",
30
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
31
+ "prepare": "npm run build",
32
+ "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
+ },
36
+ "keywords": [
37
+ "ai",
38
+ "agents",
39
+ "multiplayer",
40
+ "mcp",
41
+ "claude",
42
+ "codex",
43
+ "gemini",
44
+ "cursor",
45
+ "orchestration"
46
+ ],
47
+ "license": "MIT",
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "^1.0.0",
50
+ "nanoid": "^5.0.0",
51
+ "ws": "^8.18.0"
52
+ },
53
+ "optionalDependencies": {
54
+ "better-sqlite3": "^11.0.0"
55
+ },
56
+ "devDependencies": {
57
+ "@types/node": "^22.0.0",
58
+ "@types/ws": "^8.5.0",
59
+ "puppeteer": "^24.43.1",
60
+ "typescript": "^5.6.0"
61
+ }
62
+ }