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,122 @@
|
|
|
1
|
+
// Identity = your GitHub account, proven with the SSH key you already use for git.
|
|
2
|
+
//
|
|
3
|
+
// GitHub publishes everyone's public SSH keys at https://github.com/<user>.keys.
|
|
4
|
+
// So when a room invites @harsh, whoever joins must sign a one-time challenge
|
|
5
|
+
// with a private key whose public half is on Harsh's GitHub. Huddle verifies
|
|
6
|
+
// with `ssh-keygen -Y verify`. No passwords, no OAuth app, no credentials held —
|
|
7
|
+
// the private key never leaves the joiner's machine.
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
const NAMESPACE = "huddle-join";
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
/** "ssh-ed25519 AAAA… comment" → "ssh-ed25519 AAAA…" */
|
|
16
|
+
const bare = (line) => line.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
17
|
+
export async function githubKeys(user) {
|
|
18
|
+
const hit = cache.get(user.toLowerCase());
|
|
19
|
+
if (hit && Date.now() - hit.at < 10 * 60_000)
|
|
20
|
+
return hit.keys;
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(`https://github.com/${encodeURIComponent(user)}.keys`, { signal: AbortSignal.timeout(8000) });
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return [];
|
|
25
|
+
const keys = (await r.text()).split("\n").filter((l) => l.startsWith("ssh-")).map(bare);
|
|
26
|
+
cache.set(user.toLowerCase(), { at: Date.now(), keys });
|
|
27
|
+
return keys;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Your GitHub login, from the gh CLI or git config. */
|
|
34
|
+
export function githubUser() {
|
|
35
|
+
const gh = spawnSync("gh", ["api", "user", "--jq", ".login"], { encoding: "utf8", timeout: 8000 });
|
|
36
|
+
if (gh.status === 0 && gh.stdout.trim())
|
|
37
|
+
return gh.stdout.trim();
|
|
38
|
+
const g = spawnSync("git", ["config", "github.user"], { encoding: "utf8" });
|
|
39
|
+
return g.stdout?.trim() || null;
|
|
40
|
+
}
|
|
41
|
+
function localKeyPairs() {
|
|
42
|
+
const dir = path.join(os.homedir(), ".ssh");
|
|
43
|
+
let files = [];
|
|
44
|
+
try {
|
|
45
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".pub"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
return files
|
|
51
|
+
.map((f) => {
|
|
52
|
+
const pub = path.join(dir, f);
|
|
53
|
+
const priv = pub.slice(0, -4);
|
|
54
|
+
try {
|
|
55
|
+
return { priv, pub, key: bare(fs.readFileSync(pub, "utf8")) };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
.filter((x) => !!x && fs.existsSync(x.priv));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sign a challenge as GitHub user `user`: pick the local key that's on their
|
|
65
|
+
* GitHub, sign with ssh-keygen (it asks for your passphrase if the key has one).
|
|
66
|
+
*/
|
|
67
|
+
export async function signAs(user, nonce) {
|
|
68
|
+
const published = new Set(await githubKeys(user));
|
|
69
|
+
if (!published.size)
|
|
70
|
+
return { ok: false, reason: `couldn't read github.com/${user}.keys (no keys, or no network)` };
|
|
71
|
+
const mine = localKeyPairs().find((k) => published.has(k.key));
|
|
72
|
+
if (!mine)
|
|
73
|
+
return { ok: false, reason: `none of your ~/.ssh keys are on github.com/${user} — are you ${user}?` };
|
|
74
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "huddle-sign-"));
|
|
75
|
+
const msg = path.join(tmp, "challenge");
|
|
76
|
+
fs.writeFileSync(msg, nonce);
|
|
77
|
+
const r = spawnSync("ssh-keygen", ["-Y", "sign", "-q", "-f", mine.priv, "-n", NAMESPACE, msg], { stdio: ["inherit", "pipe", "pipe"] });
|
|
78
|
+
try {
|
|
79
|
+
if (r.status !== 0)
|
|
80
|
+
return { ok: false, reason: `ssh-keygen couldn't sign: ${String(r.stderr).trim().split("\n")[0]}` };
|
|
81
|
+
return { ok: true, key: mine.key, sig: fs.readFileSync(msg + ".sig", "utf8") };
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Is `sig` a signature of `nonce` by a key that's on github.com/<user>.keys? */
|
|
88
|
+
export async function verifyAs(user, nonce, key, sig) {
|
|
89
|
+
if (!(await githubKeys(user)).includes(bare(key)))
|
|
90
|
+
return false;
|
|
91
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "huddle-verify-"));
|
|
92
|
+
try {
|
|
93
|
+
const allowed = path.join(tmp, "allowed");
|
|
94
|
+
const sigFile = path.join(tmp, "sig");
|
|
95
|
+
fs.writeFileSync(allowed, `${user} namespaces="${NAMESPACE}" ${bare(key)}\n`);
|
|
96
|
+
fs.writeFileSync(sigFile, sig);
|
|
97
|
+
const r = spawnSync("ssh-keygen", ["-Y", "verify", "-f", allowed, "-I", user, "-n", NAMESPACE, "-s", sigFile], {
|
|
98
|
+
input: nonce,
|
|
99
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
100
|
+
});
|
|
101
|
+
return r.status === 0;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export const newNonce = () => crypto.randomBytes(24).toString("hex");
|
|
108
|
+
/** "github.com/owner/repo" → "owner/repo", when the remote is on GitHub. */
|
|
109
|
+
export function githubRepo(canonical) {
|
|
110
|
+
const m = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical);
|
|
111
|
+
return m ? m[1] : null;
|
|
112
|
+
}
|
|
113
|
+
/** Can `user` push to owner/repo? true/false, or null if we can't tell (no gh). */
|
|
114
|
+
export function isCollaborator(repo, user) {
|
|
115
|
+
const r = spawnSync("gh", ["api", `repos/${repo}/collaborators/${user}`, "--silent"], { encoding: "utf8", timeout: 10_000 });
|
|
116
|
+
if (r.error)
|
|
117
|
+
return null;
|
|
118
|
+
if (r.status === 0)
|
|
119
|
+
return true;
|
|
120
|
+
return /404|not a collaborator/i.test(`${r.stderr}${r.stdout}`) ? false : null;
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// Attach mode, the Room side: live sessions. When you run claude, codex and
|
|
2
|
+
// cursor yourself — interactively, each in its own terminal — nobody schedules
|
|
3
|
+
// them. Each one's Plug says hello here instead, and from then on the Room
|
|
4
|
+
// knows who is in the repo, what each is doing, and which files each holds.
|
|
5
|
+
//
|
|
6
|
+
// hello / heartbeat / bye presence. A session that stops heart-beating for
|
|
7
|
+
// STALE_MS is gone: its leases are released.
|
|
8
|
+
// announce / check intent + files ("path", "dir/", "path::symbol").
|
|
9
|
+
// Leases match by prefix, so "src/" covers src/a.ts.
|
|
10
|
+
// guard the pre-edit check (Claude's PreToolUse hook):
|
|
11
|
+
// warn if someone else holds the file, else lease it.
|
|
12
|
+
// note / peers messages between sessions; every reply carries the
|
|
13
|
+
// caller's unread notes, so nobody has to poll.
|
|
14
|
+
//
|
|
15
|
+
// Sessions live in memory (presence is ephemeral); leases, notes and memory
|
|
16
|
+
// are in the db, so `huddle peers` and the dashboard can read them.
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
export const LIVE_TASK = "live";
|
|
20
|
+
/** "a/b.ts" "a/" "a/b.ts::fn" — normalized against a checkout root. */
|
|
21
|
+
export function normPath(p, roots) {
|
|
22
|
+
let [file, sym] = String(p).replace(/\\/g, "/").split("::");
|
|
23
|
+
file = file.trim();
|
|
24
|
+
if (path.isAbsolute(file)) {
|
|
25
|
+
const rel = relativeTo(file, roots) ?? relativeTo(realish(file), roots);
|
|
26
|
+
if (rel !== null)
|
|
27
|
+
file = rel;
|
|
28
|
+
}
|
|
29
|
+
file = file.replace(/^(\.\/)+/, "").replace(/\/\*\*?$/, "/");
|
|
30
|
+
if (!file || file === ".")
|
|
31
|
+
file = "/"; // the whole repo
|
|
32
|
+
return sym ? `${file}::${sym.trim()}` : file;
|
|
33
|
+
}
|
|
34
|
+
function relativeTo(file, roots) {
|
|
35
|
+
for (const r of roots) {
|
|
36
|
+
const rel = path.relative(r, file);
|
|
37
|
+
if (!rel)
|
|
38
|
+
return "";
|
|
39
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
40
|
+
return rel.split(path.sep).join("/");
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** realpath of the file, or of its nearest existing parent (/tmp vs /private/tmp on macOS). */
|
|
45
|
+
function realish(file) {
|
|
46
|
+
let dir = file;
|
|
47
|
+
const rest = [];
|
|
48
|
+
while (dir !== path.dirname(dir)) {
|
|
49
|
+
try {
|
|
50
|
+
return path.join(fs.realpathSync(dir), ...rest.reverse());
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
rest.push(path.basename(dir));
|
|
54
|
+
dir = path.dirname(dir);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
/** Do two leases cover any of the same code? Directories cover what's under them. */
|
|
60
|
+
/** 42s · 5m · 3h · 2d — how long ago, for notes an agent reads. */
|
|
61
|
+
export function ago(ms) {
|
|
62
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
63
|
+
return s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : s < 86400 ? `${Math.round(s / 3600)}h` : `${Math.round(s / 86400)}d`;
|
|
64
|
+
}
|
|
65
|
+
export function overlaps(a, b) {
|
|
66
|
+
const [fa, sa] = a.split("::");
|
|
67
|
+
const [fb, sb] = b.split("::");
|
|
68
|
+
const dir = (d, f) => d === "/" || (d.endsWith("/") && (f.startsWith(d) || f === d.slice(0, -1)));
|
|
69
|
+
if (dir(fa, fb) || dir(fb, fa))
|
|
70
|
+
return true;
|
|
71
|
+
if (fa !== fb)
|
|
72
|
+
return false;
|
|
73
|
+
return !sa || !sb || sa === sb; // same file: only distinct symbols can coexist
|
|
74
|
+
}
|
|
75
|
+
export function liveRoom(db, opts = { repoRoot: process.cwd() }) {
|
|
76
|
+
const STALE_MS = opts.staleMs ?? 60_000;
|
|
77
|
+
const LEASE_MS = opts.leaseMs ?? 30 * 60_000;
|
|
78
|
+
const live = new Map();
|
|
79
|
+
const end = (s, why) => {
|
|
80
|
+
live.delete(s.id);
|
|
81
|
+
db.releaseClaims(s.id);
|
|
82
|
+
db.setAgentStatus(s.id, "offline");
|
|
83
|
+
db.publish("live.left", { agent_id: s.id, payload: { id: s.id, agent: s.agent, why } });
|
|
84
|
+
};
|
|
85
|
+
// Found live: the laptop slept for 14 minutes in the middle of a run. On
|
|
86
|
+
// wake the room's first sweep saw every session silent for > STALE_MS and
|
|
87
|
+
// ended them all at once, so Claude lost its lease on src/strings.js
|
|
88
|
+
// mid-task (its huddle_release then said "released: []") and Cursor was
|
|
89
|
+
// handed the same file. Nobody could heartbeat while the whole machine was
|
|
90
|
+
// asleep, so a gap between sweeps longer than STALE_MS means the ROOM was
|
|
91
|
+
// suspended, not that the sessions went away: give everyone a fresh window
|
|
92
|
+
// (and fresh leases) instead. A session that really died in the meantime is
|
|
93
|
+
// swept one window later.
|
|
94
|
+
const SWEEP_MS = opts.sweepMs ?? Math.min(STALE_MS, 15_000);
|
|
95
|
+
let lastSweep = Date.now();
|
|
96
|
+
const sweep = () => {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
if (now - lastSweep > Math.max(STALE_MS, SWEEP_MS * 3)) {
|
|
99
|
+
for (const s of live.values()) {
|
|
100
|
+
s.seen_at = now;
|
|
101
|
+
db.renewClaims(s.id, now + LEASE_MS);
|
|
102
|
+
}
|
|
103
|
+
db.publish("live.resumed", { payload: { asleep_ms: now - lastSweep, sessions: live.size } });
|
|
104
|
+
}
|
|
105
|
+
lastSweep = now;
|
|
106
|
+
for (const s of [...live.values()])
|
|
107
|
+
if (now - s.seen_at > STALE_MS)
|
|
108
|
+
end(s, "went quiet");
|
|
109
|
+
};
|
|
110
|
+
const timer = setInterval(sweep, SWEEP_MS);
|
|
111
|
+
timer.unref();
|
|
112
|
+
const roots = (s) => [...(s ? [s.root] : []), opts.repoRoot];
|
|
113
|
+
const norm = (list, s) => [...new Set((Array.isArray(list) ? list : [list]).filter(Boolean).map((p) => normPath(String(p), roots(s))))].slice(0, 200);
|
|
114
|
+
/** Leases held by *other* live sessions that collide with `touches`. */
|
|
115
|
+
const conflicts = (touches, me) => {
|
|
116
|
+
const out = [];
|
|
117
|
+
const claims = db.listClaims().filter((c) => c.task_id === LIVE_TASK && c.held_by !== me && live.has(c.held_by));
|
|
118
|
+
for (const t of touches)
|
|
119
|
+
for (const c of claims)
|
|
120
|
+
if (overlaps(t, c.path)) {
|
|
121
|
+
const h = live.get(c.held_by);
|
|
122
|
+
out.push({ path: t, held: c.path, held_by: h.id, agent: h.agent, intent: h.intent });
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
};
|
|
126
|
+
const holds = (id) => db.listClaims().filter((c) => c.held_by === id && c.task_id === LIVE_TASK).map((c) => c.path);
|
|
127
|
+
const note = (from, to, body, kind = "note") => db.publish("note", { agent_id: from, payload: { from, to, body: body.slice(0, 2000), kind } });
|
|
128
|
+
/** Notes for `s` it hasn't seen: addressed to everyone, its id, or its agent name. */
|
|
129
|
+
const inbox = (s) => {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const e of db.eventsOfType(["note"], s.started_at - 1)) {
|
|
132
|
+
if (s.seen.has(e.id))
|
|
133
|
+
continue;
|
|
134
|
+
const p = JSON.parse(e.payload || "{}");
|
|
135
|
+
if (p.from === s.id)
|
|
136
|
+
continue;
|
|
137
|
+
if (p.to && p.to !== "all" && p.to !== s.id && p.to !== s.agent)
|
|
138
|
+
continue;
|
|
139
|
+
s.seen.add(e.id);
|
|
140
|
+
out.push({ from: p.from, body: p.body, kind: p.kind ?? "note", at: e.ts });
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
const peerView = (s, me) => ({
|
|
145
|
+
id: s.id,
|
|
146
|
+
agent: s.agent,
|
|
147
|
+
you: s.id === me,
|
|
148
|
+
branch: s.branch,
|
|
149
|
+
worktree: s.root === opts.repoRoot ? "(main checkout)" : path.relative(opts.repoRoot, s.root) || s.root,
|
|
150
|
+
doing: s.intent || "(hasn't said)",
|
|
151
|
+
holds: holds(s.id),
|
|
152
|
+
seen_s_ago: Math.round((Date.now() - s.seen_at) / 1000),
|
|
153
|
+
});
|
|
154
|
+
// Found live: notes outlive their sessions (they're in live.db), and a new
|
|
155
|
+
// codex read an hour-old "I'm adding whisper to strings.js" from a claude
|
|
156
|
+
// that had long finished as if it were current. So each note says how old it
|
|
157
|
+
// is and whether its author is still here.
|
|
158
|
+
// A session sees only notes it may read (to everyone, to it, or from it);
|
|
159
|
+
// the person's read-only view (`huddle peers`, no session) sees them all.
|
|
160
|
+
const recentNotes = (n = 10, s) => db
|
|
161
|
+
.eventsOfType(["note"], 0, s ? n * 5 : n)
|
|
162
|
+
.map((e) => ({ e, p: JSON.parse(e.payload || "{}") }))
|
|
163
|
+
.filter(({ p }) => !s || !p.to || p.to === "all" || p.to === s.id || p.to === s.agent || p.from === s.id)
|
|
164
|
+
.slice(-n)
|
|
165
|
+
.map(({ e, p }) => ({ from: p.from, to: p.to ?? "all", body: p.body, ago: ago(Date.now() - e.ts), from_live: live.has(p.from) }));
|
|
166
|
+
const take = (s, touches) => {
|
|
167
|
+
const exp = Date.now() + LEASE_MS;
|
|
168
|
+
return touches.filter((t) => db.leaseClaim(t, s.id, LIVE_TASK, exp));
|
|
169
|
+
};
|
|
170
|
+
const verbs = {
|
|
171
|
+
heartbeat: () => ({ ok: true }),
|
|
172
|
+
peers: (_b, s) => ({
|
|
173
|
+
sessions: [...live.values()].map((x) => peerView(x, s.id)),
|
|
174
|
+
notes: recentNotes(10, s),
|
|
175
|
+
}),
|
|
176
|
+
announce: (b, s) => {
|
|
177
|
+
const touches = norm(b.touches, s);
|
|
178
|
+
const hit = conflicts(touches, s.id);
|
|
179
|
+
const blocked = new Set(hit.map((c) => c.path));
|
|
180
|
+
s.intent = String(b.intent ?? "").slice(0, 300) || s.intent;
|
|
181
|
+
const leased = take(s, touches.filter((t) => !blocked.has(t)));
|
|
182
|
+
// Tell each holder someone wants their files — they hear it on their next call.
|
|
183
|
+
for (const h of new Set(hit.map((c) => c.held_by)))
|
|
184
|
+
note(s.id, h, `${s.id} wants ${hit.filter((c) => c.held_by === h).map((c) => c.held).join(", ")} (you hold it) for: ${s.intent}. Reply with huddle_note, or huddle_release when you're done.`, "heads-up");
|
|
185
|
+
db.publish("plan.announced", { task_id: LIVE_TASK, agent_id: s.id, payload: { intent: s.intent, touches, conflicts: hit } });
|
|
186
|
+
return {
|
|
187
|
+
ok: !hit.length,
|
|
188
|
+
leased,
|
|
189
|
+
conflicts: hit,
|
|
190
|
+
advice: hit.length
|
|
191
|
+
? "STOP: another live session holds some of these. Don't edit them — huddle_note the holder (they've been told you asked), pick other work, or ask your user."
|
|
192
|
+
: "Yours. Others will be warned before they edit these. huddle_release when you're done.",
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
check: (b, s) => {
|
|
196
|
+
const hit = conflicts(norm(b.touches, s), s.id);
|
|
197
|
+
return { clear: !hit.length, conflicts: hit };
|
|
198
|
+
},
|
|
199
|
+
release: (b, s) => {
|
|
200
|
+
const mine = holds(s.id);
|
|
201
|
+
const want = b.touches?.length ? norm(b.touches, s) : mine;
|
|
202
|
+
const freed = mine.filter((m) => want.some((w) => overlaps(w, m)));
|
|
203
|
+
for (const f of freed)
|
|
204
|
+
db.releaseClaim(f, s.id);
|
|
205
|
+
if (!b.touches?.length)
|
|
206
|
+
s.intent = "";
|
|
207
|
+
db.publish("live.released", { agent_id: s.id, payload: { paths: freed } });
|
|
208
|
+
return { released: freed };
|
|
209
|
+
},
|
|
210
|
+
note: (b, s) => {
|
|
211
|
+
const body = String(b.body ?? "").trim();
|
|
212
|
+
if (!body)
|
|
213
|
+
return { ok: false, error: "empty note" };
|
|
214
|
+
const to = b.to ? String(b.to) : null;
|
|
215
|
+
note(s.id, to, body);
|
|
216
|
+
return { ok: true, to: to ?? "all" };
|
|
217
|
+
},
|
|
218
|
+
bye: (_b, s) => {
|
|
219
|
+
end(s, "closed");
|
|
220
|
+
return { ok: true };
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
sessions: () => [...live.values()],
|
|
225
|
+
close: () => clearInterval(timer),
|
|
226
|
+
async handle(verb, body, sessionId) {
|
|
227
|
+
sweep();
|
|
228
|
+
if (verb === "ping")
|
|
229
|
+
return { status: 200, body: { ok: true, sessions: live.size } };
|
|
230
|
+
if (verb === "hello") {
|
|
231
|
+
const agent = String(body.agent ?? "agent").replace(/[^\w.-]/g, "").slice(0, 24) || "agent";
|
|
232
|
+
const pid = Number(body.pid) || 0;
|
|
233
|
+
const root = path.resolve(String(body.root ?? opts.repoRoot));
|
|
234
|
+
// The same process saying hello again (the room restarted) keeps its id.
|
|
235
|
+
let s = [...live.values()].find((x) => x.agent === agent && x.pid === pid && pid > 0);
|
|
236
|
+
if (!s) {
|
|
237
|
+
let id = agent;
|
|
238
|
+
for (let n = 2; live.has(id); n++)
|
|
239
|
+
id = `${agent}#${n}`;
|
|
240
|
+
s = { id, agent, pid, root, branch: String(body.branch ?? ""), intent: "", started_at: Date.now(), seen_at: Date.now(), seen: new Set() };
|
|
241
|
+
live.set(id, s);
|
|
242
|
+
db.upsertAgent({ id, vendor: agent, model: "", role: "worker" });
|
|
243
|
+
db.setAgentStatus(id, "working", root);
|
|
244
|
+
db.publish("live.joined", { agent_id: id, payload: { id, agent, branch: s.branch, root } });
|
|
245
|
+
}
|
|
246
|
+
s.seen_at = Date.now();
|
|
247
|
+
return { status: 200, body: { id: s.id, peers: [...live.values()].filter((x) => x.id !== s.id).map((x) => peerView(x)) } };
|
|
248
|
+
}
|
|
249
|
+
if (verb === "guard") {
|
|
250
|
+
// Called by a hook, not the Plug: find the session by its process tree.
|
|
251
|
+
const pids = (Array.isArray(body.pids) ? body.pids : []).map(Number);
|
|
252
|
+
const s = [...live.values()].find((x) => pids.includes(x.pid));
|
|
253
|
+
const touches = norm(body.path, s);
|
|
254
|
+
const hit = conflicts(touches, s?.id);
|
|
255
|
+
if (hit.length) {
|
|
256
|
+
const c = hit[0];
|
|
257
|
+
if (s)
|
|
258
|
+
note(s.id, c.held_by, `${s.id} is about to edit ${c.path}, which you hold. Their user is being asked to confirm.`, "heads-up");
|
|
259
|
+
return {
|
|
260
|
+
status: 200,
|
|
261
|
+
body: {
|
|
262
|
+
decision: "ask",
|
|
263
|
+
reason: `Huddle: ${c.path} is held by ${c.held_by} (${c.agent}) — "${c.intent || "no intent given"}". Editing now may collide; check huddle_peers or huddle_note ${c.held_by} first.`,
|
|
264
|
+
conflicts: hit,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
if (s) {
|
|
269
|
+
s.seen_at = Date.now();
|
|
270
|
+
return { status: 200, body: { decision: "allow", session: s.id, leased: take(s, touches) } };
|
|
271
|
+
}
|
|
272
|
+
return { status: 200, body: { decision: "allow", session: null } };
|
|
273
|
+
}
|
|
274
|
+
const s = live.get(sessionId);
|
|
275
|
+
// A person peeking in (`huddle peers`) isn't a session: read-only view.
|
|
276
|
+
if (!s && verb === "peers")
|
|
277
|
+
return { status: 200, body: { sessions: [...live.values()].map((x) => peerView(x)), notes: recentNotes() } };
|
|
278
|
+
if (!s)
|
|
279
|
+
return { status: 409, body: { error: "unknown session — say hello first", rehello: true } };
|
|
280
|
+
const fn = verbs[verb];
|
|
281
|
+
if (!fn)
|
|
282
|
+
return { status: 404, body: { error: `unknown live verb: ${verb}` } };
|
|
283
|
+
s.seen_at = Date.now();
|
|
284
|
+
db.renewClaims(s.id, Date.now() + LEASE_MS);
|
|
285
|
+
const out = fn(body, s);
|
|
286
|
+
if (verb === "bye")
|
|
287
|
+
return { status: 200, body: out };
|
|
288
|
+
const unread = inbox(s);
|
|
289
|
+
return { status: 200, body: unread.length ? { ...out, notes_for_you: unread } : out };
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
//# sourceMappingURL=live.js.map
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// The board as agents see it mid-task: for every task, who owns it, where it
|
|
2
|
+
// is, the owner's last huddle_progress note and how old things are — so an
|
|
3
|
+
// agent can tell a teammate that's nearly done from one that went quiet, and
|
|
4
|
+
// see who took over whose work. Built from the task table plus Room events
|
|
5
|
+
// (task.claimed / task.progress / task.takeover / task.split); no new tables.
|
|
6
|
+
const ago = (s) => (s < 60 ? `${s}s` : s < 3600 ? `${Math.round(s / 60)}m` : `${Math.round(s / 360) / 10}h`);
|
|
7
|
+
const parse = (s) => {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(s || "{}");
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export function taskViews(db, now = Date.now()) {
|
|
16
|
+
const evs = db.eventsOfType(["task.claimed", "task.progress", "task.takeover", "task.split"], 0, 1000);
|
|
17
|
+
const claimed = new Map();
|
|
18
|
+
const note = new Map();
|
|
19
|
+
const touched = new Map();
|
|
20
|
+
const takeover = new Map();
|
|
21
|
+
const split = new Map();
|
|
22
|
+
for (const e of evs) {
|
|
23
|
+
if (!e.task_id)
|
|
24
|
+
continue;
|
|
25
|
+
const p = parse(e.payload);
|
|
26
|
+
touched.set(e.task_id, e.ts);
|
|
27
|
+
if (e.type === "task.claimed")
|
|
28
|
+
claimed.set(e.task_id, e.ts);
|
|
29
|
+
if (e.type === "task.progress" && p.note)
|
|
30
|
+
note.set(e.task_id, { text: String(p.note), ts: e.ts });
|
|
31
|
+
if (e.type === "task.takeover" && p.kind !== "split") {
|
|
32
|
+
takeover.set(e.task_id, { from: String(p.from ?? ""), reason: String(p.reason ?? "") });
|
|
33
|
+
claimed.set(e.task_id, e.ts); // the new owner's clock starts now
|
|
34
|
+
}
|
|
35
|
+
if (e.type === "task.split" && p.parent)
|
|
36
|
+
split.set(e.task_id, String(p.parent));
|
|
37
|
+
}
|
|
38
|
+
const secs = (ts) => Math.max(0, Math.round((now - ts) / 1000));
|
|
39
|
+
return db.listTasks().map((t) => {
|
|
40
|
+
const n = note.get(t.id);
|
|
41
|
+
const tk = takeover.get(t.id);
|
|
42
|
+
const c = claimed.get(t.id);
|
|
43
|
+
return {
|
|
44
|
+
id: t.id,
|
|
45
|
+
title: t.title,
|
|
46
|
+
status: t.status,
|
|
47
|
+
owner: t.claimed_by,
|
|
48
|
+
progress_pct: t.progress_pct,
|
|
49
|
+
last_note: n?.text ?? null,
|
|
50
|
+
last_update_s: secs(Math.max(n?.ts ?? 0, touched.get(t.id) ?? 0, t.updated_at)),
|
|
51
|
+
age_s: t.status === "backlog" || c === undefined ? null : secs(c),
|
|
52
|
+
depends_on: JSON.parse(t.depends_on),
|
|
53
|
+
files: JSON.parse(t.files ?? "[]"),
|
|
54
|
+
...(tk ? { taken_over_from: tk.from, takeover_reason: tk.reason } : {}),
|
|
55
|
+
...(split.has(t.id) ? { split_from: split.get(t.id) } : {}),
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** One readable line per task: "codex · in_progress 40% · 3m · “wired the parser” (20s ago)". */
|
|
60
|
+
export function progressLine(v) {
|
|
61
|
+
const who = v.owner ?? "unclaimed";
|
|
62
|
+
const pct = v.status === "in_progress" || v.status === "claimed" ? ` ${v.progress_pct}%` : "";
|
|
63
|
+
const age = v.age_s !== null && v.status !== "merged" ? ` · ${ago(v.age_s)} on it` : "";
|
|
64
|
+
const last = v.last_note ? ` · “${v.last_note.slice(0, 120)}” (${ago(v.last_update_s)} ago)` : "";
|
|
65
|
+
const tk = v.taken_over_from ? ` · took over from ${v.taken_over_from}` : "";
|
|
66
|
+
const sp = v.split_from ? ` · split from ${v.split_from}` : "";
|
|
67
|
+
return `[${v.id}] ${v.title} — ${who} · ${v.status}${pct}${age}${last}${tk}${sp}`;
|
|
68
|
+
}
|
|
69
|
+
/** What huddle_progress hands back: everyone else's in-flight work, at a glance. */
|
|
70
|
+
export function teamProgress(db, exceptTask) {
|
|
71
|
+
return taskViews(db)
|
|
72
|
+
.filter((v) => v.id !== exceptTask && ["claimed", "in_progress", "review"].includes(v.status))
|
|
73
|
+
.map(progressLine);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The launch prompt's "WORKING ALONGSIDE YOU" section: the other tasks in
|
|
77
|
+
* flight right now, who is on them, and their files, so an agent knows WHO
|
|
78
|
+
* to tell when it settles something they also rely on or expose. Found live
|
|
79
|
+
* (capstone round 2): 0 agent-to-agent messages in a 9-task run; the CLI and
|
|
80
|
+
* API were built side by side over the same service, and neither agent knew
|
|
81
|
+
* the other was mid-task without polling huddle_board. Empty when nobody else is working.
|
|
82
|
+
*/
|
|
83
|
+
export function alongsideBrief(db, taskId, agentId) {
|
|
84
|
+
const others = db
|
|
85
|
+
.listTasks()
|
|
86
|
+
.filter((t) => t.id !== taskId && t.claimed_by && t.claimed_by !== agentId && ["claimed", "in_progress"].includes(t.status))
|
|
87
|
+
.filter((t) => !/^QA round \d+:/.test(t.title));
|
|
88
|
+
if (!others.length)
|
|
89
|
+
return "";
|
|
90
|
+
const lines = others.map((t) => {
|
|
91
|
+
const files = JSON.parse(t.files ?? "[]").slice(0, 6);
|
|
92
|
+
return `- ${t.claimed_by}: “${t.title}” (task ${t.id}${files.length ? ` · ${files.join(", ")}` : ""})`;
|
|
93
|
+
});
|
|
94
|
+
return (`\n\nWORKING ALONGSIDE YOU right now, each in its own worktree:\n${lines.join("\n")}\n` +
|
|
95
|
+
`When you settle something one of them also relies on or exposes (a function or data shape, an error message, an output format, a test helper), ` +
|
|
96
|
+
`huddle_send(to="<agent>", message=…) as soon as you decide it, not at the end. Unsure what theirs does? huddle_ask(…, to="<agent>"). ` +
|
|
97
|
+
`Nothing they need? Don't send.`);
|
|
98
|
+
}
|
|
99
|
+
/** Was this message Huddle's automatic help offer (sent in a free agent's name), not words the agent wrote? */
|
|
100
|
+
export function isHelpOffer(from, body) {
|
|
101
|
+
return body.startsWith(`${from} is free and can take part of your task `);
|
|
102
|
+
}
|
|
103
|
+
/** The note an idle agent (or the scheduler on its behalf) drops in a busy teammate's inbox. */
|
|
104
|
+
export function helpOffer(from, taskId, title) {
|
|
105
|
+
return (`${from} is free and can take part of your task "${title}" (${taskId}). ` +
|
|
106
|
+
`If a piece of it is separable — ideally in files you won't keep editing — call ` +
|
|
107
|
+
`huddle_split(task_id="${taskId}", title=…, spec=…, files=[…]) and Huddle hands that part to a free agent; then don't do that part yourself. ` +
|
|
108
|
+
`If it isn't worth splitting, ignore this.`);
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=progress.js.map
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The host's side of the relay: keeps one outbound WebSocket to the relay and
|
|
2
|
+
// serves members through it with the exact same shared API as the direct door.
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
export function openRelayLink(room, relayUrl, log) {
|
|
6
|
+
const base = relayUrl.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
7
|
+
const roomId = crypto.randomBytes(12).toString("base64url");
|
|
8
|
+
const key = crypto.randomBytes(24).toString("hex");
|
|
9
|
+
const subs = new Map(); // member connection → unsubscribe
|
|
10
|
+
let ws;
|
|
11
|
+
let closed = false;
|
|
12
|
+
let backoff = 1000;
|
|
13
|
+
const connect = (first) => {
|
|
14
|
+
ws = new WebSocket(`${base}/host/${roomId}?key=${key}`);
|
|
15
|
+
ws.on("open", () => {
|
|
16
|
+
backoff = 1000;
|
|
17
|
+
first(true);
|
|
18
|
+
});
|
|
19
|
+
ws.on("message", async (raw) => {
|
|
20
|
+
let m;
|
|
21
|
+
try {
|
|
22
|
+
m = JSON.parse(String(raw));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const send = (f, close) => ws.readyState === WebSocket.OPEN && ws.send(JSON.stringify({ t: "to", cid: m.cid, f, ...(close ? { close } : {}) }));
|
|
28
|
+
if (m.t === "gone") {
|
|
29
|
+
subs.get(m.cid)?.();
|
|
30
|
+
subs.delete(m.cid);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (m.t !== "from")
|
|
34
|
+
return;
|
|
35
|
+
const f = m.f ?? {};
|
|
36
|
+
if (f.t === "req") {
|
|
37
|
+
const r = await room.handleShared({ method: String(f.method), path: String(f.path), token: f.token, body: f.body });
|
|
38
|
+
send({ t: "res", id: f.id, status: r.status, body: r.body });
|
|
39
|
+
}
|
|
40
|
+
else if (f.t === "sub") {
|
|
41
|
+
const stop = room.subscribe(String(f.token ?? ""), (e) => send({ t: "event", e }), () => send({ t: "closed", code: 4403 }, 4403));
|
|
42
|
+
if (!stop)
|
|
43
|
+
send({ t: "closed", code: 4401 }, 4401);
|
|
44
|
+
else
|
|
45
|
+
subs.set(m.cid, stop);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
ws.on("close", () => {
|
|
49
|
+
for (const stop of subs.values())
|
|
50
|
+
stop();
|
|
51
|
+
subs.clear();
|
|
52
|
+
if (closed)
|
|
53
|
+
return;
|
|
54
|
+
setTimeout(() => connect(() => { }), backoff);
|
|
55
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
56
|
+
});
|
|
57
|
+
ws.on("error", () => first(false));
|
|
58
|
+
};
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let done = false;
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
if (!done)
|
|
63
|
+
(done = true), resolve(null);
|
|
64
|
+
}, 10_000);
|
|
65
|
+
connect((ok) => {
|
|
66
|
+
if (done)
|
|
67
|
+
return;
|
|
68
|
+
done = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
if (!ok) {
|
|
71
|
+
closed = true;
|
|
72
|
+
log(` couldn't reach the relay at ${relayUrl}`);
|
|
73
|
+
return resolve(null);
|
|
74
|
+
}
|
|
75
|
+
resolve({
|
|
76
|
+
address: `${base}/r/${roomId}`,
|
|
77
|
+
close: () => {
|
|
78
|
+
closed = true;
|
|
79
|
+
ws.close(1000);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=relaylink.js.map
|