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.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +102 -0
- package/README.md +376 -2
- package/dist/adapters/amp.js +40 -0
- package/dist/adapters/antigravity.js +54 -0
- package/dist/adapters/claude.js +86 -0
- package/dist/adapters/codex.js +64 -0
- package/dist/adapters/copilot.js +69 -0
- package/dist/adapters/cursor.js +92 -0
- package/dist/adapters/custom.js +342 -0
- package/dist/adapters/droid.js +58 -0
- package/dist/adapters/gemini.js +61 -0
- package/dist/adapters/goose.js +57 -0
- package/dist/adapters/index.js +161 -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 +412 -0
- package/dist/attach/guard.js +83 -0
- package/dist/attach/live.js +226 -0
- package/dist/attach/plug.js +185 -0
- package/dist/attach/screen.js +150 -0
- package/dist/attach/session.js +177 -0
- package/dist/cli.js +244 -0
- package/dist/mcp/context-tools.js +79 -0
- package/dist/mcp/server.js +267 -0
- package/dist/orchestrator/ask.js +54 -0
- package/dist/orchestrator/brain.js +174 -0
- package/dist/orchestrator/council.js +615 -0
- package/dist/orchestrator/goal.js +196 -0
- package/dist/orchestrator/handoff.js +117 -0
- package/dist/orchestrator/launcher.js +171 -0
- package/dist/orchestrator/learn.js +105 -0
- package/dist/orchestrator/planner.js +212 -0
- package/dist/orchestrator/qa.js +398 -0
- package/dist/orchestrator/radar.js +719 -0
- package/dist/orchestrator/scheduler.js +1207 -0
- package/dist/orchestrator/takeover.js +240 -0
- package/dist/platform.js +273 -0
- package/dist/preflight.js +31 -0
- package/dist/relay/server.js +113 -0
- package/dist/room/context.js +1285 -0
- package/dist/room/contract.js +132 -0
- package/dist/room/db.js +680 -0
- package/dist/room/followup.js +233 -0
- package/dist/room/identity.js +122 -0
- package/dist/room/lessons.js +275 -0
- package/dist/room/live.js +348 -0
- package/dist/room/mail.js +212 -0
- package/dist/room/progress.js +149 -0
- package/dist/room/relaylink.js +85 -0
- package/dist/room/server.js +882 -0
- package/dist/room/sqlite.js +156 -0
- package/dist/room/transport.js +107 -0
- package/dist/room/tunnel.js +86 -0
- package/dist/sandbox/sync.js +104 -0
- package/dist/sandbox/worktree.js +392 -0
- package/dist/types.js +4 -0
- package/dist/ui/app.js +1955 -0
- package/dist/ui/catalog.js +113 -0
- package/dist/ui/dashboard.js +335 -0
- package/dist/ui/doctor.js +58 -0
- package/dist/ui/lessons.js +81 -0
- package/dist/ui/logo.js +40 -0
- package/dist/ui/paste.js +515 -0
- package/dist/ui/remote.js +338 -0
- package/dist/ui/setup.js +152 -0
- package/dist/ui/story.js +1529 -0
- package/dist/ui/term.js +249 -0
- package/dist/ui/watch.js +271 -0
- package/install.sh +44 -0
- package/package.json +63 -4
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
export 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
|
+
windowsHide: true, // detached on Windows = a new console window, unless hidden
|
|
146
|
+
stdio: ["ignore", out, out],
|
|
147
|
+
env: { ...process.env, HUDDLE_LIVE_ROOT: root },
|
|
148
|
+
});
|
|
149
|
+
child.unref();
|
|
150
|
+
fs.closeSync(out);
|
|
151
|
+
}
|
|
152
|
+
const deadline = Date.now() + timeoutMs;
|
|
153
|
+
try {
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
const s = readLive(root);
|
|
156
|
+
if (s && (await pingLive(s)))
|
|
157
|
+
return { state: s, started: mine };
|
|
158
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (mine)
|
|
163
|
+
fs.rmSync(f.lock, { force: true });
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`the live room didn't start — see ${path.relative(root, f.log)}`);
|
|
166
|
+
}
|
|
167
|
+
/** The daemon itself (`huddle up --serve`). Runs until `huddle down` or idle. */
|
|
168
|
+
export async function serveLive(root) {
|
|
169
|
+
ensureHuddleDir(root);
|
|
170
|
+
const f = liveFiles(root);
|
|
171
|
+
const other = readLive(root);
|
|
172
|
+
if (other && other.pid !== process.pid && (await pingLive(other))) {
|
|
173
|
+
console.log(`live room already running (pid ${other.pid})`);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const db = new RoomDB(f.db);
|
|
177
|
+
const room = await startRoom(db, Number(process.env.HUDDLE_LIVE_PORT ?? 0), { host: "live" });
|
|
178
|
+
room.live = liveRoom(db, { repoRoot: root, staleMs: Number(process.env.HUDDLE_LIVE_STALE_MS ?? 60_000) });
|
|
179
|
+
const st = { pid: process.pid, port: room.port, url: room.url, secret: room.secret, root, started_at: Date.now() };
|
|
180
|
+
const tmp = `${f.state}.${process.pid}`;
|
|
181
|
+
fs.writeFileSync(tmp, JSON.stringify(st, null, 2), { mode: 0o600 });
|
|
182
|
+
fs.renameSync(tmp, f.state);
|
|
183
|
+
console.log(`${new Date().toISOString()} live room up on ${room.url} (pid ${process.pid})`);
|
|
184
|
+
let lastBusy = Date.now();
|
|
185
|
+
const IDLE_MS = Number(process.env.HUDDLE_LIVE_IDLE_MS ?? 6 * 3600_000);
|
|
186
|
+
const stop = (why) => {
|
|
187
|
+
console.log(`${new Date().toISOString()} live room stopping: ${why}`);
|
|
188
|
+
try {
|
|
189
|
+
const cur = JSON.parse(fs.readFileSync(f.state, "utf8"));
|
|
190
|
+
if (cur.pid === process.pid)
|
|
191
|
+
fs.rmSync(f.state, { force: true });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* already gone */
|
|
195
|
+
}
|
|
196
|
+
room.live?.close();
|
|
197
|
+
room.close();
|
|
198
|
+
db.close();
|
|
199
|
+
process.exit(0);
|
|
200
|
+
};
|
|
201
|
+
process.on("SIGTERM", () => stop("huddle down"));
|
|
202
|
+
process.on("SIGINT", () => stop("interrupted"));
|
|
203
|
+
// No live sessions for IDLE_MS (default 6h) = nobody needs it; leave quietly.
|
|
204
|
+
setInterval(() => {
|
|
205
|
+
if (room.live.sessions().length)
|
|
206
|
+
lastBusy = Date.now();
|
|
207
|
+
else if (Date.now() - lastBusy > IDLE_MS)
|
|
208
|
+
stop("idle");
|
|
209
|
+
}, Math.min(IDLE_MS, 60_000));
|
|
210
|
+
}
|
|
211
|
+
/** `huddle down`. True if a room was running. */
|
|
212
|
+
export async function stopLive(root) {
|
|
213
|
+
const st = readLive(root);
|
|
214
|
+
if (!st) {
|
|
215
|
+
fs.rmSync(liveFiles(root).state, { force: true });
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
process.kill(st.pid, "SIGTERM");
|
|
219
|
+
for (let i = 0; i < 50 && alive(st.pid); i++)
|
|
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 });
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=live.js.map
|
|
@@ -0,0 +1,185 @@
|
|
|
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 { contextTools, toContent } from "../mcp/context-tools.js";
|
|
11
|
+
import { packageInfo } from "../preflight.js";
|
|
12
|
+
import { checkoutRoot, ensureLive, liveCall, mainRoot, roomCall } from "./live.js";
|
|
13
|
+
/** What every attached agent is told, via the MCP `instructions` field. */
|
|
14
|
+
export const LIVE_INSTRUCTIONS = `
|
|
15
|
+
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.
|
|
16
|
+
Before you edit ANY file:
|
|
17
|
+
1. huddle_peers — see who else is live, what they're doing, which files they hold.
|
|
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.
|
|
19
|
+
3. When you finish a change, huddle_release so the others can proceed.
|
|
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.
|
|
22
|
+
`.trim();
|
|
23
|
+
const tools = [
|
|
24
|
+
{
|
|
25
|
+
name: "huddle_peers",
|
|
26
|
+
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.",
|
|
27
|
+
inputSchema: { type: "object", properties: {} },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "huddle_announce_plan",
|
|
31
|
+
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.",
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: "object",
|
|
34
|
+
properties: {
|
|
35
|
+
intent: { type: "string", description: "one line: what you're changing and why" },
|
|
36
|
+
touches: { type: "array", items: { type: "string" } },
|
|
37
|
+
},
|
|
38
|
+
required: ["intent", "touches"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
name: "huddle_check_overlap",
|
|
43
|
+
description: "Is anyone else holding these files? ('path', 'dir/', 'path::symbol'). Read-only — takes no lease.",
|
|
44
|
+
inputSchema: { type: "object", properties: { touches: { type: "array", items: { type: "string" } } }, required: ["touches"] },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: "huddle_release",
|
|
48
|
+
description: "You're done with these files (omit `touches` = all of yours). Others waiting on them can go ahead.",
|
|
49
|
+
inputSchema: { type: "object", properties: { touches: { type: "array", items: { type: "string" } } } },
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "huddle_note",
|
|
53
|
+
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.",
|
|
54
|
+
inputSchema: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: { body: { type: "string" }, to: { type: "string", description: "optional: a session id or agent name; default everyone" } },
|
|
57
|
+
required: ["body"],
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "huddle_remember",
|
|
62
|
+
description: "Write to the repo's SHARED MEMORY (kind = decision | fact | gotcha) so every session, now and later, inherits it. Include 'why'.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: { kind: { type: "string", enum: ["decision", "fact", "gotcha"] }, body: { type: "string" }, why: { type: "string" } },
|
|
66
|
+
required: ["kind", "body"],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "huddle_recall",
|
|
71
|
+
description: "Read shared memory: decisions, facts and gotchas other sessions recorded. Optional text filter.",
|
|
72
|
+
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
function branchOf(dir) {
|
|
76
|
+
try {
|
|
77
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export async function runLivePlug() {
|
|
84
|
+
const agent = process.env.HUDDLE_AGENT_ID ?? "agent";
|
|
85
|
+
// Claude sets CLAUDE_PROJECT_DIR for its MCP servers; others start us in the project.
|
|
86
|
+
const here = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
87
|
+
const sessionRoot = checkoutRoot(here) ?? here;
|
|
88
|
+
const repo = process.env.HUDDLE_REPO || mainRoot(here) || sessionRoot;
|
|
89
|
+
let st = null;
|
|
90
|
+
let sid = "";
|
|
91
|
+
let hello = null;
|
|
92
|
+
const sayHello = () => (hello ??= (async () => {
|
|
93
|
+
st = (await ensureLive(repo)).state;
|
|
94
|
+
const r = await liveCall(st, "hello", { agent, pid: process.ppid, root: sessionRoot, branch: branchOf(sessionRoot) });
|
|
95
|
+
if (r.status !== 200)
|
|
96
|
+
throw new Error(r.body?.error ?? "hello failed");
|
|
97
|
+
sid = r.body.id;
|
|
98
|
+
})().finally(() => (hello = null)));
|
|
99
|
+
/** One live verb, re-saying hello if the room restarted or forgot us. */
|
|
100
|
+
const live = async (verb, body) => {
|
|
101
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
102
|
+
try {
|
|
103
|
+
if (!sid || !st)
|
|
104
|
+
await sayHello();
|
|
105
|
+
const r = await liveCall(st, verb, body, sid);
|
|
106
|
+
if (r.status === 409 && r.body?.rehello) {
|
|
107
|
+
sid = "";
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
return r.body;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
st = null;
|
|
114
|
+
sid = "";
|
|
115
|
+
if (attempt)
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const plain = async (method, p, body) => {
|
|
121
|
+
if (!sid || !st)
|
|
122
|
+
await sayHello();
|
|
123
|
+
return roomCall(st, method, p, body, sid);
|
|
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 ?? {}));
|
|
127
|
+
const call = (name, a) => {
|
|
128
|
+
const ctx = screen.find((t) => t.name === name);
|
|
129
|
+
if (ctx)
|
|
130
|
+
return ctx.handler(a);
|
|
131
|
+
switch (name) {
|
|
132
|
+
case "huddle_peers":
|
|
133
|
+
return live("peers", {});
|
|
134
|
+
case "huddle_announce_plan":
|
|
135
|
+
return live("announce", { intent: a.intent, touches: a.touches ?? [] });
|
|
136
|
+
case "huddle_check_overlap":
|
|
137
|
+
return live("check", { touches: a.touches ?? [] });
|
|
138
|
+
case "huddle_release":
|
|
139
|
+
return live("release", { touches: a.touches });
|
|
140
|
+
case "huddle_note":
|
|
141
|
+
// Models sometimes name it message/text (seen live with claude -p): accept those.
|
|
142
|
+
return live("note", { body: a.body ?? a.message ?? a.text ?? a.note, to: a.to });
|
|
143
|
+
case "huddle_remember":
|
|
144
|
+
return plain("POST", "/remember", { kind: a.kind, body: a.body, why: a.why ?? "" });
|
|
145
|
+
case "huddle_recall":
|
|
146
|
+
return plain("GET", `/recall${a.query ? `?q=${encodeURIComponent(a.query)}` : ""}`, {});
|
|
147
|
+
}
|
|
148
|
+
throw new Error(`unknown tool: ${name}`);
|
|
149
|
+
};
|
|
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
|
+
}));
|
|
154
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
155
|
+
try {
|
|
156
|
+
const result = await call(req.params.name, req.params.arguments ?? {});
|
|
157
|
+
// toContent: JSON text, plus an MCP image block for a screenshot.
|
|
158
|
+
return { content: toContent(result) };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
// Never wedge the agent: say what's wrong and let it carry on.
|
|
162
|
+
return { isError: true, content: [{ type: "text", text: `Huddle live room unavailable (${e?.message ?? e}). Tell your user to run \`huddle up\`.` }] };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
// Be visible to the other sessions from the moment this one opens.
|
|
166
|
+
sayHello().catch(() => { });
|
|
167
|
+
const beat = setInterval(() => void live("heartbeat", {}).catch(() => { }), Number(process.env.HUDDLE_LIVE_BEAT_MS ?? 15_000));
|
|
168
|
+
beat.unref();
|
|
169
|
+
let closing = false;
|
|
170
|
+
const goodbye = async () => {
|
|
171
|
+
if (closing)
|
|
172
|
+
return;
|
|
173
|
+
closing = true;
|
|
174
|
+
clearInterval(beat);
|
|
175
|
+
if (st && sid)
|
|
176
|
+
await liveCall(st, "bye", {}, sid, 1500).catch(() => { });
|
|
177
|
+
process.exit(0);
|
|
178
|
+
};
|
|
179
|
+
process.stdin.on("end", goodbye);
|
|
180
|
+
process.stdin.on("close", goodbye);
|
|
181
|
+
process.on("SIGTERM", goodbye);
|
|
182
|
+
process.on("SIGINT", goodbye);
|
|
183
|
+
await server.connect(new StdioServerTransport());
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=plug.js.map
|
|
@@ -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
|