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
@@ -7,6 +7,8 @@ import { execFileSync } from "node:child_process";
7
7
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
+ import { contextTools, toContent } from "../mcp/context-tools.js";
11
+ import { packageInfo } from "../preflight.js";
10
12
  import { checkoutRoot, ensureLive, liveCall, mainRoot, roomCall } from "./live.js";
11
13
  /** What every attached agent is told, via the MCP `instructions` field. */
12
14
  export const LIVE_INSTRUCTIONS = `
@@ -16,6 +18,7 @@ Before you edit ANY file:
16
18
  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
19
  3. When you finish a change, huddle_release so the others can proceed.
18
20
  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.
21
+ 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
22
  `.trim();
20
23
  const tools = [
21
24
  {
@@ -119,7 +122,12 @@ export async function runLivePlug() {
119
122
  await sayHello();
120
123
  return roomCall(st, method, p, body, sid);
121
124
  };
125
+ // The shared screen: the same tools (and Room routes) as a `huddle join` agent's Plug.
126
+ const screen = contextTools((p, method, body) => plain(method, p, body ?? {}));
122
127
  const call = (name, a) => {
128
+ const ctx = screen.find((t) => t.name === name);
129
+ if (ctx)
130
+ return ctx.handler(a);
123
131
  switch (name) {
124
132
  case "huddle_peers":
125
133
  return live("peers", {});
@@ -139,12 +147,15 @@ export async function runLivePlug() {
139
147
  }
140
148
  throw new Error(`unknown tool: ${name}`);
141
149
  };
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 })) }));
150
+ const server = new Server({ name: "huddle", version: packageInfo().version }, { capabilities: { tools: {} }, instructions: LIVE_INSTRUCTIONS });
151
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
152
+ tools: [...tools.map((t) => ({ ...t })), ...screen.map(({ handler: _h, ...t }) => t)],
153
+ }));
144
154
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
145
155
  try {
146
156
  const result = await call(req.params.name, req.params.arguments ?? {});
147
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
157
+ // toContent: JSON text, plus an MCP image block for a screenshot.
158
+ return { content: toContent(result) };
148
159
  }
