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
@@ -0,0 +1,262 @@
1
+ // `huddle app` — the dashboard: a local web page for a repo's runs.
2
+ //
3
+ // It shows the run going on now (from .huddle/live.json, whoever started
4
+ // it), every past run as a conversation (the same records `huddle export`
5
+ // reads), and lets you start a run, answer an agent's question, talk to the
6
+ // agents and stop them (through .huddle/inbox/, app/live.ts).
7
+ //
8
+ // Local only: it listens on 127.0.0.1, every API call needs the token from
9
+ // the URL it prints, and a Host other than localhost is refused (so a web
10
+ // page you visit can't reach it through DNS rebinding).
11
+ import { spawn } from "node:child_process";
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import http from "node:http";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import { ADAPTERS, LEGACY, applySkips, probeAsync } from "../adapters/index.js";
18
+ import { RoomDB } from "../room/db.js";
19
+ import { splitRuns } from "../ui/story.js";
20
+ import { buildExport, findRun, renderExport, runRows, EXPORT_FORMATS, EXPORT_LEVELS } from "../ui/export.js";
21
+ import { packageInfo } from "../preflight.js";
22
+ import { clearAppInfo, postCommand, readLive, readReport, writeAppInfo } from "./live.js";
23
+ import { appPage } from "./page.js";
24
+ const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
25
+ const json = (res, status, body) => {
26
+ res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
27
+ res.end(JSON.stringify(body));
28
+ };
29
+ function readBody(req, max = 200_000) {
30
+ return new Promise((resolve, reject) => {
31
+ let raw = "";
32
+ req.on("data", (d) => {
33
+ raw += d;
34
+ if (raw.length > max)
35
+ reject(new Error("too large"));
36
+ });
37
+ req.on("end", () => {
38
+ try {
39
+ resolve(raw ? JSON.parse(raw) : {});
40
+ }
41
+ catch {
42
+ reject(new Error("bad json"));
43
+ }
44
+ });
45
+ req.on("error", reject);
46
+ });
47
+ }
48
+ /** The default way a run starts from the dashboard: its own process, so closing the page (or the app) doesn't stop it. */
49
+ function spawnRun(root, goal) {
50
+ const logs = path.join(root, ".huddle", "logs");
51
+ fs.mkdirSync(logs, { recursive: true });
52
+ const fd = fs.openSync(path.join(logs, `app-run-${new Date().toISOString().replace(/[:.]/g, "-")}.log`), "a");
53
+ const child = spawn(process.execPath, [CLI, "join", goal], {
54
+ cwd: root,
55
+ detached: true,
56
+ stdio: ["ignore", fd, fd],
57
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: undefined },
58
+ windowsHide: true,
59
+ });
60
+ child.unref();
61
+ fs.closeSync(fd);
62
+ }
63
+ /** Every agent Huddle knows, probed quietly (no spinner rows): who's ready, signed out, skipped. */
64
+ async function probeAgents() {
65
+ const { skippedAgents } = await import("../ui/app.js");
66
+ const all = applySkips(await Promise.all(ADAPTERS.map((a) => probeAsync(a))), skippedAgents());
67
+ return all.filter((r) => !LEGACY.has(r.adapter.id) || r.state === "ready");
68
+ }
69
+ export async function startApp(opts) {
70
+ const root = opts.root;
71
+ const dbFile = path.join(root, ".huddle", "huddle.db");
72
+ fs.mkdirSync(path.dirname(dbFile), { recursive: true });
73
+ const db = new RoomDB(dbFile);
74
+ const token = crypto.randomBytes(18).toString("base64url");
75
+ const startRun = opts.startRun ?? ((goal) => spawnRun(root, goal));
76
+ let agents = opts.agents ?? null;
77
+ let probing = null;
78
+ const probe = () => {
79
+ probing ??= probeAgents()
80
+ .then((r) => void (agents = r))
81
+ .catch(() => void (agents = []))
82
+ .finally(() => (probing = null));
83
+ return probing;
84
+ };
85
+ if (!agents)
86
+ void probe();
87
+ const runsNow = () => splitRuns(db.allEvents());
88
+ /** Changes whenever the run or the history does: the page refetches on it. */
89
+ const signature = () => {
90
+ let live = 0;
91
+ try {
92
+ live = fs.statSync(path.join(root, ".huddle", "live.json")).mtimeMs;
93
+ }
94
+ catch {
95
+ /* no run yet */
96
+ }
97
+ const last = db.recentEvents(1)[0];
98
+ return `${live}:${last?.id ?? ""}`;
99
+ };
100
+ const state = () => {
101
+ const runs = runsNow();
102
+ const live = readLive(root);
103
+ return {
104
+ version: packageInfo().version,
105
+ repo: { name: path.basename(root), root },
106
+ agents: (agents ?? []).map((r) => ({ id: r.adapter.id, vendor: r.adapter.vendor, state: r.state, detail: r.detail ?? "" })),
107
+ probing: !agents,
108
+ live,
109
+ runs: runRows(runs).reverse(),
110
+ };
111
+ };
112
+ const runDoc = (which) => {
113
+ const runs = runsNow();
114
+ const run = findRun(runs, which ?? undefined);
115
+ if (!run)
116
+ return null;
117
+ const doc = buildExport(run, runs.length, { level: "chat", logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
118
+ const made = run.events.find((e) => e.type === "plan.made");
119
+ const plain = made?.p?.plain && typeof made.p.plain === "object" ? made.p.plain : null;
120
+ return { ...doc, plain, report: readReport(root, run.id) };
121
+ };
122
+ const allowedHost = (h, port) => h === `127.0.0.1:${port}` || h === `localhost:${port}`;
123
+ const server = http.createServer(async (req, res) => {
124
+ const url = new URL(req.url ?? "/", "http://local");
125
+ const port = server.address().port;
126
+ if (!allowedHost(req.headers.host, port))
127
+ return json(res, 403, { error: "local only" });
128
+ try {
129
+ if (req.method === "GET" && url.pathname === "/") {
130
+ if (url.searchParams.get("t") !== token) {
131
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
132
+ return res.end("Open the link `huddle app` printed (it carries a key for this session).");
133
+ }
134
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "referrer-policy": "no-referrer" });
135
+ return res.end(appPage(token));
136
+ }
137
+ if (!url.pathname.startsWith("/api/"))
138
+ return json(res, 404, { error: "not found" });
139
+ // The token: a header for fetches, ?t= for the event stream and downloads.
140
+ const given = req.headers["x-huddle-token"] ?? url.searchParams.get("t");
141
+ if (given !== token)
142
+ return json(res, 401, { error: "no key" });
143
+ const route = `${req.method} ${url.pathname}`;
144
+ if (route === "GET /api/state")
145
+ return json(res, 200, state());
146
+ if (route === "GET /api/run") {
147
+ const doc = runDoc(url.searchParams.get("n"));
148
+ return doc ? json(res, 200, doc) : json(res, 404, { error: "no such run" });
149
+ }
150
+ if (route === "GET /api/export") {
151
+ const runs = runsNow();
152
+ const run = findRun(runs, url.searchParams.get("n") ?? undefined);
153
+ if (!run)
154
+ return json(res, 404, { error: "no such run" });
155
+ const format = EXPORT_FORMATS.includes(url.searchParams.get("format") ?? "") ? url.searchParams.get("format") : "html";
156
+ const level = EXPORT_LEVELS.includes(url.searchParams.get("level") ?? "") ? url.searchParams.get("level") : "chat";
157
+ const doc = buildExport(run, runs.length, { level, logsDir: path.join(root, ".huddle", "logs"), repoRoot: root });
158
+ const type = format === "json" ? "application/json" : format === "html" ? "text/html" : "text/markdown";
159
+ res.writeHead(200, {
160
+ "content-type": `${type}; charset=utf-8`,
161
+ "content-disposition": `attachment; filename="huddle-run-${run.index}-${level}.${format}"`,
162
+ });
163
+ return res.end(renderExport(doc, format));
164
+ }
165
+ if (route === "GET /api/events") {
166
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
167
+ let last = "";
168
+ const tick = () => {
169
+ const sig = signature();
170
+ if (sig !== last)
171
+ res.write(`data: ${JSON.stringify({ sig: (last = sig) })}\n\n`);
172
+ else
173
+ res.write(": ping\n\n");
174
+ };
175
+ tick();
176
+ const t = setInterval(tick, 1000);
177
+ req.on("close", () => clearInterval(t));
178
+ return;
179
+ }
180
+ if (route === "POST /api/agents") {
181
+ agents = null;
182
+ await probe();
183
+ return json(res, 200, state());
184
+ }
185
+ if (route === "POST /api/goal") {
186
+ const body = await readBody(req);
187
+ const goal = String(body.goal ?? "").trim();
188
+ if (!goal)
189
+ return json(res, 400, { error: "say what to build" });
190
+ if (readLive(root))
191
+ return json(res, 409, { error: "a run is already going — talk to it, or stop it first" });
192
+ startRun(goal);
193
+ return json(res, 200, { ok: true });
194
+ }
195
+ if (route === "POST /api/say" || route === "POST /api/answer" || route === "POST /api/stop") {
196
+ const body = await readBody(req);
197
+ if (!readLive(root))
198
+ return json(res, 409, { error: "nothing is running" });
199
+ const c = route === "POST /api/stop"
200
+ ? { type: "stop" }
201
+ : route === "POST /api/say"
202
+ ? { type: "say", text: String(body.text ?? "").slice(0, 4000) }
203
+ : { type: "answer", id: String(body.id ?? ""), line: String(body.line ?? "").slice(0, 2000) };
204
+ if (c.type === "say" && !c.text.trim())
205
+ return json(res, 400, { error: "empty" });
206
+ postCommand(root, c);
207
+ return json(res, 200, { ok: true });
208
+ }
209
+ return json(res, 404, { error: "not found" });
210
+ }
211
+ catch (e) {
212
+ return json(res, 500, { error: e?.message ?? String(e) });
213
+ }
214
+ });
215
+ const port = await new Promise((resolve, reject) => {
216
+ server.once("error", reject);
217
+ server.listen(opts.port ?? 0, "127.0.0.1", () => resolve(server.address().port));
218
+ });
219
+ const url = `http://127.0.0.1:${port}/?t=${token}`;
220
+ writeAppInfo(root, { pid: process.pid, url });
221
+ const close = () => {
222
+ server.closeAllConnections?.();
223
+ server.close();
224
+ clearAppInfo(root);
225
+ db.close();
226
+ };
227
+ return { url, port, token, close };
228
+ }
229
+ /** `huddle app [--port n] [--no-open]` */
230
+ export async function runApp(args) {
231
+ const { C } = await import("../ui/term.js");
232
+ const { isGitRepo } = await import("../sandbox/worktree.js");
233
+ const { ensureInit, repoRoot } = await import("../ui/app.js");
234
+ if (!isGitRepo(process.cwd()) || !ensureInit()) {
235
+ console.log(C.red("✗ Not a git repo.") + " Run `huddle app` inside a project (`git init` makes one).");
236
+ process.exitCode = 1;
237
+ return;
238
+ }
239
+ const i = args.indexOf("--port");
240
+ const port = i >= 0 ? Number(args[i + 1]) : undefined;
241
+ const app = await startApp({ root: repoRoot(), port });
242
+ console.log(`${C.green("✓")} Huddle is open at ${C.bold(app.url)}`);
243
+ console.log(C.faint(" runs keep going if you close the page · ctrl-c closes the dashboard"));
244
+ if (!args.includes("--no-open"))
245
+ openBrowser(app.url);
246
+ const bye = () => {
247
+ app.close();
248
+ process.exit(0);
249
+ };
250
+ process.on("SIGINT", bye);
251
+ process.on("SIGTERM", bye);
252
+ }
253
+ function openBrowser(url) {
254
+ const [cmd, ...a] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
255
+ try {
256
+ spawn(cmd, a, { stdio: "ignore", detached: true, windowsHide: true }).on("error", () => { }).unref();
257
+ }
258
+ catch {
259
+ /* no browser: the URL is printed */
260
+ }
261
+ }
262
+ //# sourceMappingURL=server.js.map
package/dist/args.js ADDED
@@ -0,0 +1,18 @@
1
+ // What `huddle <words>` means.
2
+ const COMMANDS = new Set("app dashboard plug init setup agents doctor join board view watch story brief runs export lessons up down peers add ctx context attach land connect disconnect guard mail relay version help".split(" "));
3
+ /**
4
+ * `huddle build a temperature converter` / `huddle "fix the login bug"`: a goal
5
+ * in plain words, without `join` or quotes. Found live (0.1.2 test): "as a
6
+ * normal user, I would never write it like that". A command name, a flag or a
7
+ * single unknown word stays what it was (an unknown command is still an error).
8
+ */
9
+ export function asGoal(args) {
10
+ const [cmd, ...rest] = args;
11
+ if (!cmd || cmd.startsWith("-") || COMMANDS.has(cmd))
12
+ return null;
13
+ const words = [cmd, ...rest.filter((a) => !a.startsWith("--"))].join(" ").trim().split(/\s+/);
14
+ if (cmd === "build" || cmd === "make" || words.length >= 3 || /\s/.test(cmd))
15
+ return ["join", ...(cmd === "build" ? rest : args)];
16
+ return null;
17
+ }
18
+ //# sourceMappingURL=args.js.map
@@ -19,7 +19,7 @@ import readline from "node:readline";
19
19
  import { ADAPTERS } from "../adapters/index.js";
