huddle-cli 0.0.0-stage → 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 (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,412 @@
1
+ // Attach mode, the wiring: `huddle connect` puts the Plug into each installed
2
+ // agent's PROJECT-scoped config for this repo, so the claude / codex / cursor
3
+ // sessions you start yourself all join the same live room. Nothing global is
4
+ // touched (~/.claude, ~/.codex, ~/.cursor stay as they are), every file is
5
+ // shown before it's written, and every file goes into .git/info/exclude so it
6
+ // is never committed. `huddle disconnect` takes out exactly what we put in.
7
+ //
8
+ // claude .mcp.json (Claude asks you to approve it once)
9
+ // .claude/settings.local.json PreToolUse guard → `huddle guard`
10
+ // codex .codex/config.toml (Codex loads .codex/ only in trusted projects)
11
+ // cursor .cursor/mcp.json + .cursor/rules/huddle.mdc
12
+ // gemini .gemini/settings.json
13
+ // antigravity .agents/mcp_config.json (community-documented; unverified)
14
+ // any other adapter whose Plug wiring is a project file (adapter.plug.file.in = "worktree")
15
+ import { execFileSync } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import readline from "node:readline";
19
+ import { ADAPTERS } from "../adapters/index.js";
20
+ import { C } from "../ui/term.js";
21
+ import { CLI_PATH, ensureLive, liveFiles, mainRoot } from "./live.js";
22
+ import { LIVE_INSTRUCTIONS } from "./plug.js";
23
+ const TOML_START = "# >>> huddle connect (remove with `huddle disconnect`) >>>";
24
+ const TOML_END = "# <<< huddle connect <<<";
25
+ // Codex's top-level keys must come before any [table], so the instructions get
26
+ // their own block at the top of the file.
27
+ const TOML_TOP_START = "# >>> huddle connect: instructions (remove with `huddle disconnect`) >>>";
28
+ const TOML_TOP_END = "# <<< huddle connect: instructions <<<";
29
+ const EXCLUDE_START = "# >>> huddle connect >>>";
30
+ const EXCLUDE_END = "# <<< huddle connect <<<";
31
+ const HOOK_MATCHER = "Edit|Write|MultiEdit|NotebookEdit";
32
+ /** Claude permission rule for every tool of the `huddle` MCP server. */
33
+ const CLAUDE_ALLOW = "mcp__huddle";
34
+ /** The Plug in live mode. `repo` = the main checkout (where the room lives). */
35
+ export function livePlug(repo, agent) {
36
+ return { command: process.execPath, args: [CLI_PATH, "plug"], env: { HUDDLE_LIVE: "1", HUDDLE_AGENT_ID: agent, HUDDLE_REPO: repo } };
37
+ }
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`;
42
+ /** What `huddle connect` would write for one agent. */
43
+ export function wiresFor(adapter, repo, opts = {}) {
44
+ const plug = livePlug(repo, adapter.id);
45
+ if (adapter.id === "claude")
46
+ return [
47
+ { agent: "claude", file: ".mcp.json", kind: "mcp-json", why: "the Plug (Claude asks you to approve it once)", entry: { mcpServers: { huddle: { type: "stdio", ...plug } } } },
48
+ {
49
+ agent: "claude",
50
+ file: ".claude/settings.local.json",
51
+ kind: "claude-hook",
52
+ guard: opts.guard !== false,
53
+ why: opts.guard === false
54
+ ? "lets Claude use Huddle's tools without asking each time (they only talk to the local room)"
55
+ : "lets Claude use Huddle's tools without asking each time + pre-edit guard: asks you before Claude edits a file another session holds",
56
+ },
57
+ ];
58
+ if (adapter.id === "codex")
59
+ return [{ agent: "codex", file: ".codex/config.toml", kind: "codex-toml", why: "the Plug (Codex reads .codex/ only in projects you trust)" }];
60
+ if (adapter.plug.file?.in === "worktree" && adapter.plug.entry) {
61
+ const out = [
62
+ {
63
+ agent: adapter.id,
64
+ file: adapter.plug.file.path,
65
+ kind: "mcp-json",
66
+ why: adapter.id === "antigravity" ? "the Plug (path from community docs — unverified)" : "the Plug",
67
+ entry: adapter.plug.entry(plug),
68
+ },
69
+ ];
70
+ if (adapter.id === "cursor")
71
+ out.push({
72
+ agent: "cursor",
73
+ file: ".cursor/rules/huddle.mdc",
74
+ kind: "rule",
75
+ why: "tells Cursor to check in with Huddle before editing",
76
+ content: `---\ndescription: Coordinate with the other AI sessions in this repo through Huddle\nalwaysApply: true\n---\n\n${LIVE_INSTRUCTIONS}\n`,
77
+ });
78
+ return out;
79
+ }
80
+ return [];
81
+ }
82
+ function git(cwd, args) {
83
+ try {
84
+ return { ok: true, out: execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() };
85
+ }
86
+ catch {
87
+ return { ok: false, out: "" };
88
+ }
89
+ }
90
+ const tracked = (root, file) => git(root, ["ls-files", "--error-unmatch", "--", file]).ok;
91
+ function onPath(bin) {
92
+ if (path.isAbsolute(bin))
93
+ return fs.existsSync(bin);
94
+ try {
95
+ execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
96
+ return true;
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
102
+ // ---- pure edits (string in → string out), so tests can check them directly --
103
+ function readJson(text) {
104
+ if (!text?.trim())
105
+ return {};
106
+ return JSON.parse(text);
107
+ }
108
+ function deepMerge(a, b) {
109
+ const out = { ...a };
110
+ for (const [k, v] of Object.entries(b))
111
+ out[k] = v && typeof v === "object" && !Array.isArray(v) ? deepMerge(a[k] ?? {}, v) : v;
112
+ return out;
113
+ }
114
+ const tomlStr = (v) => JSON.stringify(v); // JSON strings/arrays are valid TOML
115
+ export function codexBlock(plug) {
116
+ return [
117
+ TOML_START,
118
+ "[mcp_servers.huddle]",
119
+ `command = ${tomlStr(plug.command)}`,
120
+ `args = ${tomlStr(plug.args)}`,
121
+ // Huddle's tools only talk to the local room — no files, no shell.
122
+ `default_tools_approval_mode = "approve"`,
123
+ "",
124
+ "[mcp_servers.huddle.env]",
125
+ ...Object.entries(plug.env).map(([k, v]) => `${k} = ${tomlStr(v)}`),
126
+ TOML_END,
127
+ ].join("\n");
128
+ }
129
+ const stripBlock = (text, start, end) => {
130
+ const i = text.indexOf(start);
131
+ const j = text.indexOf(end);
132
+ if (i < 0 || j < i)
133
+ return text;
134
+ return (text.slice(0, i).replace(/\n+$/, "") + "\n" + text.slice(j + end.length).replace(/^\n+/, "")).replace(/^\n+/, "");
135
+ };
136
+ /** The new content of `w`'s file, or null if it must be skipped (with why). */
137
+ export function applyWire(w, current, repo) {
138
+ switch (w.kind) {
139
+ case "mcp-json":
140
+ return { text: JSON.stringify(deepMerge(readJson(current), w.entry ?? {}), null, 2) + "\n" };
141
+ case "rule":
142
+ return { text: w.content ?? "" };
143
+ case "codex-toml": {
144
+ const base = stripBlock(stripBlock(current ?? "", TOML_TOP_START, TOML_TOP_END), TOML_START, TOML_END);
145
+ if (/^\s*\[mcp_servers\.huddle(\.|\])/m.test(base))
146
+ return { skip: "already has its own [mcp_servers.huddle]" };
147
+ // Found live (live-2): with only the Plug, a plain `codex exec` in a repo
148
+ // where claude and cursor were working never called a huddle_* tool —
149
+ // Codex doesn't surface an MCP server's `instructions`, which is how
150
+ // Claude and Cursor learn to check in. `developer_instructions` is
151
+ // documented in Codex's config reference (learn.chatgpt.com/docs/config-file/
152
+ // config-reference). A user's own top-level value wins (TOML forbids two).
153
+ const top = base.split(/^\s*\[/m)[0];
154
+ const own = /^\s*developer_instructions\s*=/m.test(top);
155
+ const head = own ? "" : `${TOML_TOP_START}\ndeveloper_instructions = ${tomlStr(LIVE_INSTRUCTIONS)}\n${TOML_TOP_END}\n\n`;
156
+ return { text: head + (base.trim() ? base.replace(/\n*$/, "\n\n") : "") + codexBlock(livePlug(repo, w.agent)) + "\n" };
157
+ }
158
+ case "claude-hook": {
159
+ const cfg = readJson(current);
160
+ // Found live: without an allow rule every huddle_* call is a permission
161
+ // prompt (and in `claude -p` a flat denial — the session then refuses to
162
+ // edit, as the instructions say). `mcp__huddle` = every tool of the
163
+ // `huddle` server (code.claude.com/docs/en/permissions). The tools only
164
+ // reach the local room: no files, no shell.
165
+ // `enabledMcpjsonServers` approves the project .mcp.json server the user
166
+ // just agreed to in `huddle connect` (code.claude.com/docs/en/mcp).
167
+ const allow = [...new Set([...(cfg.permissions?.allow ?? []), CLAUDE_ALLOW])];
168
+ const enabled = [...new Set([...(cfg.enabledMcpjsonServers ?? []), "huddle"])];
169
+ const out = { ...cfg, permissions: { ...(cfg.permissions ?? {}), allow }, enabledMcpjsonServers: enabled };
170
+ if (w.guard !== false) {
171
+ const pre = (cfg.hooks?.PreToolUse ?? []).filter((m) => !isOurHook(m));
172
+ pre.push({ matcher: HOOK_MATCHER, hooks: [{ type: "command", command: guardCommand(), timeout: 5 }] });
173
+ out.hooks = { ...(cfg.hooks ?? {}), PreToolUse: pre };
174
+ }
175
+ return { text: JSON.stringify(out, null, 2) + "\n" };
176
+ }
177
+ }
178
+ }
179
+ const isOurHook = (m) => (m?.hooks ?? []).some((h) => typeof h?.command === "string" && /cli\.js"? guard$/.test(h.command));
180
+ /** `text` with our part taken out. "" = nothing of the user's is left. */
181
+ export function unapplyWire(kind, current) {
182
+ switch (kind) {
183
+ case "mcp-json": {
184
+ const cfg = readJson(current);
185
+ if (cfg.mcpServers) {
186
+ delete cfg.mcpServers.huddle;
187
+ if (!Object.keys(cfg.mcpServers).length)
188
+ delete cfg.mcpServers;
189
+ }
190
+ return Object.keys(cfg).length ? JSON.stringify(cfg, null, 2) + "\n" : "";
191
+ }
192
+ case "rule":
193
+ return "";
194
+ case "codex-toml": {
195
+ const t = stripBlock(stripBlock(current, TOML_TOP_START, TOML_TOP_END), TOML_START, TOML_END);
196
+ return t.trim() ? t : "";
197
+ }
198
+ case "claude-hook": {
199
+ const cfg = readJson(current);
200
+ if (cfg.hooks?.PreToolUse) {
201
+ cfg.hooks.PreToolUse = cfg.hooks.PreToolUse.filter((m) => !isOurHook(m));
202
+ if (!cfg.hooks.PreToolUse.length)
203
+ delete cfg.hooks.PreToolUse;
204
+ if (!Object.keys(cfg.hooks).length)
205
+ delete cfg.hooks;
206
+ }
207
+ // Both entries only name Huddle's server, so they go with it.
208
+ if (Array.isArray(cfg.permissions?.allow)) {
209
+ cfg.permissions.allow = cfg.permissions.allow.filter((r) => r !== CLAUDE_ALLOW);
210
+ if (!cfg.permissions.allow.length)
211
+ delete cfg.permissions.allow;
212
+ if (!Object.keys(cfg.permissions).length)
213
+ delete cfg.permissions;
214
+ }
215
+ if (Array.isArray(cfg.enabledMcpjsonServers)) {
216
+ cfg.enabledMcpjsonServers = cfg.enabledMcpjsonServers.filter((n) => n !== "huddle");
217
+ if (!cfg.enabledMcpjsonServers.length)
218
+ delete cfg.enabledMcpjsonServers;
219
+ }
220
+ return Object.keys(cfg).length ? JSON.stringify(cfg, null, 2) + "\n" : "";
221
+ }
222
+ }
223
+ }
224
+ // ---- plan / apply / undo -------------------------------------------------------
225
+ export function planConnect(root, opts = {}) {
226
+ const repo = opts.repo ?? root;
227
+ const adapters = (opts.adapters ?? ADAPTERS).filter((a) => (opts.agents?.length ? opts.agents.includes(a.id) : onPath(a.bin)));
228
+ const out = [];
229
+ for (const a of adapters)
230
+ for (const w of wiresFor(a, repo, opts)) {
231
+ const abs = path.join(root, w.file);
232
+ if (tracked(root, w.file)) {
233
+ out.push({ ...w, action: "skip", reason: "tracked in git — Huddle won't edit a committed file" });
234
+ continue;
235
+ }
236
+ let current = null;
237
+ try {
238
+ current = fs.readFileSync(abs, "utf8");
239
+ }
240
+ catch {
241
+ /* new file */
242
+ }
243
+ let r;
244
+ try {
245
+ r = applyWire(w, current, repo);
246
+ }
247
+ catch (e) {
248
+ out.push({ ...w, action: "skip", reason: `can't parse it (${String(e?.message ?? e).slice(0, 60)})` });
249
+ continue;
250
+ }
251
+ if ("skip" in r)
252
+ out.push({ ...w, action: "skip", reason: r.skip });
253
+ else
254
+ out.push({ ...w, action: current === null ? "create" : "update" });
255
+ }
256
+ return out;
257
+ }
258
+ function excludeFile(root) {
259
+ const p = git(root, ["rev-parse", "--git-path", "info/exclude"]).out || ".git/info/exclude";
260
+ return path.isAbsolute(p) ? p : path.join(root, p);
261
+ }
262
+ /** Make the marked block in .git/info/exclude list exactly `files` (shared by every worktree). */
263
+ export function setExcludes(root, files) {
264
+ const ex = excludeFile(root);
265
+ const cur = fs.existsSync(ex) ? fs.readFileSync(ex, "utf8") : "";
266
+ const keep = [...new Set(files.map((f) => "/" + f.replace(/^\/+/, "")))].sort();
267
+ const base = stripBlock(cur, EXCLUDE_START, EXCLUDE_END).replace(/\n*$/, "\n");
268
+ const head = base.trim() ? base : "";
269
+ fs.mkdirSync(path.dirname(ex), { recursive: true });
270
+ fs.writeFileSync(ex, keep.length ? `${head}${EXCLUDE_START}\n${keep.join("\n")}\n${EXCLUDE_END}\n` : head);
271
+ }
272
+ const allFiles = (r) => Object.values(r).flat().map((m) => m.file);
273
+ const recordsPath = (repo) => path.join(liveFiles(repo).dir, "connect.json");
274
+ function readRecords(repo) {
275
+ try {
276
+ return JSON.parse(fs.readFileSync(recordsPath(repo), "utf8"));
277
+ }
278
+ catch {
279
+ return {};
280
+ }
281
+ }
282
+ function writeRecords(repo, r) {
283
+ fs.mkdirSync(path.dirname(recordsPath(repo)), { recursive: true });
284
+ fs.writeFileSync(recordsPath(repo), JSON.stringify(r, null, 2));
285
+ }
286
+ /** Write the planned files. `root` = the checkout; records live in the main repo. */
287
+ export function applyConnect(root, plan, repo = root) {
288
+ const records = readRecords(repo);
289
+ const mine = records[root] ?? [];
290
+ const written = [];
291
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
292
+ for (const w of plan) {
293
+ if (w.action === "skip")
294
+ continue;
295
+ const abs = path.join(root, w.file);
296
+ const current = fs.existsSync(abs) ? fs.readFileSync(abs, "utf8") : null;
297
+ const r = applyWire(w, current, repo);
298
+ if ("skip" in r)
299
+ continue;
300
+ let backup;
301
+ const known = mine.find((m) => m.file === w.file && m.kind === w.kind);
302
+ if (current !== null && !known) {
303
+ backup = path.join(liveFiles(repo).dir, "backup", stamp, w.file);
304
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
305
+ fs.writeFileSync(backup, current);
306
+ }
307
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
308
+ fs.writeFileSync(abs, r.text);
309
+ if (!known)
310
+ mine.push({ file: w.file, kind: w.kind, agent: w.agent, created: current === null, backup });
311
+ written.push(w.file);
312
+ }
313
+ records[root] = mine;
314
+ writeRecords(repo, records);
315
+ setExcludes(root, allFiles(records));
316
+ return written;
317
+ }
318
+ /** Take out what applyConnect put in. Returns the files touched. */
319
+ export function disconnect(root, repo = root) {
320
+ const records = readRecords(repo);
321
+ const mine = records[root] ?? [];
322
+ const touched = [];
323
+ for (const m of mine) {
324
+ const abs = path.join(root, m.file);
325
+ if (!fs.existsSync(abs))
326
+ continue;
327
+ let left;
328
+ try {
329
+ left = unapplyWire(m.kind, fs.readFileSync(abs, "utf8"));
330
+ }
331
+ catch {
332
+ continue; // the user rewrote it into something we can't parse — leave it
333
+ }
334
+ if (!left && m.created) {
335
+ fs.rmSync(abs);
336
+ // Tidy dirs we may have created (.cursor/rules, .codex …) if now empty.
337
+ for (let d = path.dirname(abs); d !== root && d.startsWith(root); d = path.dirname(d)) {
338
+ try {
339
+ fs.rmdirSync(d);
340
+ }
341
+ catch {
342
+ break;
343
+ }
344
+ }
345
+ }
346
+ else
347
+ fs.writeFileSync(abs, left);
348
+ touched.push(m.file);
349
+ }
350
+ delete records[root];
351
+ writeRecords(repo, records);
352
+ // Other checkouts (attach worktrees) that are still connected keep theirs.
353
+ setExcludes(root, allFiles(records));
354
+ return touched;
355
+ }
356
+ // ---- CLI -------------------------------------------------------------------
357
+ export async function confirm(q, def = false) {
358
+ if (!process.stdin.isTTY)
359
+ return false;
360
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
361
+ const a = await new Promise((r) => rl.question(`${q} ${def ? "[Y/n]" : "[y/N]"} `, r));
362
+ rl.close();
363
+ return a.trim() ? /^y/i.test(a.trim()) : def;
364
+ }
365
+ const flagList = (args, name) => args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);
366
+ export async function runConnect(args) {
367
+ const root = mainRoot();
368
+ if (!root) {
369
+ console.log(C.red("✗ Not a git repo.") + " Run this inside the repo your agents work in.");
370
+ process.exit(1);
371
+ }
372
+ const plan = planConnect(root, { agents: flagList(args, "agents"), guard: !args.includes("--no-guard") });
373
+ console.log(`${C.violet("huddle connect")} — your own claude / codex / cursor sessions in ${C.bold(path.basename(root))} will see each other\n`);
374
+ if (!plan.length) {
375
+ console.log(C.yellow("No supported agent CLI found on PATH.") + " Install claude, codex or cursor-agent first.");
376
+ return;
377
+ }
378
+ for (const w of plan) {
379
+ const act = w.action === "skip" ? C.yellow("skip ") : w.action === "create" ? C.green("create") : C.green("update");
380
+ console.log(` ${w.agent.padEnd(12)} ${act} ${w.file.padEnd(30)} ${C.faint(w.action === "skip" ? w.reason : w.why)}`);
381
+ }
382
+ const todo = plan.filter((w) => w.action !== "skip");
383
+ console.log(`\n ${C.faint("Project files only — your global agent configs aren't touched. All go in .git/info/exclude, so")}`);
384
+ console.log(` ${C.faint("they're never committed; existing files are backed up to .huddle/backup/. Undo: huddle disconnect")}\n`);
385
+ if (!todo.length)
386
+ return;
387
+ if (args.includes("--dry-run"))
388
+ return;
389
+ const ok = args.includes("--yes") || args.includes("-y") || (await confirm(`Write ${todo.length === 1 ? "this file" : `these ${todo.length} files`}?`));
390
+ if (!ok) {
391
+ console.log(process.stdin.isTTY ? "Nothing written." : "Nothing written — not a terminal, so run again with --yes to confirm.");
392
+ return;
393
+ }
394
+ const written = applyConnect(root, plan);
395
+ console.log(`${C.green("✓")} wrote ${written.join(", ")}`);
396
+ try {
397
+ const { state, started } = await ensureLive(root);
398
+ console.log(`${C.green("✓")} live room ${started ? "started" : "running"} ${C.faint(`(${state.url}, pid ${state.pid}) · stop it with huddle down`)}`);
399
+ }
400
+ catch (e) {
401
+ console.log(C.yellow(`! couldn't start the live room: ${e?.message ?? e}`) + " — agents start it themselves when they connect.");
402
+ }
403
+ console.log(`\nNow open your agents in this repo as usual. ${C.bold("huddle peers")} shows who's in and what they hold.`);
404
+ }
405
+ export async function runDisconnect() {
406
+ const root = mainRoot();
407
+ if (!root)
408
+ return void console.log(C.red("✗ Not a git repo."));
409
+ const touched = disconnect(root);
410
+ console.log(touched.length ? `${C.green("✓")} removed Huddle from ${touched.join(", ")}` : "Nothing to undo — this repo wasn't connected.");
411
+ }
412
+ //# sourceMappingURL=connect.js.map
@@ -0,0 +1,83 @@
1
+ // Attach mode, the pre-edit guard: `huddle guard` is a Claude Code PreToolUse
2
+ // hook on Edit/Write/MultiEdit/NotebookEdit (wired by `huddle connect`). It
3
+ // doesn't rely on the model remembering to check in:
4
+ //
5
+ // another live session holds the file → "ask": Claude's own permission
6
+ // prompt shows who holds it and why
7
+ // nobody does → lease it to this session, silently
8
+ // no live room / anything goes wrong → allow; Huddle never blocks your agent
9
+ //
10
+ // Hook I/O per https://code.claude.com/docs/en/hooks: JSON on stdin
11
+ // (tool_name, tool_input, cwd, session_id); on stdout
12
+ // {hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision,permissionDecisionReason}}.
13
+ // tool_input.file_path / notebook_path are the Edit/Write/NotebookEdit fields.
14
+ import { execFileSync } from "node:child_process";
15
+ import { liveCall, mainRoot, readLive } from "./live.js";
16
+ /** This process's ancestors (the hook's shell, then Claude itself, …). */
17
+ export function ancestors(max = 8, platform = process.platform) {
18
+ const out = [];
19
+ // Windows has no `ps`: one PowerShell call lists every process's parent.
20
+ const parents = platform === "win32" ? windowsParents() : null;
21
+ let pid = process.ppid;
22
+ for (let i = 0; i < max && pid > 1; i++) {
23
+ out.push(pid);
24
+ try {
25
+ pid = parents
26
+ ? (parents.get(pid) ?? 0)
27
+ : Number(execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
28
+ }
29
+ catch {
30
+ break;
31
+ }
32
+ }
33
+ return out;
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
+ }
50
+ async function readStdin() {
51
+ let s = "";
52
+ for await (const c of process.stdin)
53
+ s += c;
54
+ return s;
55
+ }
56
+ /** Decide for one hook payload. Exported for tests. */
57
+ export async function guardDecision(input, pids) {
58
+ const ti = input?.tool_input ?? {};
59
+ const file = ti.file_path ?? ti.notebook_path ?? ti.path;
60
+ if (!file)
61
+ return { decision: "allow" };
62
+ const root = process.env.HUDDLE_REPO || mainRoot(process.env.CLAUDE_PROJECT_DIR || input?.cwd || process.cwd());
63
+ if (!root)
64
+ return { decision: "allow" };
65
+ const st = readLive(root);
66
+ if (!st)
67
+ return { decision: "allow" };
68
+ const r = await liveCall(st, "guard", { path: String(file), pids }, "", 2500);
69
+ return r.body?.decision === "ask" ? { decision: "ask", reason: String(r.body.reason) } : { decision: "allow" };
70
+ }
71
+ export async function runGuard() {
72
+ try {
73
+ const input = JSON.parse((await readStdin()) || "{}");
74
+ const d = await guardDecision(input, ancestors());
75
+ if (d.decision === "ask")
76
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: d.reason } }) + "\n");
77
+ }
78
+ catch {
79
+ /* allow */
80
+ }
81
+ process.exit(0);
82
+ }
83
+ //# sourceMappingURL=guard.js.map