huddle-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +357 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +52 -0
- package/dist/adapters/claude.js +70 -0
- package/dist/adapters/codex.js +59 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +85 -0
- package/dist/adapters/custom.js +340 -0
- package/dist/adapters/droid.js +57 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +55 -0
- package/dist/adapters/index.js +135 -0
- package/dist/adapters/kiro.js +41 -0
- package/dist/adapters/opencode.js +63 -0
- package/dist/adapters/qwen.js +67 -0
- package/dist/adapters/types.js +6 -0
- package/dist/adapters/usage.js +141 -0
- package/dist/attach/connect.js +409 -0
- package/dist/attach/guard.js +64 -0
- package/dist/attach/live.js +222 -0
- package/dist/attach/plug.js +174 -0
- package/dist/attach/session.js +172 -0
- package/dist/cli.js +176 -0
- package/dist/mcp/server.js +240 -0
- package/dist/orchestrator/ask.js +51 -0
- package/dist/orchestrator/brain.js +164 -0
- package/dist/orchestrator/council.js +274 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +155 -0
- package/dist/orchestrator/planner.js +194 -0
- package/dist/orchestrator/qa.js +350 -0
- package/dist/orchestrator/scheduler.js +748 -0
- package/dist/orchestrator/takeover.js +85 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/db.js +617 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/live.js +293 -0
- package/dist/room/progress.js +110 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +752 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +85 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +261 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1534 -0
- package/dist/ui/catalog.js +75 -0
- package/dist/ui/dashboard.js +192 -0
- package/dist/ui/doctor.js +54 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/remote.js +332 -0
- package/dist/ui/story.js +932 -0
- package/dist/ui/term.js +114 -0
- package/dist/ui/watch.js +174 -0
- package/install.sh +44 -0
- package/package.json +60 -4
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Attach mode, the daemon: one long-lived Room per repo for the sessions you
|
|
2
|
+
// run yourself. `huddle up` starts it in the background (or finds the one
|
|
3
|
+
// already running), `huddle down` stops it. Everything it needs is under
|
|
4
|
+
// .huddle/ (never committed):
|
|
5
|
+
//
|
|
6
|
+
// .huddle/live.json pid · port · secret (0600) — how Plugs and hooks find it
|
|
7
|
+
// .huddle/live.db its board: leases, notes, shared memory
|
|
8
|
+
// .huddle/logs/live.log
|
|
9
|
+
//
|
|
10
|
+
// The Plug reads live.json on every connect, so a restarted room (new port,
|
|
11
|
+
// new secret) is picked up without rewriting anyone's MCP config.
|
|
12
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { RoomDB } from "../room/db.js";
|
|
17
|
+
import { liveRoom } from "../room/live.js";
|
|
18
|
+
import { startRoom } from "../room/server.js";
|
|
19
|
+
// dist/attach/live.js -> dist/cli.js
|
|
20
|
+
export const CLI_PATH = fileURLToPath(new URL("../cli.js", import.meta.url));
|
|
21
|
+
/**
|
|
22
|
+
* The main checkout's root for wherever we are — also from inside a worktree
|
|
23
|
+
* (git-common-dir is the main repo's .git). null outside git.
|
|
24
|
+
*/
|
|
25
|
+
export function mainRoot(cwd = process.cwd()) {
|
|
26
|
+
try {
|
|
27
|
+
const common = execFileSync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
28
|
+
cwd,
|
|
29
|
+
encoding: "utf8",
|
|
30
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
31
|
+
}).trim();
|
|
32
|
+
return fs.realpathSync(path.dirname(common));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** This checkout's root (a worktree's own root when inside one). */
|
|
39
|
+
export function checkoutRoot(cwd = process.cwd()) {
|
|
40
|
+
try {
|
|
41
|
+
return fs.realpathSync(execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim());
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export const liveFiles = (root) => {
|
|
48
|
+
const dir = path.join(root, ".huddle");
|
|
49
|
+
return {
|
|
50
|
+
dir,
|
|
51
|
+
state: path.join(dir, "live.json"),
|
|
52
|
+
db: path.join(dir, "live.db"),
|
|
53
|
+
lock: path.join(dir, "live.lock"),
|
|
54
|
+
log: path.join(dir, "logs", "live.log"),
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
function alive(pid) {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (e) {
|
|
63
|
+
return e?.code === "EPERM";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** The running room's state, if its process is still alive. */
|
|
67
|
+
export function readLive(root) {
|
|
68
|
+
try {
|
|
69
|
+
const st = JSON.parse(fs.readFileSync(liveFiles(root).state, "utf8"));
|
|
70
|
+
return st.pid && alive(st.pid) ? st : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** Call the live room as `as` (a session id; "" for anonymous verbs). */
|
|
77
|
+
export async function liveCall(st, verb, body = {}, as = "", timeoutMs = 4000) {
|
|
78
|
+
const res = await fetch(`${st.url}/live/${verb}`, {
|
|
79
|
+
method: "POST",
|
|
80
|
+
headers: { "content-type": "application/json", "x-huddle-secret": st.secret, "x-huddle-agent": as },
|
|
81
|
+
body: JSON.stringify(body ?? {}),
|
|
82
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
83
|
+
});
|
|
84
|
+
return { status: res.status, body: await res.json().catch(() => ({})) };
|
|
85
|
+
}
|
|
86
|
+
/** Same as liveCall, for the Room's ordinary agent verbs (/recall, /remember, …). */
|
|
87
|
+
export async function roomCall(st, method, p, body, as) {
|
|
88
|
+
const res = await fetch(`${st.url}${p}`, {
|
|
89
|
+
method,
|
|
90
|
+
headers: { "content-type": "application/json", "x-huddle-secret": st.secret, "x-huddle-agent": as },
|
|
91
|
+
body: method === "POST" ? JSON.stringify({ agent_id: as, ...body }) : undefined,
|
|
92
|
+
signal: AbortSignal.timeout(8000),
|
|
93
|
+
});
|
|
94
|
+
return res.json();
|
|
95
|
+
}
|
|
96
|
+
export async function pingLive(st) {
|
|
97
|
+
if (!st)
|
|
98
|
+
return false;
|
|
99
|
+
try {
|
|
100
|
+
return (await liveCall(st, "ping", {}, "", 1500)).status === 200;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function ensureHuddleDir(root) {
|
|
107
|
+
const f = liveFiles(root);
|
|
108
|
+
fs.mkdirSync(path.dirname(f.log), { recursive: true });
|
|
109
|
+
const gi = path.join(f.dir, ".gitignore");
|
|
110
|
+
if (!fs.existsSync(gi))
|
|
111
|
+
fs.writeFileSync(gi, "*\n"); // keep .huddle/ out of commits
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Find the repo's live room, or start it in the background. Safe to call from
|
|
115
|
+
* several Plugs at once: a lock file makes sure only one daemon starts.
|
|
116
|
+
*/
|
|
117
|
+
export async function ensureLive(root, timeoutMs = 8000) {
|
|
118
|
+
const st = readLive(root);
|
|
119
|
+
if (st && (await pingLive(st)))
|
|
120
|
+
return { state: st, started: false };
|
|
121
|
+
ensureHuddleDir(root);
|
|
122
|
+
const f = liveFiles(root);
|
|
123
|
+
let mine = false;
|
|
124
|
+
try {
|
|
125
|
+
fs.writeFileSync(f.lock, String(process.pid), { flag: "wx" });
|
|
126
|
+
mine = true;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Someone else is starting it — unless their lock is stale.
|
|
130
|
+
try {
|
|
131
|
+
if (Date.now() - fs.statSync(f.lock).mtimeMs > 10_000) {
|
|
132
|
+
fs.writeFileSync(f.lock, String(process.pid));
|
|
133
|
+
mine = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* lock vanished: they finished */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (mine) {
|
|
141
|
+
const out = fs.openSync(f.log, "a");
|
|
142
|
+
const child = spawn(process.execPath, [CLI_PATH, "up", "--serve"], {
|
|
143
|
+
cwd: root,
|
|
144
|
+
detached: true,
|
|
145
|
+
stdio: ["ignore", out, out],
|
|
146
|
+
env: { ...process.env, HUDDLE_LIVE_ROOT: root },
|
|
147
|
+
});
|
|
148
|
+
child.unref();
|
|
149
|
+
fs.closeSync(out);
|
|
150
|
+
}
|
|
151
|
+
const deadline = Date.now() + timeoutMs;
|
|
152
|
+
try {
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
const s = readLive(root);
|
|
155
|
+
if (s && (await pingLive(s)))
|
|
156
|
+
return { state: s, started: mine };
|
|
157
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
if (mine)
|
|
162
|
+
fs.rmSync(f.lock, { force: true });
|
|
163
|
+
}
|
|
164
|
+
throw new Error(`the live room didn't start — see ${path.relative(root, f.log)}`);
|
|
165
|
+
}
|
|
166
|
+
/** The daemon itself (`huddle up --serve`). Runs until `huddle down` or idle. */
|
|
167
|
+
export async function serveLive(root) {
|
|
168
|
+
ensureHuddleDir(root);
|
|
169
|
+
const f = liveFiles(root);
|
|
170
|
+
const other = readLive(root);
|
|
171
|
+
if (other && other.pid !== process.pid && (await pingLive(other))) {
|
|
172
|
+
console.log(`live room already running (pid ${other.pid})`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const db = new RoomDB(f.db);
|
|
176
|
+
const room = await startRoom(db, Number(process.env.HUDDLE_LIVE_PORT ?? 0), { host: "live" });
|
|
177
|
+
room.live = liveRoom(db, { repoRoot: root, staleMs: Number(process.env.HUDDLE_LIVE_STALE_MS ?? 60_000) });
|
|
178
|
+
const st = { pid: process.pid, port: room.port, url: room.url, secret: room.secret, root, started_at: Date.now() };
|
|
179
|
+
const tmp = `${f.state}.${process.pid}`;
|
|
180
|
+
fs.writeFileSync(tmp, JSON.stringify(st, null, 2), { mode: 0o600 });
|
|
181
|
+
fs.renameSync(tmp, f.state);
|
|
182
|
+
console.log(`${new Date().toISOString()} live room up on ${room.url} (pid ${process.pid})`);
|
|
183
|
+
let lastBusy = Date.now();
|
|
184
|
+
const IDLE_MS = Number(process.env.HUDDLE_LIVE_IDLE_MS ?? 6 * 3600_000);
|
|
185
|
+
const stop = (why) => {
|
|
186
|
+
console.log(`${new Date().toISOString()} live room stopping: ${why}`);
|
|
187
|
+
try {
|
|
188
|
+
const cur = JSON.parse(fs.readFileSync(f.state, "utf8"));
|
|
189
|
+
if (cur.pid === process.pid)
|
|
190
|
+
fs.rmSync(f.state, { force: true });
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
/* already gone */
|
|
194
|
+
}
|
|
195
|
+
room.live?.close();
|
|
196
|
+
room.close();
|
|
197
|
+
db.close();
|
|
198
|
+
process.exit(0);
|
|
199
|
+
};
|
|
200
|
+
process.on("SIGTERM", () => stop("huddle down"));
|
|
201
|
+
process.on("SIGINT", () => stop("interrupted"));
|
|
202
|
+
// No live sessions for IDLE_MS (default 6h) = nobody needs it; leave quietly.
|
|
203
|
+
setInterval(() => {
|
|
204
|
+
if (room.live.sessions().length)
|
|
205
|
+
lastBusy = Date.now();
|
|
206
|
+
else if (Date.now() - lastBusy > IDLE_MS)
|
|
207
|
+
stop("idle");
|
|
208
|
+
}, Math.min(IDLE_MS, 60_000));
|
|
209
|
+
}
|
|
210
|
+
/** `huddle down`. True if a room was running. */
|
|
211
|
+
export async function stopLive(root) {
|
|
212
|
+
const st = readLive(root);
|
|
213
|
+
if (!st) {
|
|
214
|
+
fs.rmSync(liveFiles(root).state, { force: true });
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
process.kill(st.pid, "SIGTERM");
|
|
218
|
+
for (let i = 0; i < 50 && alive(st.pid); i++)
|
|
219
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=live.js.map
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Attach mode, the agent side: the Plug as seen by a session YOU started
|
|
2
|
+
// (claude / codex / cursor in your own terminal). `huddle connect` puts this in
|
|
3
|
+
// each agent's project MCP config with HUDDLE_LIVE=1. There's no scheduler and
|
|
4
|
+
// no task id: the session says hello to the repo's live room (starting it if
|
|
5
|
+
// needed), and its tools are about sharing the repo safely with the others.
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
8
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
9
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { checkoutRoot, ensureLive, liveCall, mainRoot, roomCall } from "./live.js";
|
|
11
|
+
/** What every attached agent is told, via the MCP `instructions` field. */
|
|
12
|
+
export const LIVE_INSTRUCTIONS = `
|
|
13
|
+
You are one of several AI coding sessions (e.g. claude, codex, cursor) working in this same repo at the same time, coordinated by Huddle.
|
|
14
|
+
Before you edit ANY file:
|
|
15
|
+
1. huddle_peers — see who else is live, what they're doing, which files they hold.
|
|
16
|
+
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
|
+
3. When you finish a change, huddle_release so the others can proceed.
|
|
18
|
+
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.
|
|
19
|
+
`.trim();
|
|
20
|
+
const tools = [
|
|
21
|
+
{
|
|
22
|
+
name: "huddle_peers",
|
|
23
|
+
description: "See every OTHER live AI session in this repo right now (claude, codex, cursor…): what each is doing, which files it holds, recent notes. Call this before you start editing.",
|
|
24
|
+
inputSchema: { type: "object", properties: {} },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "huddle_announce_plan",
|
|
28
|
+
description: "BEFORE editing: say what you're about to do and every file you'll touch ('path', 'dir/' or 'path::symbol'). Leases the free ones to you; returns conflicts for any another live session holds — don't edit those.",
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
intent: { type: "string", description: "one line: what you're changing and why" },
|
|
33
|
+
touches: { type: "array", items: { type: "string" } },
|
|
34
|
+
},
|
|
35
|
+
required: ["intent", "touches"],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "huddle_check_overlap",
|
|
40
|
+
description: "Is anyone else holding these files? ('path', 'dir/', 'path::symbol'). Read-only — takes no lease.",
|
|
41
|
+
inputSchema: { type: "object", properties: { touches: { type: "array", items: { type: "string" } } }, required: ["touches"] },
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: "huddle_release",
|
|
45
|
+
description: "You're done with these files (omit `touches` = all of yours). Others waiting on them can go ahead.",
|
|
46
|
+
inputSchema: { type: "object", properties: { touches: { type: "array", items: { type: "string" } } } },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "huddle_note",
|
|
50
|
+
description: "Leave a note for the other sessions — everyone, or one by id/agent ('codex', 'claude#2'). E.g. 'I renamed User.email to User.mail, update your callers'. They see it on their next huddle_* call.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: "object",
|
|
53
|
+
properties: { body: { type: "string" }, to: { type: "string", description: "optional: a session id or agent name; default everyone" } },
|
|
54
|
+
required: ["body"],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "huddle_remember",
|
|
59
|
+
description: "Write to the repo's SHARED MEMORY (kind = decision | fact | gotcha) so every session, now and later, inherits it. Include 'why'.",
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
properties: { kind: { type: "string", enum: ["decision", "fact", "gotcha"] }, body: { type: "string" }, why: { type: "string" } },
|
|
63
|
+
required: ["kind", "body"],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "huddle_recall",
|
|
68
|
+
description: "Read shared memory: decisions, facts and gotchas other sessions recorded. Optional text filter.",
|
|
69
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
function branchOf(dir) {
|
|
73
|
+
try {
|
|
74
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return "";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export async function runLivePlug() {
|
|
81
|
+
const agent = process.env.HUDDLE_AGENT_ID ?? "agent";
|
|
82
|
+
// Claude sets CLAUDE_PROJECT_DIR for its MCP servers; others start us in the project.
|
|
83
|
+
const here = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
84
|
+
const sessionRoot = checkoutRoot(here) ?? here;
|
|
85
|
+
const repo = process.env.HUDDLE_REPO || mainRoot(here) || sessionRoot;
|
|
86
|
+
let st = null;
|
|
87
|
+
let sid = "";
|
|
88
|
+
let hello = null;
|
|
89
|
+
const sayHello = () => (hello ??= (async () => {
|
|
90
|
+
st = (await ensureLive(repo)).state;
|
|
91
|
+
const r = await liveCall(st, "hello", { agent, pid: process.ppid, root: sessionRoot, branch: branchOf(sessionRoot) });
|
|
92
|
+
if (r.status !== 200)
|
|
93
|
+
throw new Error(r.body?.error ?? "hello failed");
|
|
94
|
+
sid = r.body.id;
|
|
95
|
+
})().finally(() => (hello = null)));
|
|
96
|
+
/** One live verb, re-saying hello if the room restarted or forgot us. */
|
|
97
|
+
const live = async (verb, body) => {
|
|
98
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
99
|
+
try {
|
|
100
|
+
if (!sid || !st)
|
|
101
|
+
await sayHello();
|
|
102
|
+
const r = await liveCall(st, verb, body, sid);
|
|
103
|
+
if (r.status === 409 && r.body?.rehello) {
|
|
104
|
+
sid = "";
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
return r.body;
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
st = null;
|
|
111
|
+
sid = "";
|
|
112
|
+
if (attempt)
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const plain = async (method, p, body) => {
|
|
118
|
+
if (!sid || !st)
|
|
119
|
+
await sayHello();
|
|
120
|
+
return roomCall(st, method, p, body, sid);
|
|
121
|
+
};
|
|
122
|
+
const call = (name, a) => {
|
|
123
|
+
switch (name) {
|
|
124
|
+
case "huddle_peers":
|
|
125
|
+
return live("peers", {});
|
|
126
|
+
case "huddle_announce_plan":
|
|
127
|
+
return live("announce", { intent: a.intent, touches: a.touches ?? [] });
|
|
128
|
+
case "huddle_check_overlap":
|
|
129
|
+
return live("check", { touches: a.touches ?? [] });
|
|
130
|
+
case "huddle_release":
|
|
131
|
+
return live("release", { touches: a.touches });
|
|
132
|
+
case "huddle_note":
|
|
133
|
+
// Models sometimes name it message/text (seen live with claude -p): accept those.
|
|
134
|
+
return live("note", { body: a.body ?? a.message ?? a.text ?? a.note, to: a.to });
|
|
135
|
+
case "huddle_remember":
|
|
136
|
+
return plain("POST", "/remember", { kind: a.kind, body: a.body, why: a.why ?? "" });
|
|
137
|
+
case "huddle_recall":
|
|
138
|
+
return plain("GET", `/recall${a.query ? `?q=${encodeURIComponent(a.query)}` : ""}`, {});
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`unknown tool: ${name}`);
|
|
141
|
+
};
|
|
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 })) }));
|
|
144
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
145
|
+
try {
|
|
146
|
+
const result = await call(req.params.name, req.params.arguments ?? {});
|
|
147
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
// Never wedge the agent: say what's wrong and let it carry on.
|
|
151
|
+
return { isError: true, content: [{ type: "text", text: `Huddle live room unavailable (${e?.message ?? e}). Tell your user to run \`huddle up\`.` }] };
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
// Be visible to the other sessions from the moment this one opens.
|
|
155
|
+
sayHello().catch(() => { });
|
|
156
|
+
const beat = setInterval(() => void live("heartbeat", {}).catch(() => { }), Number(process.env.HUDDLE_LIVE_BEAT_MS ?? 15_000));
|
|
157
|
+
beat.unref();
|
|
158
|
+
let closing = false;
|
|
159
|
+
const goodbye = async () => {
|
|
160
|
+
if (closing)
|
|
161
|
+
return;
|
|
162
|
+
closing = true;
|
|
163
|
+
clearInterval(beat);
|
|
164
|
+
if (st && sid)
|
|
165
|
+
await liveCall(st, "bye", {}, sid, 1500).catch(() => { });
|
|
166
|
+
process.exit(0);
|
|
167
|
+
};
|
|
168
|
+
process.stdin.on("end", goodbye);
|
|
169
|
+
process.stdin.on("close", goodbye);
|
|
170
|
+
process.on("SIGTERM", goodbye);
|
|
171
|
+
process.on("SIGINT", goodbye);
|
|
172
|
+
await server.connect(new StdioServerTransport());
|
|
173
|
+
}
|
|
174
|
+
//# sourceMappingURL=plug.js.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Attach mode, isolation: `huddle attach <agent>` opens the agent's normal
|
|
2
|
+
// interactive CLI in its OWN worktree branch (huddle/<agent>-live-N), already
|
|
3
|
+
// connected to the live room. Two sessions can then never overwrite each
|
|
4
|
+
// other's files mid-edit; `huddle land` brings a session's branch home through
|
|
5
|
+
// the merge engine (commit → tests → rebase → fast-forward), after you say so.
|
|
6
|
+
//
|
|
7
|
+
// Plus `huddle up` / `huddle down` / `huddle peers`, the human's view of the room.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { getAdapter } from "../adapters/index.js";
|
|
12
|
+
import { createWorktree, landMerge, prepareMerge, removeWorktree } from "../sandbox/worktree.js";
|
|
13
|
+
import { C, dot } from "../ui/term.js";
|
|
14
|
+
import { detectTestCmd } from "../ui/app.js";
|
|
15
|
+
import { applyConnect, confirm, disconnect, livePlug, planConnect } from "./connect.js";
|
|
16
|
+
import { ensureLive, liveCall, liveFiles, mainRoot, readLive, serveLive, stopLive, pingLive, checkoutRoot } from "./live.js";
|
|
17
|
+
const attachFile = (repo) => path.join(liveFiles(repo).dir, "attach.json");
|
|
18
|
+
export function readAttached(repo) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(attachFile(repo), "utf8"));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeAttached(repo, list) {
|
|
27
|
+
fs.mkdirSync(path.dirname(attachFile(repo)), { recursive: true });
|
|
28
|
+
fs.writeFileSync(attachFile(repo), JSON.stringify(list, null, 2));
|
|
29
|
+
}
|
|
30
|
+
function needRepo() {
|
|
31
|
+
const root = mainRoot();
|
|
32
|
+
if (!root) {
|
|
33
|
+
console.log(C.red("✗ Not a git repo.") + " Run this inside the repo your agents work in.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Make a session worktree for `agent`, wired to the live room. Returns the
|
|
40
|
+
* worktree and the interactive command to run in it.
|
|
41
|
+
*/
|
|
42
|
+
export function prepareAttach(repo, agentId) {
|
|
43
|
+
const adapter = getAdapter(agentId);
|
|
44
|
+
if (!adapter)
|
|
45
|
+
throw new Error(`unknown agent "${agentId}"`);
|
|
46
|
+
const taken = new Set(readAttached(repo).map((e) => e.branch));
|
|
47
|
+
let n = 1;
|
|
48
|
+
while (taken.has(`huddle/${agentId}-live-${n}`) || fs.existsSync(path.join(repo, ".huddle", "worktrees", `${agentId}-live-${n}`)))
|
|
49
|
+
n++;
|
|
50
|
+
const wt = createWorktree(repo, agentId, `live-${n}`);
|
|
51
|
+
// The same project files `huddle connect` writes, inside the session's own worktree.
|
|
52
|
+
const plan = planConnect(wt.path, { agents: [agentId], repo, adapters: [adapter] });
|
|
53
|
+
applyConnect(wt.path, plan, repo);
|
|
54
|
+
const excludes = plan.filter((w) => w.action !== "skip").map((w) => w.file);
|
|
55
|
+
const cmd = [adapter.bin];
|
|
56
|
+
if (adapter.id === "codex") {
|
|
57
|
+
// A new worktree isn't a trusted project yet, and Codex ignores .codex/ in
|
|
58
|
+
// untrusted ones — so hand the Plug over with -c too (verified: `codex --help`).
|
|
59
|
+
const plug = livePlug(repo, "codex");
|
|
60
|
+
const env = `{${Object.entries(plug.env).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(",")}}`;
|
|
61
|
+
cmd.push("-c", `mcp_servers.huddle.command=${JSON.stringify(plug.command)}`, "-c", `mcp_servers.huddle.args=${JSON.stringify(plug.args)}`, "-c", `mcp_servers.huddle.env=${env}`);
|
|
62
|
+
}
|
|
63
|
+
writeAttached(repo, [...readAttached(repo), { agent: agentId, branch: wt.branch, path: wt.path, excludes, started_at: Date.now() }]);
|
|
64
|
+
return { wt, cmd, excludes };
|
|
65
|
+
}
|
|
66
|
+
export async function runAttach(args) {
|
|
67
|
+
const repo = needRepo();
|
|
68
|
+
const agentId = args.find((a) => !a.startsWith("-"));
|
|
69
|
+
if (!agentId) {
|
|
70
|
+
console.log(C.red("✗ Which agent?") + " huddle attach claude (or codex, cursor, …)");
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
const passthrough = args.includes("--") ? args.slice(args.indexOf("--") + 1) : [];
|
|
74
|
+
const dirty = spawnSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repo, encoding: "utf8" }).stdout.trim();
|
|
75
|
+
if (dirty)
|
|
76
|
+
console.log(C.yellow("! You have uncommitted changes in the main checkout — the session starts from the last commit, without them."));
|
|
77
|
+
const { state } = await ensureLive(repo);
|
|
78
|
+
const { wt, cmd } = prepareAttach(repo, agentId);
|
|
79
|
+
console.log(`${dot(agentId)} ${C.bold(agentId)} in its own worktree ${C.faint(path.relative(repo, wt.path))} on ${C.bold(wt.branch)}`);
|
|
80
|
+
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" });
|
|
82
|
+
if (r.error)
|
|
83
|
+
console.log(C.red(`✗ couldn't start ${cmd[0]}: ${r.error.message}`));
|
|
84
|
+
const changed = spawnSync("git", ["status", "--porcelain"], { cwd: wt.path, encoding: "utf8" }).stdout.trim();
|
|
85
|
+
const ahead = spawnSync("git", ["rev-list", "--count", `HEAD..${wt.branch}`], { cwd: repo, encoding: "utf8" }).stdout.trim();
|
|
86
|
+
console.log(`\n${agentId} session ended. ${changed || (ahead && ahead !== "0") ? `Its work is on ${C.bold(wt.branch)} — ${C.bold(`huddle land ${wt.branch}`)} tests it and merges it.` : "No changes."}`);
|
|
87
|
+
}
|
|
88
|
+
/** `huddle land [branch|agent]`: the merge engine for one attach session. */
|
|
89
|
+
export async function landSession(repo, e, opts = {}) {
|
|
90
|
+
const wt = { branch: e.branch, path: e.path };
|
|
91
|
+
const testCmd = opts.testCmd !== undefined ? opts.testCmd : detectTestCmd(e.path);
|
|
92
|
+
const prep = prepareMerge(repo, wt, testCmd, e.excludes, `huddle: ${e.agent} session (${e.branch})`);
|
|
93
|
+
// Attach mode has no scheduler to hand a conflict to a resolver agent: the
|
|
94
|
+
// session's own agent (or you) merges main in, then lands again.
|
|
95
|
+
if (prep.conflict)
|
|
96
|
+
return { merged: false, reason: `main changed the same lines — in ${e.path} run \`git merge main\`, resolve, then \`huddle land\` again` };
|
|
97
|
+
if (!prep.ok)
|
|
98
|
+
return { merged: false, reason: prep.reason };
|
|
99
|
+
if (!prep.diffstat.trim())
|
|
100
|
+
return { merged: false, reason: "nothing to land — the branch matches main" };
|
|
101
|
+
if (!opts.yes) {
|
|
102
|
+
console.log(prep.diffstat + "\n" + C.faint(prep.reason));
|
|
103
|
+
if (!(await confirm(`Merge ${e.branch} into your current branch?`)))
|
|
104
|
+
return { merged: false, reason: "not now", diffstat: prep.diffstat };
|
|
105
|
+
}
|
|
106
|
+
const land = landMerge(repo, wt);
|
|
107
|
+
if (land.merged && !opts.keep) {
|
|
108
|
+
disconnect(e.path, repo);
|
|
109
|
+
removeWorktree(repo, e.path);
|
|
110
|
+
spawnSync("git", ["branch", "-d", e.branch], { cwd: repo, stdio: "ignore" });
|
|
111
|
+
writeAttached(repo, readAttached(repo).filter((x) => x.branch !== e.branch));
|
|
112
|
+
}
|
|
113
|
+
return { merged: land.merged, reason: land.reason, diffstat: prep.diffstat };
|
|
114
|
+
}
|
|
115
|
+
export async function runLand(args) {
|
|
116
|
+
const repo = needRepo();
|
|
117
|
+
const list = readAttached(repo);
|
|
118
|
+
const want = args.find((a) => !a.startsWith("-"));
|
|
119
|
+
const here = checkoutRoot();
|
|
120
|
+
const pick = want
|
|
121
|
+
? list.filter((e) => e.branch === want || e.agent === want || path.basename(e.path) === want)
|
|
122
|
+
: list.filter((e) => e.path === here).length
|
|
123
|
+
? list.filter((e) => e.path === here)
|
|
124
|
+
: list;
|
|
125
|
+
if (!pick.length)
|
|
126
|
+
return void console.log(want ? C.red(`✗ no attach session "${want}"`) : "No attach sessions. Start one with huddle attach <agent>.");
|
|
127
|
+
if (pick.length > 1) {
|
|
128
|
+
console.log("Which one?\n" + pick.map((e) => ` huddle land ${e.branch} ${C.faint(e.agent)}`).join("\n"));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const r = await landSession(repo, pick[0], { yes: args.includes("--yes") || args.includes("-y"), keep: args.includes("--keep") });
|
|
132
|
+
console.log(r.merged ? `${C.green("✓")} landed ${pick[0].branch}` : `${C.yellow("·")} not landed: ${r.reason}`);
|
|
133
|
+
if (!r.merged)
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
}
|
|
136
|
+
// ---- up / down / peers ---------------------------------------------------------
|
|
137
|
+
export async function runUp(args) {
|
|
138
|
+
if (args.includes("--serve"))
|
|
139
|
+
return serveLive(process.env.HUDDLE_LIVE_ROOT || needRepo());
|
|
140
|
+
const repo = needRepo();
|
|
141
|
+
const { state, started } = await ensureLive(repo);
|
|
142
|
+
console.log(`${C.green("✓")} live room ${started ? "started" : "already running"} for ${C.bold(path.basename(repo))} ${C.faint(`(${state.url}, pid ${state.pid})`)}`);
|
|
143
|
+
const connected = fs.existsSync(path.join(liveFiles(repo).dir, "connect.json"));
|
|
144
|
+
if (!connected)
|
|
145
|
+
console.log(` next: ${C.bold("huddle connect")} so your claude / codex / cursor sessions join it`);
|
|
146
|
+
console.log(C.faint(" huddle peers — who's in · huddle down — stop it"));
|
|
147
|
+
}
|
|
148
|
+
export async function runDown() {
|
|
149
|
+
const repo = needRepo();
|
|
150
|
+
console.log((await stopLive(repo)) ? `${C.green("✓")} live room stopped` : "No live room running for this repo.");
|
|
151
|
+
}
|
|
152
|
+
export async function runPeers() {
|
|
153
|
+
const repo = needRepo();
|
|
154
|
+
const st = readLive(repo);
|
|
155
|
+
if (!st || !(await pingLive(st)))
|
|
156
|
+
return void console.log(`No live room. ${C.bold("huddle up")} starts one.`);
|
|
157
|
+
// Anonymous: a person looking in isn't a session.
|
|
158
|
+
const r = await liveCall(st, "peers", {}, "");
|
|
159
|
+
const sessions = (r.body.sessions ?? []).filter((s) => !s.you);
|
|
160
|
+
console.log(`${C.violet("huddle")} live room · ${path.basename(repo)} · ${sessions.length} session${sessions.length === 1 ? "" : "s"}\n`);
|
|
161
|
+
for (const s of sessions) {
|
|
162
|
+
console.log(` ${dot(s.agent)} ${C.bold(s.id.padEnd(12))} ${s.doing}`);
|
|
163
|
+
console.log(C.faint(` ${s.branch || "?"} · ${s.worktree} · holds ${s.holds.length ? s.holds.join(", ") : "nothing"} · seen ${s.seen_s_ago}s ago`));
|
|
164
|
+
}
|
|
165
|
+
const notes = r.body.notes ?? [];
|
|
166
|
+
if (notes.length) {
|
|
167
|
+
console.log(`\n ${C.bold("notes")}`);
|
|
168
|
+
for (const n of notes)
|
|
169
|
+
console.log(` ${C.faint(`${n.ago} ago`.padStart(8))} ${n.from}${n.from_live ? "" : C.faint(" (gone)")} → ${n.to}: ${n.body}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=session.js.map
|