20
20
  import { C } from "../ui/term.js";
21
21
  import { CLI_PATH, ensureLive, liveFiles, mainRoot } from "./live.js";
22
- import { LIVE_INSTRUCTIONS } from "./plug.js";
22
+ import { liveInstructions } from "./plug.js";
23
23
  const TOML_START = "# >>> huddle connect (remove with `huddle disconnect`) >>>";
24
24
  const TOML_END = "# <<< huddle connect <<<";
25
25
  // Codex's top-level keys must come before any [table], so the instructions get
@@ -35,7 +35,10 @@ const CLAUDE_ALLOW = "mcp__huddle";
35
35
  export function livePlug(repo, agent) {
36
36
  return { command: process.execPath, args: [CLI_PATH, "plug"], env: { HUDDLE_LIVE: "1", HUDDLE_AGENT_ID: agent, HUDDLE_REPO: repo } };
37
37
  }
38
- export const guardCommand = () => `"${process.execPath}" "${CLI_PATH}" guard`;
38
+ // Quoted (paths with spaces: C:\Users\Some Name\…) and with forward slashes on
39
+ // Windows, which node accepts and which survive both bash and cmd/PowerShell.
40
+ const hookPath = (p) => (process.platform === "win32" ? p.replace(/\\/g, "/") : p);
41
+ export const guardCommand = () => `"${hookPath(process.execPath)}" "${hookPath(CLI_PATH)}" guard`;
39
42
  /** What `huddle connect` would write for one agent. */
