huddle-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/LICENSE +21 -0
  3. package/QUICKSTART.md +102 -0
  4. package/README.md +376 -2
  5. package/dist/adapters/amp.js +40 -0
  6. package/dist/adapters/antigravity.js +54 -0
  7. package/dist/adapters/claude.js +86 -0
  8. package/dist/adapters/codex.js +64 -0
  9. package/dist/adapters/copilot.js +69 -0
  10. package/dist/adapters/cursor.js +92 -0
  11. package/dist/adapters/custom.js +342 -0
  12. package/dist/adapters/droid.js +58 -0
  13. package/dist/adapters/gemini.js +61 -0
  14. package/dist/adapters/goose.js +57 -0
  15. package/dist/adapters/index.js +161 -0
  16. package/dist/adapters/kiro.js +41 -0
  17. package/dist/adapters/opencode.js +63 -0
  18. package/dist/adapters/qwen.js +67 -0
  19. package/dist/adapters/types.js +6 -0
  20. package/dist/adapters/usage.js +141 -0
  21. package/dist/attach/connect.js +412 -0
  22. package/dist/attach/guard.js +83 -0
  23. package/dist/attach/live.js +226 -0
  24. package/dist/attach/plug.js +185 -0
  25. package/dist/attach/screen.js +150 -0
  26. package/dist/attach/session.js +177 -0
  27. package/dist/cli.js +244 -0
  28. package/dist/mcp/context-tools.js +79 -0
  29. package/dist/mcp/server.js +267 -0
  30. package/dist/orchestrator/ask.js +54 -0
  31. package/dist/orchestrator/brain.js +174 -0
  32. package/dist/orchestrator/council.js +615 -0
  33. package/dist/orchestrator/goal.js +196 -0
  34. package/dist/orchestrator/handoff.js +117 -0
  35. package/dist/orchestrator/launcher.js +171 -0
  36. package/dist/orchestrator/learn.js +105 -0
  37. package/dist/orchestrator/planner.js +212 -0
  38. package/dist/orchestrator/qa.js +398 -0
  39. package/dist/orchestrator/radar.js +719 -0
  40. package/dist/orchestrator/scheduler.js +1207 -0
  41. package/dist/orchestrator/takeover.js +240 -0
  42. package/dist/platform.js +273 -0
  43. package/dist/preflight.js +31 -0
  44. package/dist/relay/server.js +113 -0
  45. package/dist/room/context.js +1285 -0
  46. package/dist/room/contract.js +132 -0
  47. package/dist/room/db.js +680 -0
  48. package/dist/room/followup.js +233 -0
  49. package/dist/room/identity.js +122 -0
  50. package/dist/room/lessons.js +275 -0
  51. package/dist/room/live.js +348 -0
  52. package/dist/room/mail.js +212 -0
  53. package/dist/room/progress.js +149 -0
  54. package/dist/room/relaylink.js +85 -0
  55. package/dist/room/server.js +882 -0
  56. package/dist/room/sqlite.js +156 -0
  57. package/dist/room/transport.js +107 -0
  58. package/dist/room/tunnel.js +86 -0
  59. package/dist/sandbox/sync.js +104 -0
  60. package/dist/sandbox/worktree.js +392 -0
  61. package/dist/types.js +4 -0
  62. package/dist/ui/app.js +1955 -0
  63. package/dist/ui/catalog.js +113 -0
  64. package/dist/ui/dashboard.js +335 -0
  65. package/dist/ui/doctor.js +58 -0
  66. package/dist/ui/lessons.js +81 -0
  67. package/dist/ui/logo.js +40 -0
  68. package/dist/ui/paste.js +515 -0
  69. package/dist/ui/remote.js +338 -0
  70. package/dist/ui/setup.js +152 -0
  71. package/dist/ui/story.js +1529 -0
  72. package/dist/ui/term.js +249 -0
  73. package/dist/ui/watch.js +271 -0
  74. package/install.sh +44 -0
  75. package/package.json +63 -4