149
160
  catch (e) {
150
161
  // 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")}`);
package/dist/cli.js CHANGED
@@ -8,96 +8,151 @@
8
8
  // --agents=claude,codex only these agents work on this run
9
9
  // --council / --no-council every agent brainstorms the design first (default: big goals)
10
10
  // --qa / --no-qa a QA round after the build (default: on for big goals)
11
+ // --with <file|dir|glob> put files on the shared screen first (repeatable); piped stdin too
12
+ // huddle setup install + sign in to the agents you pick (asks first)
11
13
  // huddle doctor is this machine ready: node, git, room db, your agents
14
+ // huddle agents your agents: ready / not signed in / not installed, used or skipped
15
+ // huddle agents use|skip <ids> choose who works with you (remembered)
12
16
  // huddle --version print the version
13
17
  // huddle agents --all every connector (built in + agents.json) and how to get it
14
18
  // huddle agents install <id> install one connector's CLI, after asking
15
19
  // huddle board print the board + shared memory
16
20
  // huddle view [repo] the live view (ctrl-t) from another terminal, read-only
17
- // huddle story [--html f] replay the latest run: who said what to whom, reviews, merges
21
+ // huddle story [--html f] replay the latest run: who built on whose idea, who said what to whom, merges
22
+ // huddle lessons what Huddle learned across runs (--forget <id>, --clear)
18
23
  // huddle init set up .huddle/ in this repo (automatic on first run)
19
24
  // huddle up | down attach mode: start/stop this repo's live room
20
25
  // huddle connect | disconnect wire your own claude/codex/cursor sessions into it
21
26
  // huddle peers who's live in this repo and which files they hold
27
+ // huddle add <file|dir|glob|-> put it on the live room's shared screen (- = stdin)
28
+ // huddle ctx [show|rm|note|clear] what's on that screen; take things off, pin notes
22
29
  // huddle attach <agent> open an agent in its own worktree, connected
23
30
  // huddle land [branch] tests -> rebase -> merge an attach session's branch
24
31
  // huddle plug (internal) the MCP server each agent talks to
25
32
  // huddle guard (internal) Claude's pre-edit hook in attach mode
33
+ // huddle mail --hook (internal) Claude's post-tool hook: teammates' new messages
26
34
  import { packageInfo } from "./preflight.js"; // first: refuses old Node before anything else loads
27
- import { runPlug } from "./mcp/server.js";
28
- import { C } from "./ui/term.js";
29
- import { connectAgents, ensureInit, huddleDir, printBoard, runApp, runGoal } from "./ui/app.js";
30
- import { INVITE_CODE, joinRoom } from "./ui/remote.js";
31
- import { startRelay } from "./relay/server.js";
32
- import { runDoctor } from "./ui/doctor.js";
35
+ import { C, useAsciiOutput } from "./ui/term.js";
33
36
  import { SqliteUnavailableError } from "./room/sqlite.js";
34
- import { installConnector, printCatalog } from "./ui/catalog.js";
37
+ // Everything heavier loads per command: `huddle mail --hook` runs after every
38
+ // one of Claude's tool calls and has to start in well under 300ms.
39
+ const app = () => import("./ui/app.js");
40
+ const B = C.bold;
41
+ const F = C.faint;
42
+ // Every line fits 80 columns.
35
43
  const USAGE = `${C.violet("huddle")} — your agents, in the same room
36
44
 
37
- ${C.bold("huddle")} open Huddle
38
- ${C.bold('huddle join "<goal>"')} plan the goal and run your agents
39
- ${C.bold('huddle join "<goal>" --plan')} dry run — show who'd do what
40
- ${C.bold(" --approvals=safe|auto|yolo")} what agents may do without asking (default auto)
41
- ${C.bold(" --agents=claude,codex")} only these agents work on this run
42
- ${C.bold(" --council | --no-council")} every agent brainstorms the design first ${C.faint("(default: big goals only)")}
43
- ${C.bold(" --qa")} ${C.faint("/")} ${C.bold("--no-qa")} agents re-check the build and fix what they find ${C.faint("(default: big goals)")}
44
- ${C.bold("huddle join <address> <CODE>")} join someone's room from an invite
45
- ${C.bold("huddle doctor")} check this machine: node, git, your agents
46
- ${C.bold("huddle agents --all")} every connector Huddle knows, installed or not
47
- ${C.bold("huddle agents install <id>")} install one (asks first)
48
- ${C.bold("huddle relay")} ${C.faint("[--port 8080]")} run a relay so rooms work across networks
49
- ${C.bold("huddle board")} print the board
50
- ${C.bold("huddle view")} ${C.faint("[repo]")} watch a run live from another terminal ${C.faint("(read-only)")}
51
- ${C.bold("huddle story")} ${C.faint("[--html f]")} replay the last run: messages, reviews, merges ${C.faint("(--list, --run n)")}
52
- ${C.bold("huddle --version")} print the version
45
+ ${B("huddle")} open Huddle
46
+ ${B('huddle join "<goal>"')} plan the goal and run your agents
47
+ ${B("--plan")} dry run: show who'd do what
48
+ ${B("--agents=claude,codex")} only these agents work on this run
49
+ ${B("--approvals=safe|auto|yolo")} what agents may do unasked ${F("(default auto)")}
50
+ ${B("--council")} ${F("/")} ${B("--no-council")} design council first ${F("(default: big goals)")}
51
+ ${B("--qa")} ${F("/")} ${B("--no-qa")} agents re-check the build ${F("(default: big goals)")}
52
+ ${B("--with <file>")} put a file, folder or glob on the shared screen
53
+ ${F("(repeatable; or pipe: cat log | huddle join …)")}
54
+ ${B("huddle join <address> <CODE>")} join someone's room from an invite
53
55
 
54
- ${C.faint("run claude, codex and cursor yourself — in one repo, safely:")}
55
- ${C.bold("huddle connect")} wire your agents into this repo's live room ${C.faint("(asks first)")}
56
- ${C.bold("huddle peers")} who's live, what they're doing, which files they hold
57
- ${C.bold("huddle attach <agent>")} open an agent in its own worktree branch
58
- ${C.bold("huddle land")} ${C.faint("[branch]")} test + merge that session's work
59
- ${C.bold("huddle up")} ${C.faint("/")} ${C.bold("down")} ${C.faint("/")} ${C.bold("disconnect")} start · stop the live room · unwire
56
+ ${F("watch and replay")}
57
+ ${B("huddle view")} ${F("[repo]")} watch a run live, read-only, from a 2nd terminal
58
+ ${B("huddle story")} ${F("[--html f]")} replay the last run: ideas, contract, merges
59
+ ${F("(--list, --run n)")}
60
+ ${B("huddle board")} print the board
61
+ ${B("huddle lessons")} ${F("[--forget id]")} what earlier runs taught your agents ${F("(--clear)")}
62
+
63
+ ${F("your agents")}
64
+ ${B("huddle setup")} install + sign in to your agents ${F("(--yes)")}
65
+ ${B("huddle doctor")} check this machine: node, git, your agents
66
+ ${B("huddle agents")} your agents: ready or not, used or skipped
67
+ ${B("huddle agents use|skip <ids>")} choose who works with you ${F("(e.g. skip cursor)")}
68
+ ${B("huddle agents --all")} every connector Huddle knows, installed or not
69
+ ${B("huddle agents install <id>")} install one ${F("(asks first)")}
70
+ ${B("huddle relay")} ${F("[--port 8080]")} run a relay so rooms work across networks
71
+ ${B("huddle --version")} print the version
72
+
73
+ ${F("run claude, codex and cursor yourself — in one repo, safely")}
74
+ ${B("huddle connect")} wire your agents into this repo's live room
75
+ ${B("huddle peers")} who's live, what they're on, their files
76
+ ${B("huddle add <file|->")} put a file, folder, glob or stdin on the screen
77
+ ${F("(cat err.log | huddle add -)")}
78
+ ${B("huddle ctx")} ${F("[show|rm|note|clear]")} what's on the screen every session reads
79
+ ${B("huddle attach <agent>")} open an agent in its own worktree branch
80
+ ${B("huddle land")} ${F("[branch]")} test + merge that session's work
81
+ ${B("huddle up")} ${F("/")} ${B("down")} ${F("/")} ${B("disconnect")} start · stop the live room · unwire
60
82
  `;
61
83
  async function main() {
62
84
  const [cmd, ...rest] = process.argv.slice(2);
85
+ // Legacy Windows consoles get ASCII glyphs. Not for the machine-read channels: the Plug's JSON-RPC and Claude's hooks.
86
+ if (!["plug", "guard", "mail"].includes(cmd ?? ""))
87
+ useAsciiOutput();
63
88
  switch (cmd) {
64
89
  case undefined:
65
90
  if (!process.stdin.isTTY)
66
91
  return void console.log(USAGE);
67
- return runApp();
92
+ return (await app()).runApp();
68
93
  case "plug":
69
- return runPlug();
70
- case "init":
94
+ return (await import("./mcp/server.js")).runPlug();
95
+ case "init": {
96
+ const { ensureInit, huddleDir } = await app();
71
97
  if (!ensureInit()) {
72
98
  console.log(C.red("✗ Not a git repo.") + " Huddle sandboxes are git worktrees — run `git init` first.");
73
99
  process.exit(1);
74
100
  }
75
101
  return void console.log(`${C.green("✓")} Huddle initialized in ${C.faint(huddleDir())}`);
76
- case "doctor":
77
- case "agents":
102
+ }
103
+ case "setup":
104
+ process.exitCode = await (await import("./ui/setup.js")).runSetup(rest);
105
+ return;
106
+ case "agents": {
107
+ const { chooseAgents, installConnector, printAgents, printCatalog } = await import("./ui/catalog.js");
78
108
  if (rest[0] === "install" && rest[1])
79
109
  return void (await installConnector(rest[1]));
110
+ if ((rest[0] === "use" || rest[0] === "skip") && rest.length > 1)
111
+ return void (process.exitCode = chooseAgents(rest[0], rest.slice(1)) ? 0 : 1);
80
112
  if (rest.includes("--all"))
81
113
  return printCatalog();
82
- process.exitCode = await runDoctor();
114
+ if (!rest.length)
115
+ return printAgents();
116
+ process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
117
+ return;
118
+ }
119
+ case "doctor":
120
+ process.exitCode = await (await import("./ui/doctor.js")).runDoctor();
83
121
  return;
84
122
  case "join": {
85
123
  // `huddle join <address> <CODE>` joins someone's room; anything else is a goal.
124
+ const { INVITE_CODE, joinRoom } = await import("./ui/remote.js");
86
125
  if (rest.length === 2 && INVITE_CODE.test(rest[1]))
87
126
  return joinRoom(rest[0], rest[1]);
88
- const goal = rest.filter((a) => !a.startsWith("--")).join(" ");
127
+ const { connectAgents, dbPath, ensureInit, runGoal } = await app();
128
+ const { addForRun, readPipedStdin, takeWith } = await import("./ui/paste.js");
129
+ const withs = takeWith(rest);
130
+ const goal = withs.rest.filter((a) => !a.startsWith("--")).join(" ");
89
131
  if (!goal) {
90
132
  console.log(C.red("✗ Give a goal:") + ' huddle join "build the login page"');
91
133
  process.exit(1);
92
134
  }
135
+ // The shared screen first (--with files, piped stdin), so the planner sees its index.
136
+ const piped = await readPipedStdin();
137
+ if (withs.files.length || piped.trim()) {
138
+ if (!ensureInit()) {
139
+ console.log(C.red("✗ Not a git repo.") + " The shared screen lives in a repo's .huddle/ — run `git init` first.");
140
+ process.exit(1);
141
+ }
142
+ if (await addForRun(dbPath(), withs.files, piped, (l = "") => console.log(l))) {
143
+ console.log(C.red("✗ couldn't put everything on the shared screen (above) — nothing was run."));
144
+ process.exit(1);
145
+ }
146
+ }
93
147
  const flag = rest.find((a) => a.startsWith("--approvals="))?.split("=")[1];
94
148
  const approvals = (["safe", "auto", "yolo"].includes(flag ?? "") ? flag : "auto");
95
- const probed = await connectAgents();
149
+ let probed = await connectAgents();
96
150
  let results = probed;
97
- // --agents=claude,codex: run with just these (the rest sit this one out).
151
+ // --agents=claude,codex: run with just these (the rest sit this one out) — even ones you skipped.
98
152
  const pick = rest.find((a) => a.startsWith("--agents="))?.split("=")[1];
99
153
  if (pick !== undefined) {
100
154
  const ids = pick.split(",").map((s) => s.trim()).filter(Boolean);
155
+ results = probed = (await import("./adapters/index.js")).unskip(probed, ids);
101
156
  const unknown = ids.filter((id) => !results.some((r) => r.adapter.id === id));
102
157
  if (!ids.length || unknown.length) {
103
158
  console.log(C.red(`✗ --agents: no agent called ${unknown.join(", ") || "(empty)"}.`) + ` Known: ${results.map((r) => r.adapter.id).join(", ")}`);
@@ -115,18 +170,25 @@ async function main() {
115
170
  return runGoal(goal, { results, probed, mode: rest.includes("--plan") ? "plan" : "auto", approvals, council, qa });
116
171
  }
117
172
  case "board":
118
- return printBoard();
173
+ return (await app()).printBoard();
119
174
  case "view":
120
175
  case "watch":
121
176
  return (await import("./ui/watch.js")).runView(rest);
122
177
  case "story":
123
178
  return (await import("./ui/story.js")).runStory(rest);
179
+ case "lessons":
180
+ return (await import("./ui/lessons.js")).runLessons(rest);
124
181
  case "up":
125
182
  return (await import("./attach/session.js")).runUp(rest);
126
183
  case "down":
127
184
  return (await import("./attach/session.js")).runDown();
128
185
  case "peers":
129
186
  return (await import("./attach/session.js")).runPeers();
187
+ case "add":
188
+ return (await import("./attach/screen.js")).runAdd(rest);
189
+ case "ctx":
190
+ case "context":
191
+ return (await import("./attach/screen.js")).runCtx(rest);
130
192
  case "attach":
131
193
  return (await import("./attach/session.js")).runAttach(rest);
132
194
  case "land":
@@ -137,6 +199,11 @@ async function main() {
137
199
  return (await import("./attach/connect.js")).runDisconnect();
138
200
  case "guard":
139
201
  return (await import("./attach/guard.js")).runGuard();
202
+ case "mail":
203
+ if (rest.includes("--hook"))
204
+ return (await import("./room/mail.js")).runMailHook(rest);
205
+ console.log(C.red("✗ huddle mail is internal:") + " agents read mail through the Plug (huddle_inbox).");
206
+ process.exit(1);
140
207
  case "relay": {
141
208
  // Run a relay so rooms work across networks. Host it anywhere Node runs.
142
209
  const i = rest.indexOf("--port");
@@ -145,6 +212,7 @@ async function main() {
145
212
  console.log(C.red("✗ --port needs a number from 0 to 65535,") + " e.g. huddle relay --port 8080");
146
213
  process.exit(1);
147
214
  }
215
+ const { startRelay } = await import("./relay/server.js");
148
216
  const r = await startRelay(port, (l) => console.log(C.faint(new Date().toISOString().slice(11, 19) + " " + l)));
149
217
  console.log(`${C.violet("huddle relay")} listening on :${r.port} ${C.faint("· people use it with: relay ws://<this-host>:" + r.port)}`);
150
218
  return;
@@ -0,0 +1,79 @@
1
+ // The Plug's shared-screen verbs (src/room/context.ts is the Room side).
2
+ // Kept out of server.ts so the screen is one file to read.
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ export function contextTools(room) {
7
+ return [
8
+ {
9
+ name: "huddle_context",
10
+ description: "The SHARED SCREEN: everything the user pasted, dropped or uploaded for the whole team (logs, docs, code, screenshots, PDFs) — one copy in the Room, not in your prompt. Lists each item's id, kind, title, size, summary, sections and the notes pinned on it.",
11
+ inputSchema: { type: "object", properties: {} },
12
+ handler: () => room("/context", "GET"),
13
+ },
14
+ {
15
+ name: "huddle_context_read",
16
+ description: "Read one item from the shared screen. Text comes back in chunks of up to 400 lines: pass from_line/to_line, or section (a number or title from huddle_context). Images and PDFs come back as a file path — open it with your image/file reading tool.",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {
20
+ id: { type: "string", description: "the item id, e.g. ctx-3" },
21
+ from_line: { type: "number" },
22
+ to_line: { type: "number" },
23
+ section: { type: "string", description: "optional: a section number or title" },
24
+ },
25
+ required: ["id"],
26
+ },
27
+ handler: (a) => room("/context_read", "POST", { id: a.id, from_line: a.from_line, to_line: a.to_line, section: a.section }),
28
+ },
29
+ {
30
+ name: "huddle_context_search",
31
+ description: "Search every text item on the shared screen. Returns matching lines with the item id and line number, so you can huddle_context_read around them.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: { query: { type: "string" }, regex: { type: "boolean", description: "treat query as a regular expression" } },
35
+ required: ["query"],
36
+ },
37
+ handler: (a) => room("/context_search", "POST", { query: a.query, regex: !!a.regex }),
38
+ },
39
+ {
40
+ name: "huddle_context_note",
41
+ description: "Pin a short comment on a shared-screen item — what it means, what you found in it, which part you're handling. Every agent and the user sees it next to the item. This is how the team talks about the same document.",
42
+ inputSchema: {
43
+ type: "object",
44
+ properties: { id: { type: "string" }, note: { type: "string" } },
45
+ required: ["id", "note"],
46
+ },
47
+ handler: (a) => room("/context_note", "POST", { id: a.id, note: a.note }),
48
+ },
49
+ ];
50
+ }
51
+ /**
52
+ * A Room result as MCP content. Screenshots also go as an image block (hosts
53
+ * that render MCP images show it to the model directly). Bytes sent across
54
+ * machines (a teammate's agent) are saved here and replaced by a local path.
55
+ */
56
+ export function toContent(result) {
57
+ if (!result || typeof result !== "object" || Array.isArray(result))
58
+ return [{ type: "text", text: JSON.stringify(result, null, 2) }];
59
+ const { image, file, ...rest } = result;
60
+ if (file?.data_base64 && file?.name) {
61
+ try {
62
+ const dir = path.join(os.tmpdir(), "huddle-context");
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ const p = path.join(dir, String(file.name).replace(/[^\w.-]/g, "_"));
65
+ fs.writeFileSync(p, Buffer.from(String(file.data_base64), "base64"));
66
+ rest.path = p;
67
+ rest.note = `Open this file with your image/file reading tool (e.g. Read ${p}).`;
68
+ }
69
+ catch (e) {
70
+ rest.note = `couldn't save the file here: ${e?.message ?? e}`;
71
+ }
72
+ }
73
+ const out = [{ type: "text", text: JSON.stringify(rest, null, 2) }];
74
+ // HUDDLE_CTX_MCP_IMAGES=0: path only, for a host that can't take image blocks.
75
+ if (image?.data_base64 && image?.mime && process.env.HUDDLE_CTX_MCP_IMAGES !== "0")
76
+ out.push({ type: "image", data: String(image.data_base64), mimeType: String(image.mime) });
77
+ return out;
78
+ }
79
+ //# sourceMappingURL=context-tools.js.map