40
43
  export function wiresFor(adapter, repo, opts = {}) {
41
44
  const plug = livePlug(repo, adapter.id);
@@ -70,7 +73,7 @@ export function wiresFor(adapter, repo, opts = {}) {
70
73
  file: ".cursor/rules/huddle.mdc",
71
74
  kind: "rule",
72
75
  why: "tells Cursor to check in with Huddle before editing",
73
- content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${LIVE_INSTRUCTIONS}\n`,
76
+ content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${liveInstructions(repo)}\n`,
74
77
  });
75
78
  return out;
76
79
  }
@@ -149,7 +152,7 @@ export function applyWire(w, current, repo) {
149
152
  // config-reference). A user's own top-level value wins (TOML forbids two).
150
153
  const top = base.split(/^\s*\[/m)[0];
151
154
  const own = /^\s*developer_instructions\s*=/m.test(top);
152
- const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(LIVE_INSTRUCTIONS)}\n${TOML_TOP_END}\n\n`;
155
+ const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(liveInstructions(repo))}\n${TOML_TOP_END}\n\n`;
153
156
  return { text: head + (base.trim() ? base.replace(/\n*$/, "\n\n") : "") + codexBlock(livePlug(repo, w.agent)) + "\n" };
154
157
  }
155
158
  case "claude-hook": {
@@ -14,13 +14,17 @@
14
14
  import { execFileSync } from "node:child_process";
15
15
  import { liveCall, mainRoot, readLive } from "./live.js";
16
16
  /** This process's ancestors (the hook's shell, then Claude itself, …). */
17
- export function ancestors(max = 8) {
17
+ export function ancestors(max = 8, platform = process.platform) {
18
18
  const out = [];
19
+ // Windows has no `ps`: one PowerShell call lists every process's parent.
20
+ const parents = platform === "win32" ? windowsParents() : null;
19
21
  let pid = process.ppid;
20
22
  for (let i = 0; i < max && pid > 1; i++) {
21
23
  out.push(pid);
22
24
  try {
23
- pid = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
25
+ pid = parents
26
+ ? (parents.get(pid) ?? 0)
27
+ : Number(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
24
28
  }
25
29
  catch {
26
30
  break;
@@ -28,6 +32,21 @@ export function ancestors(max = 8) {
28
32
  }
29
33
  return out;
30
34
  }
35
+ /** pid → parent pid, from `Get-CimInstance Win32_Process`. Empty if that fails. */
36
+ export function windowsParents(run = () => execFileSync("powershell.exe", ["-NoProfile", "-Command", "Get-CimInstance Win32_Process | ForEach-Object { \"$($_.ProcessId) $($_.ParentProcessId)\" }"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000, windowsHide: true })) {
37
+ const m = new Map();
38
+ try {
39
+ for (const l of run().split(/\r?\n/)) {
40
+ const [a, b] = l.trim().split(/\s+/).map(Number);
41
+ if (a > 0 && b >= 0)
42
+ m.set(a, b);
43
+ }
44
+ }
45
+ catch {
46
+ /* no PowerShell: the guard still works, just without pid matching */
47
+ }
48
+ return m;
49
+ }
31
50
  async function readStdin() {
32
51
  let s = "";
33
52
  for await (const c of process.stdin)
@@ -103,7 +103,7 @@ export async function pingLive(st) {
103
103
  return false;
104
104
  }
105
105
  }
106
- function ensureHuddleDir(root) {
106
+ export function ensureHuddleDir(root) {
107
107
  const f = liveFiles(root);
108
108
  fs.mkdirSync(path.dirname(f.log), { recursive: true });
109
109
  const gi = path.join(f.dir, ".gitignore");
@@ -142,6 +142,7 @@ export async function ensureLive(root, timeoutMs = 8000) {
142
142
  const child = spawn(process.execPath, [CLI_PATH, "up", "--serve"], {
143
143
  cwd: root,
144
144
  detached: true,
145
+ windowsHide: true, // detached on Windows = a new console window, unless hidden
145
146
  stdio: ["ignore", out, out],
146
147
  env: { ...process.env, HUDDLE_LIVE_ROOT: root },
147
148
  });
@@ -217,6 +218,9 @@ export async function stopLive(root) {
217
218
  process.kill(st.pid, "SIGTERM");
218
219
  for (let i = 0; i < 50 && alive(st.pid); i++)
219
220
  await new Promise((r) => setTimeout(r, 100));
221
+ // On Windows "SIGTERM" is TerminateProcess: the daemon's handler never runs, so tidy up for it.
222
+ if (!alive(st.pid))
223
+ fs.rmSync(liveFiles(root).state, { force: true });
220
224
  return true;
221
225
  }
222
226
  //# sourceMappingURL=live.js.map
@@ -4,9 +4,12 @@
4
4
  // no task id: the session says hello to the repo's live room (starting it if
5
5
  // needed), and its tools are about sharing the repo safely with the others.
6
6
  import { execFileSync } from "node:child_process";
7
+ import path from "node:path";
7
8
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
9
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
10
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
11
+ import { contextTools, toContent } from "../mcp/context-tools.js";
12
+ import { packageInfo } from "../preflight.js";
10
13
  import { checkoutRoot, ensureLive, liveCall, mainRoot, roomCall } from "./live.js";
11
14
  /** What every attached agent is told, via the MCP `instructions` field. */
12
15
  export const LIVE_INSTRUCTIONS = `
@@ -16,7 +19,14 @@ Before you edit ANY file:
16
19
  2. huddle_announce_plan(intent, touches) with every file (or "dir/") you'll change. If it returns conflicts, DO NOT edit those files: huddle_note the holder or pick other work, and tell your user.
17
20
  3. When you finish a change, huddle_release so the others can proceed.
18
21
  Notes from other sessions arrive in "notes_for_you" on any huddle_* reply — read them and answer with huddle_note. Record lasting decisions with huddle_remember.
22
+ The shared screen holds what your user put up for everyone (logs, docs, screenshots): huddle_context lists it, huddle_context_read / huddle_context_search open it, huddle_context_note pins what you found.
19
23
  `.trim();
24
+ /**
25
+ * LIVE_INSTRUCTIONS plus where the last team run's hand-off brief is
26
+ * (ui/brief.ts). An attach session may sit in a worktree, so the path is the
27
+ * main checkout's, absolute.
28
+ */
29
+ export const liveInstructions = (repo) => `${LIVE_INSTRUCTIONS}\nIf ${path.join(repo, ".huddle", "BRIEF.md")} exists, read it before changing code: the hand-off from the last Huddle team run in this repo (goal, decisions, contract, open issues).`;
20
30
  const tools = [
21
31
  {
22
32
  name: "huddle_peers",
@@ -119,7 +129,12 @@ export async function runLivePlug() {
119
129
  await sayHello();
120
130
  return roomCall(st, method, p, body, sid);
121
131
  };
132
+ // The shared screen: the same tools (and Room routes) as a `huddle join` agent's Plug.
133
+ const screen = contextTools((p, method, body) => plain(method, p, body ?? {}));
122
134
  const call = (name, a) => {
135
+ const ctx = screen.find((t) => t.name === name);
136
+ if (ctx)
137
+ return ctx.handler(a);
123
138
  switch (name) {
124
139
  case "huddle_peers":
125
140
  return live("peers", {});
@@ -139,12 +154,15 @@ export async function runLivePlug() {
139
154
  }
140
155
  throw new Error(`unknown tool: ${name}`);
141
156
  };
142
- const server = new Server({ name: "huddle", version: "0.1.0" }, { capabilities: { tools: {} }, instructions: LIVE_INSTRUCTIONS });
143
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: tools.map((t) => ({ ...t })) }));
157
+ const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: liveInstructions(repo) });
158
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
159
+ tools: [...tools.map((t) => ({ ...t })), ...screen.map(({ handler: _h, ...t }) => t)],
160
+ }));
144
161
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
145
162
  try {
146
163
  const result = await call(req.params.name, req.params.arguments ?? {});
147
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
164
+ // toContent: JSON text, plus an MCP image block for a screenshot.
165
+ return { content: toContent(result) };
148
166
  }
149
167
  catch (e) {
150
168
  // Never wedge the agent: say what's wrong and let it carry on.
@@ -0,0 +1,150 @@
1
+ // Attach mode, the human's side of the shared screen: `huddle add` and
2
+ // `huddle ctx`, typed in any terminal while your own claude / codex / cursor
3
+ // sessions run in theirs.
4
+ //
5
+ // huddle add err.log src/auth/ "*.png" files, folders (one bundle), globs
6
+ // cat err.log | huddle add - stdin (pbpaste | huddle add - too)
7
+ // huddle ctx [show <id>|rm <id>|note <id> <text>|clear]
8
+ //
9
+ // Same store, same commands as the `huddle` prompt (src/ui/paste.ts on
10
+ // src/room/context.ts) — only the db differs: the live room's .huddle/live.db.
11
+ // The room reads that db on every call, so writing it here is the same whether
12
+ // the room is up or not; when it's up, each session hears about a new item (or
13
+ // a pinned note) in notes_for_you on its next huddle_* call — the path
14
+ // huddle_note takes (src/room/live.ts, inbox).
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { getAdapter } from "../adapters/index.js";
18
+ import { ContextStore, normalizePathToken } from "../room/context.js";
19
+ import { RoomDB } from "../room/db.js";
20
+ import { addPaths, addPiped, ctxCommand } from "../ui/paste.js";
21
+ import { C } from "../ui/term.js";
22
+ import { confirm } from "./connect.js";
23
+ import { ensureHuddleDir, liveCall, liveFiles, mainRoot, pingLive, readLive } from "./live.js";
24
+ /** The sessions in the live room right now (agent names), or null when it's down. */
25
+ async function liveAgents(repo) {
26
+ const st = readLive(repo);
27
+ if (!st || !(await pingLive(st)))
28
+ return null;
29
+ try {
30
+ const r = await liveCall(st, "peers", {}, "", 2000);
31
+ return (r.body.sessions ?? []).map((s) => String(s.agent));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** A ScreenUI on the live room's store. `interactive` = a person can answer the secrets question. */
38
+ function screenUI(repo, agents, interactive) {
39
+ let db = null;
40
+ const ui = {
41
+ print: (l = "") => console.log(l),
42
+ yes: (q, def = true) => confirm(q, def),
43
+ db: async () => {
44
+ if (!db) {
45
+ ensureHuddleDir(repo); // .huddle/ with its own .gitignore
46
+ db = new RoomDB(liveFiles(repo).db);
47
+ }
48
+ return db;
49
+ },
50
+ audience: () => {
51
+ const list = [...new Set(agents ?? [])];
52
+ return { agents: list, companies: new Set(list.map((a) => getAdapter(a)?.vendor ?? a)).size };
53
+ },
54
+ root: () => repo,
55
+ interactive,
56
+ announce: false, // the live room delivers it with each session's notes
57
+ };
58
+ return { ui, close: () => db?.close() };
59
+ }
60
+ function needRepo() {
61
+ const repo = mainRoot();
62
+ if (!repo)
63
+ console.log(C.red("✗ Not a git repo.") + " The shared screen belongs to a repo — cd into the one your agents work in.");
64
+ return repo;
65
+ }
66
+ /**
67
+ * A path as typed: file:// URLs and ~ resolved (cmd.exe expands neither), and
68
+ * Windows separators read as "/" where "\" isn't one (logs\err.log on macOS).
69
+ */
70
+ export function cliPath(arg, cwd = process.cwd()) {
71
+ const p = normalizePathToken(arg);
72
+ if (path.sep === "\\" || !p.includes("\\") || fs.existsSync(path.resolve(cwd, p)))
73
+ return p;
74
+ return p.replace(/\\/g, "/");
75
+ }
76
+ async function readAllStdin(stdin = process.stdin) {
77
+ let s = "";
78
+ for await (const c of stdin)
79
+ s += c.toString();
80
+ return s;
81
+ }
82
+ /** `huddle add <file|folder|glob|->…` — `--title <t>` names a stdin item. */
83
+ export async function runAdd(args) {
84
+ const repo = needRepo();
85
+ if (!repo)
86
+ return void (process.exitCode = 1);
87
+ let title;
88
+ const paths = [];
89
+ let piped = false;
90
+ for (let i = 0; i < args.length; i++) {
91
+ const a = args[i];
92
+ if (a === "--title" && i + 1 < args.length)
93
+ title = args[++i];
94
+ else if (a.startsWith("--title="))
95
+ title = a.slice(8);
96
+ else if (a === "-")
97
+ piped = true;
98
+ else
99
+ paths.push(cliPath(a));
100
+ }
101
+ if (!paths.length && !piped) {
102
+ console.log(`usage: ${C.bold("huddle add <file|folder|glob>")} … ${C.faint("· or pipe it:")} ${C.bold("cat err.log | huddle add -")}`);
103
+ return void (process.exitCode = 1);
104
+ }
105
+ if (piped && process.stdin.isTTY) {
106
+ console.log(C.red(" ✗ nothing piped in") + C.faint(" — e.g. pbpaste | huddle add - · cat err.log | huddle add -"));
107
+ return void (process.exitCode = 1);
108
+ }
109
+ const agents = await liveAgents(repo);
110
+ // Stdin is the pipe with `-`, so nobody can answer: secrets are redacted.
111
+ const { ui, close } = screenUI(repo, agents, !piped && !!process.stdin.isTTY && !!process.stdout.isTTY);
112
+ let failed = 0;
113
+ let before = 0;
114
+ try {
115
+ const store = ContextStore.open((await ui.db()));
116
+ before = store.list().length;
117
+ if (piped && !(await addPiped(store, await readAllStdin(), ui, { title })))
118
+ failed++;
119
+ if (paths.length)
120
+ failed += await addPaths(paths, ui);
121
+ const added = store.list().length - before;
122
+ if (added > 0)
123
+ console.log(C.faint(agents === null
124
+ ? " no live room right now — sessions find it with huddle_context when they're up"
125
+ : agents.length
126
+ ? ` ${[...new Set(agents)].join(", ")} see it on their next huddle_* call`
127
+ : " live room is up; sessions that join find it with huddle_context"));
128
+ }
129
+ finally {
130
+ close();
131
+ }
132
+ if (failed)
133
+ process.exitCode = 1;
134
+ }
135
+ /** `huddle ctx [list|show <id>|rm <id…>|note <id> <text>|clear|add <path…>]` */
136
+ export async function runCtx(args) {
137
+ const repo = needRepo();
138
+ if (!repo)
139
+ return void (process.exitCode = 1);
140
+ const { ui, close } = screenUI(repo, await liveAgents(repo), !!process.stdin.isTTY && !!process.stdout.isTTY);
141
+ try {
142
+ if (args[0] === "add")
143
+ return void (await addPaths(args.slice(1).map((a) => cliPath(a)), ui));
144
+ await ctxCommand(args.join(" "), ui);
145
+ }
146
+ finally {
147
+ close();
148
+ }
149
+ }
150
+ //# sourceMappingURL=screen.js.map
@@ -4,11 +4,13 @@
4
4
  // other's files mid-edit; `huddle land` brings a session's branch home through
5
5
  // the merge engine (commit → tests → rebase → fast-forward), after you say so.
6
6
  //
7
- // Plus `huddle up` / `huddle down` / `huddle peers`, the human's view of the room.
7
+ // Plus `huddle up` / `huddle down` / `huddle peers`, the human's view of the room
8
+ // (`huddle add` / `huddle ctx`, its shared screen, are in screen.ts).
8
9
  import { spawnSync } from "node:child_process";
9
10
  import fs from "node:fs";
10
11
  import path from "node:path";
11
12
  import { getAdapter } from "../adapters/index.js";
13
+ import { spawnToolSync } from "../platform.js";
12
14
  import { createWorktree, landMerge, prepareMerge, removeWorktree } from "../sandbox/worktree.js";
13
15
  import { C, dot } from "../ui/term.js";
14
16
  import { detectTestCmd } from "../ui/app.js";
@@ -78,7 +80,7 @@ export async function runAttach(args) {
78
80
  const { wt, cmd } = prepareAttach(repo, agentId);
79
81
  console.log(`${dot(agentId)} ${C.bold(agentId)} in its own worktree ${C.faint(path.relative(repo, wt.path))} on ${C.bold(wt.branch)}`);
80
82
  console.log(C.faint(` live room ${state.url} · the others see it in huddle_peers · when it's done: huddle land ${wt.branch}\n`));
81
- const r = spawnSync(cmd[0], [...cmd.slice(1), ...passthrough], { cwd: wt.path, stdio: "inherit" });
83
+ const r = spawnToolSync([...cmd, ...passthrough], { cwd: wt.path, stdio: "inherit" });
82
84
  if (r.error)
83
85
  console.log(C.red(`✗ couldn't start ${cmd[0]}: ${r.error.message}`));
84
86
  const changed = spawnSync("git", ["status", "--porcelain"], { cwd: wt.path, encoding: "utf8" }).stdout.trim();
@@ -162,6 +164,9 @@ export async function runPeers() {
162
164
  console.log(` ${dot(s.agent)} ${C.bold(s.id.padEnd(12))} ${s.doing}`);
163
165
  console.log(C.faint(` ${s.branch || "?"} · ${s.worktree} · holds ${s.holds.length ? s.holds.join(", ") : "nothing"} · seen ${s.seen_s_ago}s ago`));
164
166
  }
167
+ const sc = r.body.screen;
168
+ if (sc?.items)
169
+ console.log(`\n ${C.bold("screen")} ${sc.items} item${sc.items === 1 ? "" : "s"}${sc.latest_note ? ` · latest note: ${sc.latest_note}` : ""} ${C.faint("· huddle ctx")}`);
165
170
  const notes = r.body.notes ?? [];
166
171
  if (notes.length) {
167
172
  console.log(`\n ${C.bold("notes")}`);