@@ -0,0 +1,680 @@
1
+ // The Room's storage: one SQLite file (.huddle/huddle.db) that is the single
2
+ // source of truth for the whole session. Synchronous better-sqlite3 is ideal
3
+ // for a local single-writer daemon — fast, zero-setup, trivially inspectable.
4
+ // (sqlite.ts falls back to Node's built-in node:sqlite when the addon is missing.)
5
+ import { openDatabase } from "./sqlite.js";
6
+ import crypto from "node:crypto";
7
+ import nodePath from "node:path";
8
+ import { rememberLesson } from "./lessons.js";
9
+ import { nanoid } from "nanoid";
10
+ const SCHEMA = /* sql */ `
11
+ CREATE TABLE IF NOT EXISTS session (
12
+ id TEXT PRIMARY KEY,
13
+ goal TEXT NOT NULL,
14
+ status TEXT NOT NULL DEFAULT 'open',
15
+ created_at INTEGER NOT NULL
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS agent (
19
+ id TEXT PRIMARY KEY,
20
+ vendor TEXT NOT NULL,
21
+ model TEXT NOT NULL,
22
+ role TEXT NOT NULL DEFAULT 'worker',
23
+ worktree_path TEXT,
24
+ status TEXT NOT NULL DEFAULT 'idle',
25
+ budget_cents INTEGER NOT NULL DEFAULT 0,
26
+ spent_cents INTEGER NOT NULL DEFAULT 0,
27
+ updated_at INTEGER NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS task (
31
+ id TEXT PRIMARY KEY,
32
+ title TEXT NOT NULL,
33
+ spec TEXT NOT NULL DEFAULT '',
34
+ status TEXT NOT NULL DEFAULT 'backlog',
35
+ claimed_by TEXT,
36
+ progress_pct INTEGER NOT NULL DEFAULT 0,
37
+ depends_on TEXT NOT NULL DEFAULT '[]',
38
+ prefers TEXT NOT NULL DEFAULT '[]',
39
+ est_seconds INTEGER,
40
+ created_at INTEGER NOT NULL,
41
+ updated_at INTEGER NOT NULL
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS plan (
45
+ id TEXT PRIMARY KEY,
46
+ task_id TEXT NOT NULL,
47
+ agent_id TEXT NOT NULL,
48
+ intent TEXT NOT NULL,
49
+ touches TEXT NOT NULL DEFAULT '[]',
50
+ created_at INTEGER NOT NULL
51
+ );
52
+
53
+ CREATE TABLE IF NOT EXISTS memory (
54
+ id TEXT PRIMARY KEY,
55
+ kind TEXT NOT NULL,
56
+ body TEXT NOT NULL,
57
+ why TEXT NOT NULL DEFAULT '',
58
+ author TEXT NOT NULL,
59
+ created_at INTEGER NOT NULL
60
+ );
61
+
62
+ CREATE TABLE IF NOT EXISTS file_claim (
63
+ path TEXT PRIMARY KEY,
64
+ held_by TEXT NOT NULL,
65
+ task_id TEXT NOT NULL,
66
+ expires_at INTEGER NOT NULL
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS event (
70
+ id TEXT PRIMARY KEY,
71
+ task_id TEXT,
72
+ agent_id TEXT,
73
+ type TEXT NOT NULL,
74
+ payload TEXT NOT NULL DEFAULT '{}',
75
+ ts INTEGER NOT NULL
76
+ );
77
+
78
+ -- Multiplayer: people in the room, and one-time invites to get in.
79
+ -- Tokens and invite codes are stored hashed, never in the clear.
80
+ CREATE TABLE IF NOT EXISTS member (
81
+ id TEXT PRIMARY KEY,
82
+ name TEXT NOT NULL,
83
+ role TEXT NOT NULL DEFAULT 'member',
84
+ token_hash TEXT NOT NULL UNIQUE,
85
+ joined_at INTEGER NOT NULL,
86
+ left_at INTEGER
87
+ );
88
+
89
+ CREATE TABLE IF NOT EXISTS invite (
90
+ code_hash TEXT PRIMARY KEY,
91
+ role TEXT NOT NULL DEFAULT 'member',
92
+ for_name TEXT,
93
+ expires_at INTEGER NOT NULL,
94
+ used_by TEXT
95
+ );
96
+
97
+ -- Agent-to-agent messages (huddle_send / huddle_inbox / routed huddle_ask).
98
+ -- kind: note | question | answer. Unread until the recipient reads its inbox.
99
+ CREATE TABLE IF NOT EXISTS message (
100
+ id TEXT PRIMARY KEY,
101
+ from_agent TEXT NOT NULL,
102
+ to_agent TEXT NOT NULL,
103
+ body TEXT NOT NULL,
104
+ kind TEXT NOT NULL DEFAULT 'note',
105
+ reply_to TEXT,
106
+ task_id TEXT,
107
+ created_at INTEGER NOT NULL,
108
+ read_at INTEGER,
109
+ -- How it reached the recipient: piggyback | hook | inbox | ask | reply | prompt | cleared.
110
+ delivered_how TEXT
111
+ );
112
+
113
+ -- Every huddle_done, so evals can tell mail read mid-task from mail read at the end.
114
+ CREATE TABLE IF NOT EXISTS done_log (
115
+ agent_id TEXT NOT NULL,
116
+ task_id TEXT NOT NULL,
117
+ ts INTEGER NOT NULL
118
+ );
119
+
120
+ -- What a merged task hands its dependents: summary, files, exported signatures, notes.
121
+ CREATE TABLE IF NOT EXISTS handoff (
122
+ task_id TEXT PRIMARY KEY,
123
+ agent_id TEXT NOT NULL,
124
+ title TEXT NOT NULL DEFAULT '',
125
+ summary TEXT NOT NULL DEFAULT '',
126
+ files TEXT NOT NULL DEFAULT '[]',
127
+ symbols TEXT NOT NULL DEFAULT '[]',
128
+ notes TEXT NOT NULL DEFAULT '[]',
129
+ created_at INTEGER NOT NULL
130
+ );
131
+
132
+ CREATE INDEX IF NOT EXISTS idx_task_status ON task(status);
133
+ CREATE INDEX IF NOT EXISTS idx_message_to ON message(to_agent, read_at);
134
+ CREATE INDEX IF NOT EXISTS idx_event_ts ON event(ts);
135
+ `;
136
+ /**
137
+ * Do two declared paths collide? Same file (a lease's "::symbol" suffix is
138
+ * ignored — two tasks in one file still fight over it at merge), or one is a
139
+ * directory holding the other.
140
+ */
141
+ export function pathsOverlap(a, b) {
142
+ const norm = (p) => p.split("::")[0].trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
143
+ const x = norm(a);
144
+ const y = norm(b);
145
+ if (!x || !y)
146
+ return false;
147
+ return x === y || x.startsWith(y + "/") || y.startsWith(x + "/");
148
+ }
149
+ /** A declared path with forward slashes ("src\\a.ts" → "src/a.ts"). */
150
+ const slashed = (p) => String(p).replace(/\\/g, "/");
151
+ const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
152
+ /** Human-typable one-time code: 4-4-4 from an unambiguous alphabet. */
153
+ function inviteCode() {
154
+ const A = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
155
+ const b = crypto.randomBytes(12);
156
+ const s = [...b].map((x) => A[x % A.length]).join("");
157
+ return `${s.slice(0, 4)}-${s.slice(4, 8)}-${s.slice(8, 12)}`;
158
+ }
159
+ export class RoomDB {
160
+ file;
161
+ db;
162
+ listeners = new Set();
163
+ constructor(file) {
164
+ this.file = file;
165
+ this.db = openDatabase(file);
166
+ this.db.pragma("journal_mode = WAL");
167
+ this.db.pragma("foreign_keys = ON");
168
+ this.db.exec(SCHEMA);
169
+ // Additive migrations for rooms created by older versions.
170
+ for (const sql of [
171
+ "ALTER TABLE invite ADD COLUMN github TEXT",
172
+ "ALTER TABLE member ADD COLUMN github TEXT",
173
+ "ALTER TABLE task ADD COLUMN files TEXT NOT NULL DEFAULT '[]'",
174
+ "ALTER TABLE message ADD COLUMN delivered_how TEXT",
175
+ ]) {
176
+ try {
177
+ this.db.exec(sql);
178
+ }
179
+ catch {
180
+ /* column already exists */
181
+ }
182
+ }
183
+ }
184
+ /** Where the db file is (the shared screen keeps its files next to it). */
185
+ get path() {
186
+ return this.file;
187
+ }
188
+ /** The raw connection, for Room modules that keep their own tables (context.ts). */
189
+ get sql() {
190
+ return this.db;
191
+ }
192
+ /** Subscribe to every event written to the Room (drives WS fan-out). */
193
+ onEvent(fn) {
194
+ this.listeners.add(fn);
195
+ return () => this.listeners.delete(fn);
196
+ }
197
+ emit(type, opts = {}) {
198
+ const e = {
199
+ id: nanoid(10),
200
+ task_id: opts.task_id ?? null,
201
+ agent_id: opts.agent_id ?? null,
202
+ type,
203
+ payload: JSON.stringify(opts.payload ?? {}),
204
+ ts: Date.now(),
205
+ };
206
+ this.db
207
+ .prepare("INSERT INTO event (id, task_id, agent_id, type, payload, ts) VALUES (@id,@task_id,@agent_id,@type,@payload,@ts)")
208
+ .run(e);
209
+ for (const l of this.listeners)
210
+ l(e);
211
+ return e;
212
+ }
213
+ // ---- Session ------------------------------------------------------------
214
+ createSession(goal) {
215
+ const s = { id: nanoid(8), goal, status: "open", created_at: Date.now() };
216
+ this.db
217
+ .prepare("INSERT INTO session (id, goal, status, created_at) VALUES (@id,@goal,@status,@created_at)")
218
+ .run(s);
219
+ this.emit("session.created", { payload: s });
220
+ return s;
221
+ }
222
+ currentSession() {
223
+ return this.db
224
+ // rowid breaks a same-millisecond tie: the session created last is current.
225
+ .prepare("SELECT * FROM session WHERE status='open' ORDER BY created_at DESC, rowid DESC LIMIT 1")
226
+ .get();
227
+ }
228
+ // ---- Agents -------------------------------------------------------------
229
+ upsertAgent(a) {
230
+ const now = Date.now();
231
+ this.db
232
+ .prepare(`INSERT INTO agent (id, vendor, model, role, status, budget_cents, updated_at)
233
+ VALUES (@id,@vendor,@model,@role,'idle',@budget_cents,@now)
234
+ ON CONFLICT(id) DO UPDATE SET vendor=@vendor, model=@model, role=@role, updated_at=@now`)
235
+ .run({ ...a, budget_cents: a.budget_cents ?? 0, now });
236
+ this.emit("agent.joined", { agent_id: a.id, payload: a });
237
+ return this.getAgent(a.id);
238
+ }
239
+ getAgent(id) {
240
+ return this.db.prepare("SELECT * FROM agent WHERE id=?").get(id);
241
+ }
242
+ listAgents() {
243
+ return this.db.prepare("SELECT * FROM agent ORDER BY id").all();
244
+ }
245
+ setAgentStatus(id, status, worktree_path) {
246
+ this.db
247
+ .prepare("UPDATE agent SET status=?, worktree_path=COALESCE(?, worktree_path), updated_at=? WHERE id=?")
248
+ .run(status, worktree_path ?? null, Date.now(), id);
249
+ this.emit("agent.status", { agent_id: id, payload: { status } });
250
+ }
251
+ // ---- Tasks --------------------------------------------------------------
252
+ addTask(t) {
253
+ const now = Date.now();
254
+ const task = {
255
+ id: nanoid(6),
256
+ title: t.title,
257
+ spec: t.spec ?? "",
258
+ status: "backlog",
259
+ claimed_by: null,
260
+ progress_pct: 0,
261
+ depends_on: JSON.stringify(t.depends_on ?? []),
262
+ prefers: JSON.stringify(t.prefers ?? []),
263
+ files: JSON.stringify(t.files ?? []),
264
+ est_seconds: t.est_seconds ?? null,
265
+ created_at: now,
266
+ updated_at: now,
267
+ };
268
+ this.db
269
+ .prepare(`INSERT INTO task (id,title,spec,status,claimed_by,progress_pct,depends_on,prefers,files,est_seconds,created_at,updated_at)
270
+ VALUES (@id,@title,@spec,@status,@claimed_by,@progress_pct,@depends_on,@prefers,@files,@est_seconds,@created_at,@updated_at)`)
271
+ .run(task);
272
+ this.emit("task.added", { task_id: task.id, payload: task });
273
+ return task;
274
+ }
275
+ listTasks() {
276
+ return this.db.prepare("SELECT * FROM task ORDER BY created_at").all();
277
+ }
278
+ getTask(id) {
279
+ return this.db.prepare("SELECT * FROM task WHERE id=?").get(id);
280
+ }
281
+ /** Atomic claim: only succeeds if the task is still in backlog. Race-safe. */
282
+ claimTask(taskId, agentId) {
283
+ const claim = this.db.transaction(() => {
284
+ const t = this.getTask(taskId);
285
+ if (!t)
286
+ return { ok: false, reason: "no such task" };
287
+ if (t.status !== "backlog")
288
+ return { ok: false, reason: `already ${t.status}` };
289
+ const deps = JSON.parse(t.depends_on);
290
+ const unmet = deps.filter((d) => this.getTask(d)?.status !== "merged");
291
+ if (unmet.length)
292
+ return { ok: false, reason: `blocked on ${unmet.join(", ")}` };
293
+ this.db
294
+ .prepare("UPDATE task SET status='claimed', claimed_by=?, updated_at=? WHERE id=?")
295
+ .run(agentId, Date.now(), taskId);
296
+ return { ok: true };
297
+ });
298
+ const res = claim();
299
+ if (res.ok) {
300
+ this.setAgentStatus(agentId, "working");
301
+ this.emit("task.claimed", { task_id: taskId, agent_id: agentId });
302
+ return { ok: true, task: this.getTask(taskId) };
303
+ }
304
+ return res;
305
+ }
306
+ /**
307
+ * Start a fresh board for a new goal. Tasks, plans and file claims are
308
+ * per-goal; shared memory is kept — what agents learned carries forward.
309
+ */
310
+ clearBoard() {
311
+ this.db.exec("DELETE FROM task; DELETE FROM plan; DELETE FROM file_claim; UPDATE agent SET status='offline';");
312
+ // Handoffs are keyed by this board's task ids; unread messages were about the old goal.
313
+ this.db.prepare("DELETE FROM handoff").run();
314
+ this.db.prepare("UPDATE message SET read_at=?, delivered_how='cleared' WHERE read_at IS NULL").run(Date.now());
315
+ this.emit("board.cleared", {});
316
+ }
317
+ /** Put a claimed task back on the board (a teammate declined or left). */
318
+ releaseTask(id) {
319
+ this.db.prepare("UPDATE task SET status='backlog', claimed_by=NULL, progress_pct=0, updated_at=? WHERE id=?").run(Date.now(), id);
320
+ this.emit("task.status", { task_id: id, payload: { status: "backlog" } });
321
+ }
322
+ /** Another agent takes over a task in flight (a rescue): it's theirs now, status unchanged until they start. */
323
+ reassignTask(id, agentId) {
324
+ this.db.prepare("UPDATE task SET claimed_by=?, updated_at=? WHERE id=?").run(agentId, Date.now(), id);
325
+ }
326
+ /** Public emit for Room-level events (asks, answers, assignments). */
327
+ publish(type, opts = {}) {
328
+ this.emit(type, opts);
329
+ }
330
+ setTaskStatus(id, status) {
331
+ this.db.prepare("UPDATE task SET status=?, updated_at=? WHERE id=?").run(status, Date.now(), id);
332
+ this.emit("task.status", { task_id: id, payload: { status } });
333
+ }
334
+ setProgress(id, pct, note = "") {
335
+ const clamped = Math.max(0, Math.min(100, Math.round(pct)));
336
+ this.db
337
+ .prepare("UPDATE task SET progress_pct=?, status=CASE WHEN status='claimed' THEN 'in_progress' ELSE status END, updated_at=? WHERE id=?")
338
+ .run(clamped, Date.now(), id);
339
+ this.emit("task.progress", { task_id: id, payload: { pct: clamped, note } });
340
+ }
341
+ /**
342
+ * The highest-value unblocked backlog task, optionally biased to an agent's
343
+ * strengths. A task whose files overlap work in flight (another task's
344
+ * declared files, or a live lease from huddle_announce_plan) waits its turn:
345
+ * prevention is cheaper than resolving the conflict afterwards.
346
+ */
347
+ nextBacklogTask(prefers = []) {
348
+ const busy = this.busyFiles();
349
+ const backlog = this.db.prepare("SELECT * FROM task WHERE status='backlog'").all().filter((t) => JSON.parse(t.depends_on).every((d) => this.getTask(d)?.status === "merged") &&
350
+ !JSON.parse(t.files ?? "[]").some((f) => busy.some((b) => pathsOverlap(f, b))));
351
+ if (!backlog.length)
352
+ return undefined;
353
+ const score = (t) => {
354
+ const tags = JSON.parse(t.prefers);
355
+ return tags.filter((x) => prefers.includes(x)).length;
356
+ };
357
+ backlog.sort((a, b) => score(b) - score(a) || a.created_at - b.created_at);
358
+ return backlog[0];
359
+ }
360
+ // ---- Plans & overlap ----------------------------------------------------
361
+ announcePlan(p) {
362
+ // An agent on Windows may say src\a.ts; leases are kept as src/a.ts.
363
+ p = { ...p, touches: p.touches.map(slashed) };
364
+ const conflicts = this.checkOverlap(p.touches, p.agent_id);
365
+ const plan = {
366
+ id: nanoid(8),
367
+ task_id: p.task_id,
368
+ agent_id: p.agent_id,
369
+ intent: p.intent,
370
+ touches: JSON.stringify(p.touches),
371
+ created_at: Date.now(),
372
+ };
373
+ this.db
374
+ .prepare("INSERT INTO plan (id,task_id,agent_id,intent,touches,created_at) VALUES (@id,@task_id,@agent_id,@intent,@touches,@created_at)")
375
+ .run(plan);
376
+ // Take soft leases on everything not already conflicting.
377
+ const expires = Date.now() + 30 * 60 * 1000;
378
+ const held = new Set(conflicts.map((c) => c.path));
379
+ for (const path of p.touches) {
380
+ if (held.has(path))
381
+ continue;
382
+ this.db
383
+ .prepare("INSERT INTO file_claim (path,held_by,task_id,expires_at) VALUES (?,?,?,?) ON CONFLICT(path) DO NOTHING")
384
+ .run(path, p.agent_id, p.task_id, expires);
385
+ }
386
+ this.emit("plan.announced", { task_id: p.task_id, agent_id: p.agent_id, payload: { intent: p.intent, touches: p.touches, conflicts } });
387
+ return { plan, conflicts };
388
+ }
389
+ /** Which touched paths/symbols are already held by *someone else*. */
390
+ checkOverlap(touches, byAgent) {
391
+ touches = touches.map(slashed);
392
+ if (!touches.length)
393
+ return [];
394
+ const now = Date.now();
395
+ const placeholders = touches.map(() => "?").join(",");
396
+ const rows = this.db
397
+ .prepare(`SELECT * FROM file_claim WHERE path IN (${placeholders}) AND expires_at > ?`)
398
+ .all(...touches, now);
399
+ return rows.filter((r) => r.held_by !== byAgent);
400
+ }
401
+ /** Files in flight right now: declared by claimed/running/merging tasks, or leased by an agent. */
402
+ busyFiles() {
403
+ const active = this.db.prepare("SELECT files FROM task WHERE status IN ('claimed','in_progress','review')").all();
404
+ const leased = this.db.prepare("SELECT path FROM file_claim WHERE expires_at > ?").all(Date.now());
405
+ return [...active.flatMap((r) => JSON.parse(r.files ?? "[]")), ...leased.map((r) => r.path)];
406
+ }
407
+ /** A split part takes these files: they leave the parent's file list and the parent's leases on them end. */
408
+ handOverFiles(parentId, files) {
409
+ const t = this.getTask(parentId);
410
+ if (!t)
411
+ return;
412
+ // A parent entry that is a whole directory around a part's file stays: it can't be split finer.
413
+ const norm = (p) => p.split("::")[0].trim().replace(/^\.\//, "").replace(/\/+$/, "");
414
+ const handed = (f) => files.some((p) => pathsOverlap(f, p) && !norm(p).startsWith(norm(f) + "/"));
415
+ const keep = JSON.parse(t.files ?? "[]").filter((f) => !handed(f));
416
+ this.db.prepare("UPDATE task SET files=?, updated_at=? WHERE id=?").run(JSON.stringify(keep), Date.now(), parentId);
417
+ const leases = this.db.prepare("SELECT path FROM file_claim WHERE task_id=?").all(parentId);
418
+ for (const l of leases)
419
+ if (handed(l.path))
420
+ this.db.prepare("DELETE FROM file_claim WHERE path=? AND task_id=?").run(l.path, parentId);
421
+ }
422
+ releaseClaims(agentId) {
423
+ this.db.prepare("DELETE FROM file_claim WHERE held_by=?").run(agentId);
424
+ }
425
+ // Attach mode (room/live.ts) matches leases by path prefix, so it needs the
426
+ // raw rows and single-lease verbs rather than announcePlan's exact match.
427
+ /** Every unexpired lease. */
428
+ listClaims() {
429
+ return this.db.prepare("SELECT * FROM file_claim WHERE expires_at > ? ORDER BY path").all(Date.now());
430
+ }
431
+ /** Take one lease. False if someone else holds that exact path. */
432
+ leaseClaim(path, holder, taskId, expiresAt) {
433
+ this.db.prepare("DELETE FROM file_claim WHERE path=? AND expires_at <= ?").run(path, Date.now());
434
+ this.db
435
+ .prepare("INSERT INTO file_claim (path,held_by,task_id,expires_at) VALUES (?,?,?,?) ON CONFLICT(path) DO UPDATE SET expires_at=excluded.expires_at WHERE held_by=excluded.held_by")
436
+ .run(path, holder, taskId, expiresAt);
437
+ const row = this.db.prepare("SELECT held_by FROM file_claim WHERE path=?").get(path);
438
+ return row?.held_by === holder;
439
+ }
440
+ releaseClaim(path, holder) {
441
+ this.db.prepare("DELETE FROM file_claim WHERE path=? AND held_by=?").run(path, holder);
442
+ }
443
+ /** Keep a live holder's leases from expiring. */
444
+ renewClaims(holder, expiresAt) {
445
+ this.db.prepare("UPDATE file_claim SET expires_at=? WHERE held_by=?").run(expiresAt, holder);
446
+ }
447
+ /** Events of some types, oldest first, newer than `sinceTs`. */
448
+ eventsOfType(types, sinceTs = 0, limit = 200) {
449
+ if (!types.length)
450
+ return [];
451
+ const ph = types.map(() => "?").join(",");
452
+ return this.db
453
+ .prepare(`SELECT * FROM event WHERE type IN (${ph}) AND ts > ? ORDER BY ts DESC LIMIT ?`)
454
+ .all(...types, sinceTs, limit).reverse();
455
+ }
456
+ // ---- Members & invites (multiplayer) ------------------------------------
457
+ /** One-time invite. Expires in `ttlMs`; the code is returned once and stored hashed. */
458
+ createInvite(opts = {}) {
459
+ const code = inviteCode();
460
+ const expiresAt = Date.now() + (opts.ttlMs ?? 10 * 60_000);
461
+ this.db
462
+ .prepare("INSERT INTO invite (code_hash, role, for_name, github, expires_at) VALUES (?, ?, ?, ?, ?)")
463
+ .run(sha(code), opts.role ?? "member", opts.forName ?? null, opts.github ?? null, expiresAt);
464
+ return { code, expiresAt };
465
+ }
466
+ /** Look at an invite without using it (to know if it needs a GitHub proof). */
467
+ peekInvite(code) {
468
+ const inv = this.db.prepare("SELECT * FROM invite WHERE code_hash=?").get(sha(code.trim().toUpperCase()));
469
+ if (!inv)
470
+ return { ok: false, reason: "unknown invite code" };
471
+ if (inv.used_by)
472
+ return { ok: false, reason: "that invite was already used" };
473
+ if (inv.expires_at < Date.now())
474
+ return { ok: false, reason: "that invite expired — ask for a new one" };
475
+ return { ok: true, invite: { role: inv.role, github: inv.github ?? null, for_name: inv.for_name ?? null } };
476
+ }
477
+ /** Trade a valid, unused, unexpired invite for a member token. Atomic. */
478
+ redeemInvite(code, name, verifiedGithub = null) {
479
+ const tx = this.db.transaction(() => {
480
+ const inv = this.db.prepare("SELECT * FROM invite WHERE code_hash=?").get(sha(code.trim().toUpperCase()));
481
+ if (!inv)
482
+ return { ok: false, reason: "unknown invite code" };
483
+ if (inv.used_by)
484
+ return { ok: false, reason: "that invite was already used" };
485
+ if (inv.expires_at < Date.now())
486
+ return { ok: false, reason: "that invite expired — ask for a new one" };
487
+ if (inv.github && inv.github.toLowerCase() !== (verifiedGithub ?? "").toLowerCase())
488
+ return { ok: false, reason: `this invite is for @${inv.github} — prove it with your GitHub SSH key` };
489
+ const token = crypto.randomBytes(32).toString("hex");
490
+ const member = {
491
+ id: nanoid(8),
492
+ // A verified GitHub login is the name; otherwise what they told us.
493
+ name: (inv.github ?? name ?? inv.for_name ?? "guest").slice(0, 40) || "guest",
494
+ role: inv.role,
495
+ github: inv.github ?? null,
496
+ joined_at: Date.now(),
497
+ left_at: null,
498
+ };
499
+ this.db
500
+ .prepare("INSERT INTO member (id, name, role, github, token_hash, joined_at) VALUES (?, ?, ?, ?, ?, ?)")
501
+ .run(member.id, member.name, member.role, member.github, sha(token), member.joined_at);
502
+ this.db.prepare("UPDATE invite SET used_by=? WHERE code_hash=?").run(member.id, inv.code_hash);
503
+ return { ok: true, token, member };
504
+ });
505
+ const r = tx();
506
+ if (r.ok)
507
+ this.emit("member.joined", { payload: { id: r.member.id, name: r.member.name, role: r.member.role, github: r.member.github } });
508
+ return r;
509
+ }
510
+ /** The active member a token belongs to, if any. Left members never match. */
511
+ memberByToken(token) {
512
+ if (!token)
513
+ return undefined;
514
+ return this.db.prepare("SELECT id, name, role, github, joined_at, left_at FROM member WHERE token_hash=? AND left_at IS NULL").get(sha(token));
515
+ }
516
+ listMembers() {
517
+ return this.db.prepare("SELECT id, name, role, github, joined_at, left_at FROM member WHERE left_at IS NULL ORDER BY joined_at").all();
518
+ }
519
+ /** Leave (or be removed): instant, and the token stops working immediately. */
520
+ removeMember(id, reason = "left") {
521
+ const m = this.db.prepare("SELECT id, name, role, github, joined_at, left_at FROM member WHERE id=? AND left_at IS NULL").get(id);
522
+ if (!m)
523
+ return undefined;
524
+ this.db.prepare("UPDATE member SET left_at=? WHERE id=?").run(Date.now(), id);
525
+ this.emit("member.left", { payload: { id: m.id, name: m.name, reason } });
526
+ return m;
527
+ }
528
+ /** A message in the room. Always data for readers — never instructions to agents. */
529
+ postMessage(from, body) {
530
+ this.emit("message", { payload: { from: from.name, from_id: from.id, body: body.slice(0, 2000) } });
531
+ }
532
+ // ---- Agent-to-agent messages --------------------------------------------
533
+ /** Drop a message in `to`'s inbox. Data for the reader — never instructions to Huddle. */
534
+ sendMessage(m) {
535
+ const msg = {
536
+ id: nanoid(8),
537
+ from_agent: m.from,
538
+ to_agent: m.to,
539
+ body: m.body.slice(0, 4000),
540
+ kind: m.kind ?? "note",
541
+ reply_to: m.reply_to ?? null,
542
+ task_id: m.task_id ?? null,
543
+ created_at: Date.now(),
544
+ read_at: null,
545
+ };
546
+ this.db
547
+ .prepare(`INSERT INTO message (id,from_agent,to_agent,body,kind,reply_to,task_id,created_at,read_at)
548
+ VALUES (@id,@from_agent,@to_agent,@body,@kind,@reply_to,@task_id,@created_at,@read_at)`)
549
+ .run(msg);
550
+ this.emit("agent.message", {
551
+ task_id: msg.task_id,
552
+ agent_id: msg.from_agent,
553
+ payload: { id: msg.id, from: msg.from_agent, to: msg.to_agent, body: msg.body, kind: msg.kind, reply_to: msg.reply_to },
554
+ });
555
+ return msg;
556
+ }
557
+ getMessage(id) {
558
+ return this.db.prepare("SELECT * FROM message WHERE id=?").get(id);
559
+ }
560
+ /**
561
+ * Unread messages for `agent`, oldest first (at most `limit`). Reading marks
562
+ * them read, recording `how` they were delivered, unless `peek`.
563
+ */
564
+ inbox(agent, opts = {}) {
565
+ const rows = this.db
566
+ .prepare("SELECT * FROM message WHERE to_agent=? AND read_at IS NULL ORDER BY created_at, rowid LIMIT ?")
567
+ .all(agent, opts.limit ?? -1);
568
+ if (!opts.peek)
569
+ for (const r of rows)
570
+ this.markRead(r.id, opts.how ?? "inbox");
571
+ return rows;
572
+ }
573
+ /** Messages `agent` already got in a tool result or hook since `sinceTs`, newest last. */
574
+ recentlyDelivered(agent, sinceTs, limit = 3) {
575
+ const rows = this.db
576
+ .prepare(`SELECT * FROM message WHERE to_agent=? AND read_at >= ? AND delivered_how IN ('piggyback','hook')
577
+ ORDER BY read_at DESC, rowid DESC LIMIT ?`)
578
+ .all(agent, sinceTs, limit);
579
+ return rows.reverse();
580
+ }
581
+ /** Every message since `sinceTs` with when/how it was read — the input to delivery metrics. */
582
+ messagesSince(sinceTs = 0) {
583
+ return this.db.prepare("SELECT * FROM message WHERE created_at >= ? ORDER BY created_at, rowid").all(sinceTs);
584
+ }
585
+ /** When each agent called huddle_done (oldest first). */
586
+ doneTimes(sinceTs = 0) {
587
+ return this.db.prepare("SELECT * FROM done_log WHERE ts >= ? ORDER BY ts, rowid").all(sinceTs);
588
+ }
589
+ /** Unread count and senders — the piggyback nudge on every tool result. */
590
+ unread(agent) {
591
+ const rows = this.db.prepare("SELECT from_agent FROM message WHERE to_agent=? AND read_at IS NULL").all(agent);
592
+ return { count: rows.length, from: [...new Set(rows.map((r) => r.from_agent))] };
593
+ }
594
+ markRead(id, how = "inbox") {
595
+ this.db.prepare("UPDATE message SET read_at=?, delivered_how=? WHERE id=? AND read_at IS NULL").run(Date.now(), how, id);
596
+ }
597
+ /**
598
+ * The reply to a routed question: an explicit reply_to, or — since models
599
+ * forget ids — the next plain message from the person asked back to the asker.
600
+ */
601
+ findAnswer(q) {
602
+ return this.db
603
+ .prepare(`SELECT * FROM message WHERE kind != 'question' AND (reply_to=@id OR
604
+ (reply_to IS NULL AND from_agent=@to AND to_agent=@from AND created_at >= @at))
605
+ ORDER BY created_at, rowid LIMIT 1`)
606
+ .get({ id: q.id, to: q.to_agent, from: q.from_agent, at: q.created_at });
607
+ }
608
+ // ---- Handoffs -------------------------------------------------------------
609
+ /** huddle_done's summary, kept for the handoff the merge will write. */
610
+ noteDone(taskId, agentId, summary) {
611
+ this.db.prepare("INSERT INTO done_log (agent_id, task_id, ts) VALUES (?, ?, ?)").run(agentId, taskId, Date.now());
612
+ this.db
613
+ .prepare(`INSERT INTO handoff (task_id, agent_id, title, summary, created_at) VALUES (?, ?, COALESCE((SELECT title FROM task WHERE id=?), ''), ?, ?)
614
+ ON CONFLICT(task_id) DO UPDATE SET summary=excluded.summary, agent_id=excluded.agent_id`)
615
+ .run(taskId, agentId, taskId, summary.slice(0, 2000), Date.now());
616
+ }
617
+ saveHandoff(h) {
618
+ const prev = this.getHandoff(h.task_id);
619
+ const row = {
620
+ task_id: h.task_id,
621
+ agent_id: h.agent_id,
622
+ title: h.title,
623
+ summary: h.summary ?? prev?.summary ?? "",
624
+ files: JSON.stringify(h.files),
625
+ symbols: JSON.stringify(h.symbols),
626
+ notes: JSON.stringify(h.notes),
627
+ created_at: Date.now(),
628
+ };
629
+ this.db
630
+ .prepare(`INSERT OR REPLACE INTO handoff (task_id, agent_id, title, summary, files, symbols, notes, created_at)
631
+ VALUES (@task_id, @agent_id, @title, @summary, @files, @symbols, @notes, @created_at)`)
632
+ .run(row);
633
+ this.emit("handoff.ready", { task_id: h.task_id, agent_id: h.agent_id, payload: { files: h.files.length, symbols: h.symbols.length } });
634
+ return this.getHandoff(h.task_id);
635
+ }
636
+ getHandoff(taskId) {
637
+ const r = this.db.prepare("SELECT * FROM handoff WHERE task_id=?").get(taskId);
638
+ if (!r)
639
+ return undefined;
640
+ return { ...r, files: JSON.parse(r.files), symbols: JSON.parse(r.symbols), notes: JSON.parse(r.notes) };
641
+ }
642
+ // ---- Shared memory ------------------------------------------------------
643
+ remember(m) {
644
+ const mem = {
645
+ id: nanoid(8),
646
+ kind: m.kind,
647
+ body: m.body,
648
+ why: m.why ?? "",
649
+ author: m.author,
650
+ created_at: Date.now(),
651
+ };
652
+ this.db
653
+ .prepare("INSERT INTO memory (id,kind,body,why,author,created_at) VALUES (@id,@kind,@body,@why,@author,@created_at)")
654
+ .run(mem);
655
+ this.emit("memory.added", { agent_id: m.author, payload: mem });
656
+ // A lesson outlives the room: <repo>/.huddle/huddle.db → that repo's lessons.
657
+ if (m.kind === "lesson")
658
+ rememberLesson({ body: m.body, why: m.why, author: m.author, root: nodePath.basename(nodePath.dirname(this.file)) === ".huddle" ? nodePath.dirname(nodePath.dirname(this.file)) : null });
659
+ return mem;
660
+ }
661
+ recall(query) {
662
+ const all = this.db.prepare("SELECT * FROM memory ORDER BY created_at DESC").all();
663
+ if (!query)
664
+ return all;
665
+ const q = query.toLowerCase();
666
+ return all.filter((m) => (m.body + " " + m.why).toLowerCase().includes(q));
667
+ }
668
+ // ---- Events -------------------------------------------------------------
669
+ recentEvents(limit = 50) {
670
+ return this.db.prepare("SELECT * FROM event ORDER BY ts DESC LIMIT ?").all(limit);
671
+ }
672
+ /** Every event, oldest first (insertion order breaks ties) — `huddle story` replays runs from these. */
673
+ allEvents() {
674
+ return this.db.prepare("SELECT * FROM event ORDER BY ts, rowid").all();
675
+ }
676
+ close() {
677
+ this.db.close();
678
+ }
679
+ }
680
+ //# sourceMappingURL=db.js.map