multiagents 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/broker.ts ADDED
@@ -0,0 +1,1263 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * multiagents broker daemon
4
+ *
5
+ * A singleton HTTP server on localhost backed by SQLite.
6
+ * Tracks registered agent peers, routes messages, manages sessions,
7
+ * file locks, ownership zones, and guardrails.
8
+ *
9
+ * Auto-launched by the MCP server if not already running.
10
+ * Run directly: bun broker.ts
11
+ */
12
+
13
+ import { Database } from "bun:sqlite";
14
+ import { normalize } from "node:path";
15
+ import {
16
+ DEFAULT_BROKER_PORT,
17
+ DEFAULT_DB_PATH,
18
+ DEFAULT_LOCK_TIMEOUT_MS,
19
+ MAX_LOCK_TIMEOUT_MS,
20
+ RECONNECT_RECAP_LIMIT,
21
+ DEFAULT_GUARDRAILS,
22
+ CLEANUP_INTERVAL,
23
+ } from "./shared/constants.ts";
24
+ import { generatePeerId } from "./shared/utils.ts";
25
+ import type {
26
+ RegisterRequest,
27
+ RegisterResponse,
28
+ HeartbeatRequest,
29
+ SetSummaryRequest,
30
+ ListPeersRequest,
31
+ SendMessageRequest,
32
+ SendMessageResult,
33
+ PollMessagesRequest,
34
+ PollMessagesResponse,
35
+ SetRoleRequest,
36
+ RenamePeerRequest,
37
+ CreateSessionRequest,
38
+ UpdateSessionRequest,
39
+ CreateSlotRequest,
40
+ UpdateSlotRequest,
41
+ AcquireFileRequest,
42
+ AcquireFileResult,
43
+ ReleaseFileRequest,
44
+ AssignOwnershipRequest,
45
+ UpdateGuardrailRequest,
46
+ MessageLogOptions,
47
+ Peer,
48
+ Message,
49
+ Session,
50
+ Slot,
51
+ FileLock,
52
+ FileOwnership,
53
+ AgentType,
54
+ SlotCandidate,
55
+ } from "./shared/types.ts";
56
+
57
+ // --- Configuration ---
58
+
59
+ const PORT = parseInt(process.env.MULTIAGENTS_PORT ?? String(DEFAULT_BROKER_PORT), 10);
60
+
61
+ const DB_PATH = process.env.MULTIAGENTS_DB ?? DEFAULT_DB_PATH;
62
+
63
+ // Ensure parent directory exists
64
+ const dbDir = DB_PATH.substring(0, DB_PATH.lastIndexOf("/"));
65
+ if (dbDir) {
66
+ try {
67
+ const { mkdirSync } = require("node:fs");
68
+ mkdirSync(dbDir, { recursive: true });
69
+ } catch {}
70
+ }
71
+
72
+ // --- Database setup ---
73
+
74
+ const db = new Database(DB_PATH);
75
+ db.run("PRAGMA journal_mode = WAL");
76
+ db.run("PRAGMA busy_timeout = 3000");
77
+
78
+ // Original tables
79
+ db.run(`
80
+ CREATE TABLE IF NOT EXISTS peers (
81
+ id TEXT PRIMARY KEY,
82
+ pid INTEGER NOT NULL,
83
+ cwd TEXT NOT NULL,
84
+ git_root TEXT,
85
+ tty TEXT,
86
+ summary TEXT NOT NULL DEFAULT '',
87
+ registered_at TEXT NOT NULL,
88
+ last_seen TEXT NOT NULL
89
+ )
90
+ `);
91
+
92
+ db.run(`
93
+ CREATE TABLE IF NOT EXISTS messages (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ from_id TEXT NOT NULL,
96
+ to_id TEXT NOT NULL,
97
+ text TEXT NOT NULL,
98
+ sent_at TEXT NOT NULL,
99
+ delivered INTEGER NOT NULL DEFAULT 0,
100
+ FOREIGN KEY (from_id) REFERENCES peers(id),
101
+ FOREIGN KEY (to_id) REFERENCES peers(id)
102
+ )
103
+ `);
104
+
105
+ // Add new columns to existing tables (idempotent via try/catch)
106
+ const alterStatements = [
107
+ "ALTER TABLE peers ADD COLUMN session_id TEXT",
108
+ "ALTER TABLE peers ADD COLUMN slot_id INTEGER",
109
+ "ALTER TABLE peers ADD COLUMN agent_type TEXT DEFAULT 'claude'",
110
+ "ALTER TABLE peers ADD COLUMN status TEXT DEFAULT 'idle'",
111
+ "ALTER TABLE messages ADD COLUMN session_id TEXT",
112
+ "ALTER TABLE messages ADD COLUMN from_slot_id INTEGER",
113
+ "ALTER TABLE messages ADD COLUMN to_slot_id INTEGER",
114
+ "ALTER TABLE messages ADD COLUMN msg_type TEXT DEFAULT 'chat'",
115
+ "ALTER TABLE messages ADD COLUMN delivered_at TEXT",
116
+ "ALTER TABLE messages ADD COLUMN held INTEGER DEFAULT 0",
117
+ "ALTER TABLE slots ADD COLUMN task_state TEXT DEFAULT 'idle'",
118
+ "ALTER TABLE slots ADD COLUMN input_tokens INTEGER DEFAULT 0",
119
+ "ALTER TABLE slots ADD COLUMN output_tokens INTEGER DEFAULT 0",
120
+ "ALTER TABLE slots ADD COLUMN cache_read_tokens INTEGER DEFAULT 0",
121
+ ];
122
+
123
+ for (const stmt of alterStatements) {
124
+ try {
125
+ db.run(stmt);
126
+ } catch {
127
+ // Column already exists — ignore
128
+ }
129
+ }
130
+
131
+ // New tables
132
+ db.run(`
133
+ CREATE TABLE IF NOT EXISTS sessions (
134
+ id TEXT PRIMARY KEY,
135
+ name TEXT NOT NULL,
136
+ project_dir TEXT NOT NULL,
137
+ git_root TEXT,
138
+ status TEXT DEFAULT 'active',
139
+ pause_reason TEXT,
140
+ paused_at INTEGER,
141
+ config TEXT DEFAULT '{}',
142
+ created_at INTEGER NOT NULL,
143
+ last_active_at INTEGER NOT NULL
144
+ )
145
+ `);
146
+
147
+ db.run(`
148
+ CREATE TABLE IF NOT EXISTS slots (
149
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
150
+ session_id TEXT NOT NULL REFERENCES sessions(id),
151
+ agent_type TEXT NOT NULL,
152
+ display_name TEXT,
153
+ role TEXT,
154
+ role_description TEXT,
155
+ role_assigned_by TEXT,
156
+ peer_id TEXT,
157
+ status TEXT DEFAULT 'disconnected',
158
+ paused INTEGER DEFAULT 0,
159
+ paused_at INTEGER,
160
+ task_state TEXT DEFAULT 'idle',
161
+ last_peer_pid INTEGER,
162
+ last_connected INTEGER,
163
+ last_disconnected INTEGER,
164
+ context_snapshot TEXT
165
+ )
166
+ `);
167
+
168
+ db.run(`
169
+ CREATE TABLE IF NOT EXISTS file_locks (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ session_id TEXT NOT NULL,
172
+ file_path TEXT NOT NULL,
173
+ held_by_slot INTEGER NOT NULL,
174
+ held_by_peer TEXT NOT NULL,
175
+ acquired_at INTEGER NOT NULL,
176
+ expires_at INTEGER NOT NULL,
177
+ lock_type TEXT DEFAULT 'exclusive',
178
+ purpose TEXT,
179
+ UNIQUE(session_id, file_path)
180
+ )
181
+ `);
182
+
183
+ db.run(`
184
+ CREATE TABLE IF NOT EXISTS file_ownership (
185
+ session_id TEXT NOT NULL,
186
+ slot_id INTEGER NOT NULL,
187
+ path_pattern TEXT NOT NULL,
188
+ assigned_at INTEGER NOT NULL,
189
+ assigned_by TEXT,
190
+ PRIMARY KEY (session_id, path_pattern)
191
+ )
192
+ `);
193
+
194
+ db.run(`
195
+ CREATE TABLE IF NOT EXISTS guardrail_overrides (
196
+ session_id TEXT NOT NULL,
197
+ guardrail_id TEXT NOT NULL,
198
+ value REAL NOT NULL,
199
+ changed_at INTEGER NOT NULL,
200
+ changed_by TEXT,
201
+ reason TEXT,
202
+ PRIMARY KEY (session_id, guardrail_id)
203
+ )
204
+ `);
205
+
206
+ db.run(`
207
+ CREATE TABLE IF NOT EXISTS guardrail_events (
208
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
209
+ session_id TEXT NOT NULL,
210
+ guardrail_id TEXT NOT NULL,
211
+ event_type TEXT NOT NULL,
212
+ current_usage REAL,
213
+ limit_value REAL,
214
+ slot_id INTEGER,
215
+ timestamp INTEGER NOT NULL,
216
+ metadata TEXT
217
+ )
218
+ `);
219
+
220
+ db.run(`
221
+ CREATE TABLE IF NOT EXISTS plans (
222
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
223
+ session_id TEXT NOT NULL,
224
+ title TEXT NOT NULL,
225
+ created_at INTEGER NOT NULL,
226
+ updated_at INTEGER NOT NULL
227
+ )
228
+ `);
229
+
230
+ db.run(`
231
+ CREATE TABLE IF NOT EXISTS plan_items (
232
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ plan_id INTEGER NOT NULL REFERENCES plans(id),
234
+ parent_id INTEGER,
235
+ label TEXT NOT NULL,
236
+ status TEXT DEFAULT 'pending',
237
+ assigned_to_slot INTEGER,
238
+ completed_at INTEGER,
239
+ sort_order INTEGER DEFAULT 0
240
+ )
241
+ `);
242
+
243
+ // --- Stale peer cleanup ---
244
+
245
+ function cleanStalePeers() {
246
+ const peers = db.query("SELECT id, pid, summary, cwd FROM peers").all() as { id: string; pid: number; summary: string; cwd: string }[];
247
+ const now = Date.now();
248
+
249
+ for (const peer of peers) {
250
+ try {
251
+ process.kill(peer.pid, 0);
252
+ } catch {
253
+ // Disconnect slots for this peer (capture snapshot before deleting peer)
254
+ const slots = db.query("SELECT id FROM slots WHERE peer_id = ?").all(peer.id) as any[];
255
+ for (const slot of slots) {
256
+ const snapshot = JSON.stringify({
257
+ last_summary: peer.summary ?? null,
258
+ last_status: "disconnected",
259
+ last_cwd: peer.cwd ?? null,
260
+ });
261
+ db.run(
262
+ "UPDATE slots SET status = 'disconnected', peer_id = NULL, last_disconnected = ?, context_snapshot = ? WHERE id = ?",
263
+ [now, snapshot, slot.id]
264
+ );
265
+ }
266
+
267
+ // Process dead — clean up peer (after snapshot capture)
268
+ db.run("DELETE FROM peers WHERE id = ?", [peer.id]);
269
+ db.run("DELETE FROM messages WHERE to_id = ? AND delivered = 0", [peer.id]);
270
+
271
+ // Release file locks held by this peer
272
+ db.run("DELETE FROM file_locks WHERE held_by_peer = ?", [peer.id]);
273
+ }
274
+ }
275
+
276
+ // Clean expired file locks
277
+ db.run("DELETE FROM file_locks WHERE expires_at < ?", [now]);
278
+ }
279
+
280
+ cleanStalePeers();
281
+ setInterval(cleanStalePeers, CLEANUP_INTERVAL);
282
+
283
+ // --- Prepared statements ---
284
+
285
+ const insertPeer = db.prepare(`
286
+ INSERT INTO peers (id, pid, cwd, git_root, tty, summary, registered_at, last_seen, session_id, slot_id, agent_type, status)
287
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
288
+ `);
289
+
290
+ const updateLastSeen = db.prepare(
291
+ "UPDATE peers SET last_seen = ? WHERE id = ?"
292
+ );
293
+
294
+ const updateSummary = db.prepare(
295
+ "UPDATE peers SET summary = ? WHERE id = ?"
296
+ );
297
+
298
+ const deletePeer = db.prepare(
299
+ "DELETE FROM peers WHERE id = ?"
300
+ );
301
+
302
+ const selectAllPeers = db.prepare(
303
+ "SELECT * FROM peers"
304
+ );
305
+
306
+ const selectPeersByDirectory = db.prepare(
307
+ "SELECT * FROM peers WHERE cwd = ?"
308
+ );
309
+
310
+ const selectPeersByGitRoot = db.prepare(
311
+ "SELECT * FROM peers WHERE git_root = ?"
312
+ );
313
+
314
+ const insertMessage = db.prepare(`
315
+ INSERT INTO messages (from_id, to_id, text, sent_at, delivered, session_id, from_slot_id, to_slot_id, msg_type, held)
316
+ VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
317
+ `);
318
+
319
+ const selectUndelivered = db.prepare(
320
+ "SELECT * FROM messages WHERE to_id = ? AND delivered = 0 AND held = 0 ORDER BY sent_at ASC"
321
+ );
322
+
323
+ const markDelivered = db.prepare(
324
+ "UPDATE messages SET delivered = 1, delivered_at = ? WHERE id = ?"
325
+ );
326
+
327
+ // --- Request handlers ---
328
+
329
+ function handleRegister(body: RegisterRequest): RegisterResponse {
330
+ const agentType: AgentType = body.agent_type ?? "claude";
331
+ const id = generatePeerId(agentType);
332
+ const now = new Date().toISOString();
333
+ const nowMs = Date.now();
334
+
335
+ // Remove any existing registration for this PID
336
+ const existing = db.query("SELECT id FROM peers WHERE pid = ?").get(body.pid) as { id: string } | null;
337
+ if (existing) {
338
+ deletePeer.run(existing.id);
339
+ }
340
+
341
+ // Explicit slot targeting — when orchestrator passes a specific slot_id
342
+ if (body.slot_id && body.session_id) {
343
+ const targetSlot = db.query(
344
+ "SELECT * FROM slots WHERE id = ? AND session_id = ?"
345
+ ).get(body.slot_id, body.session_id) as Slot | null;
346
+
347
+ if (targetSlot) {
348
+ insertPeer.run(id, body.pid, body.cwd, body.git_root, body.tty, body.summary, now, now, body.session_id, targetSlot.id, agentType, "idle");
349
+ db.run(
350
+ "UPDATE slots SET peer_id = ?, status = 'connected', last_connected = ?, last_peer_pid = ? WHERE id = ?",
351
+ [id, nowMs, body.pid, targetSlot.id]
352
+ );
353
+ const recap = db.query(
354
+ "SELECT * FROM messages WHERE session_id = ? AND to_slot_id = ? ORDER BY sent_at DESC LIMIT ?"
355
+ ).all(body.session_id, targetSlot.id, RECONNECT_RECAP_LIMIT) as Message[];
356
+ recap.reverse();
357
+ const updatedSlot = db.query("SELECT * FROM slots WHERE id = ?").get(targetSlot.id) as Slot;
358
+ return { id, slot: updatedSlot, recap };
359
+ }
360
+ }
361
+
362
+ // Reconnect logic — match by role first, then by agent_type
363
+ if (body.reconnect && body.session_id) {
364
+ let disconnectedSlots = db.query(
365
+ "SELECT * FROM slots WHERE session_id = ? AND agent_type = ? AND status = 'disconnected'"
366
+ ).all(body.session_id, agentType) as Slot[];
367
+
368
+ // If multiple matches and a role is provided, narrow by role
369
+ if (disconnectedSlots.length > 1 && body.role) {
370
+ const roleMatches = disconnectedSlots.filter((s) => s.role === body.role);
371
+ if (roleMatches.length >= 1) {
372
+ disconnectedSlots = roleMatches;
373
+ }
374
+ }
375
+
376
+ if (disconnectedSlots.length >= 1) {
377
+ // Pick the first match (exact role match preferred, or only candidate)
378
+ const slot = disconnectedSlots[0];
379
+ insertPeer.run(id, body.pid, body.cwd, body.git_root, body.tty, body.summary, now, now, body.session_id, slot.id, agentType, "idle");
380
+ db.run(
381
+ "UPDATE slots SET peer_id = ?, status = 'connected', last_connected = ?, last_peer_pid = ? WHERE id = ?",
382
+ [id, nowMs, body.pid, slot.id]
383
+ );
384
+
385
+ const recap = db.query(
386
+ "SELECT * FROM messages WHERE session_id = ? AND to_slot_id = ? ORDER BY sent_at DESC LIMIT ?"
387
+ ).all(body.session_id, slot.id, RECONNECT_RECAP_LIMIT) as Message[];
388
+ recap.reverse();
389
+
390
+ const updatedSlot = db.query("SELECT * FROM slots WHERE id = ?").get(slot.id) as Slot;
391
+ return { id, slot: updatedSlot, recap };
392
+ }
393
+ // 0 matches — fall through to create new slot if session exists
394
+ }
395
+
396
+ // Default registration (possibly with session)
397
+ let slotResult: Slot | undefined;
398
+ let slotId: number | null = null;
399
+
400
+ if (body.session_id) {
401
+ const session = db.query("SELECT id FROM sessions WHERE id = ?").get(body.session_id);
402
+ if (session) {
403
+ const res = db.run(
404
+ "INSERT INTO slots (session_id, agent_type, display_name, role, status, last_connected, last_peer_pid) VALUES (?, ?, ?, ?, 'connected', ?, ?)",
405
+ [body.session_id, agentType, body.display_name ?? null, body.role ?? null, nowMs, body.pid]
406
+ );
407
+ slotId = Number(res.lastInsertRowid);
408
+ db.run("UPDATE sessions SET last_active_at = ? WHERE id = ?", [nowMs, body.session_id]);
409
+ }
410
+ }
411
+
412
+ insertPeer.run(id, body.pid, body.cwd, body.git_root, body.tty, body.summary, now, now, body.session_id ?? null, slotId, agentType, "idle");
413
+
414
+ if (slotId !== null) {
415
+ db.run("UPDATE slots SET peer_id = ? WHERE id = ?", [id, slotId]);
416
+ slotResult = db.query("SELECT * FROM slots WHERE id = ?").get(slotId) as Slot;
417
+ }
418
+
419
+ return slotResult ? { id, slot: slotResult } : { id };
420
+ }
421
+
422
+ function handleHeartbeat(body: HeartbeatRequest): void {
423
+ updateLastSeen.run(new Date().toISOString(), body.id);
424
+ }
425
+
426
+ function handleSetSummary(body: SetSummaryRequest): void {
427
+ updateSummary.run(body.summary, body.id);
428
+ }
429
+
430
+ function handleListPeers(body: ListPeersRequest): Peer[] {
431
+ let peers: Peer[];
432
+
433
+ switch (body.scope) {
434
+ case "machine":
435
+ peers = selectAllPeers.all() as Peer[];
436
+ break;
437
+ case "directory":
438
+ peers = selectPeersByDirectory.all(body.cwd) as Peer[];
439
+ break;
440
+ case "repo":
441
+ if (body.git_root) {
442
+ peers = selectPeersByGitRoot.all(body.git_root) as Peer[];
443
+ } else {
444
+ peers = selectPeersByDirectory.all(body.cwd) as Peer[];
445
+ }
446
+ break;
447
+ default:
448
+ peers = selectAllPeers.all() as Peer[];
449
+ }
450
+
451
+ // Filter by agent_type
452
+ if (body.agent_type && body.agent_type !== "all") {
453
+ peers = peers.filter((p) => p.agent_type === body.agent_type);
454
+ }
455
+
456
+ // Filter by session_id
457
+ if (body.session_id) {
458
+ peers = peers.filter((p) => p.session_id === body.session_id);
459
+ }
460
+
461
+ // Exclude the requesting peer
462
+ if (body.exclude_id) {
463
+ peers = peers.filter((p) => p.id !== body.exclude_id);
464
+ }
465
+
466
+ // Verify each peer's process is still alive
467
+ return peers.filter((p) => {
468
+ try {
469
+ process.kill(p.pid, 0);
470
+ return true;
471
+ } catch {
472
+ deletePeer.run(p.id);
473
+ return false;
474
+ }
475
+ });
476
+ }
477
+
478
+ function handleSendMessage(body: SendMessageRequest): SendMessageResult {
479
+ const now = new Date().toISOString();
480
+ let toId = body.to_id;
481
+ let toSlotId = body.to_slot_id ?? null;
482
+ const msgType = body.msg_type ?? "chat";
483
+ const sessionId = body.session_id ?? null;
484
+
485
+ // Resolve to_slot_id to to_id if needed
486
+ if (!toId && toSlotId) {
487
+ const slot = db.query("SELECT peer_id FROM slots WHERE id = ?").get(toSlotId) as { peer_id: string | null } | null;
488
+ if (!slot) return { ok: false, error: `Slot ${toSlotId} not found` };
489
+ if (!slot.peer_id) return { ok: false, error: `Slot ${toSlotId} has no connected peer` };
490
+ toId = slot.peer_id;
491
+ }
492
+
493
+ if (!toId) return { ok: false, error: "No target specified" };
494
+
495
+ // Verify target exists
496
+ const target = db.query("SELECT id FROM peers WHERE id = ?").get(toId) as { id: string } | null;
497
+ if (!target) return { ok: false, error: `Peer ${toId} not found` };
498
+
499
+ // Determine from_slot_id
500
+ let fromSlotId: number | null = null;
501
+ const fromPeer = db.query("SELECT slot_id FROM peers WHERE id = ?").get(body.from_id) as { slot_id: number | null } | null;
502
+ if (fromPeer) fromSlotId = fromPeer.slot_id;
503
+
504
+ // Check if target slot is paused
505
+ let held = 0;
506
+ if (toSlotId) {
507
+ const targetSlot = db.query("SELECT paused FROM slots WHERE id = ?").get(toSlotId) as { paused: number } | null;
508
+ if (targetSlot?.paused) held = 1;
509
+ } else {
510
+ // Look up slot from peer
511
+ const targetPeer = db.query("SELECT slot_id FROM peers WHERE id = ?").get(toId) as { slot_id: number | null } | null;
512
+ if (targetPeer?.slot_id) {
513
+ toSlotId = targetPeer.slot_id;
514
+ const targetSlot = db.query("SELECT paused FROM slots WHERE id = ?").get(toSlotId) as { paused: number } | null;
515
+ if (targetSlot?.paused) held = 1;
516
+ }
517
+ }
518
+
519
+ insertMessage.run(body.from_id, toId, body.text, now, sessionId, fromSlotId, toSlotId, msgType, held);
520
+
521
+ const warning = held ? "Message held — target agent is paused" : undefined;
522
+ return { ok: true, warning };
523
+ }
524
+
525
+ function handlePollMessages(body: PollMessagesRequest): PollMessagesResponse {
526
+ // Check if the peer's slot is paused
527
+ const peer = db.query("SELECT slot_id FROM peers WHERE id = ?").get(body.id) as { slot_id: number | null } | null;
528
+ if (peer?.slot_id) {
529
+ const slot = db.query("SELECT paused FROM slots WHERE id = ?").get(peer.slot_id) as { paused: number } | null;
530
+ if (slot?.paused) {
531
+ return { messages: [], paused: true };
532
+ }
533
+ }
534
+
535
+ const messages = selectUndelivered.all(body.id) as Message[];
536
+ const now = new Date().toISOString();
537
+ for (const msg of messages) {
538
+ markDelivered.run(now, msg.id);
539
+ }
540
+
541
+ return { messages };
542
+ }
543
+
544
+ function handleUnregister(body: { id: string }): { ok: boolean; denied?: boolean; reason?: string; task_state?: string } {
545
+ const now = Date.now();
546
+ // Look up peer and its slot
547
+ const peer = db.query("SELECT slot_id, summary, cwd FROM peers WHERE id = ?").get(body.id) as any;
548
+ if (peer?.slot_id) {
549
+ // Check task_state gating — in a session, agents can ONLY disconnect when explicitly released
550
+ const slot = db.query("SELECT task_state, session_id FROM slots WHERE id = ?").get(peer.slot_id) as any;
551
+ if (slot && slot.session_id && slot.task_state !== "released") {
552
+ return {
553
+ ok: false,
554
+ denied: true,
555
+ reason: `Cannot disconnect: task_state is '${slot.task_state}'. Only the team lead or orchestrator can release you. Stay active, communicate with your team, and wait for release.`,
556
+ task_state: slot.task_state,
557
+ };
558
+ }
559
+
560
+ const snapshot = JSON.stringify({
561
+ last_summary: peer.summary ?? null,
562
+ last_status: "disconnected",
563
+ last_cwd: peer.cwd ?? null,
564
+ });
565
+ db.run(
566
+ "UPDATE slots SET status = 'disconnected', peer_id = NULL, last_disconnected = ?, context_snapshot = ? WHERE id = ?",
567
+ [now, snapshot, peer.slot_id]
568
+ );
569
+ // Release file locks
570
+ db.run("DELETE FROM file_locks WHERE held_by_peer = ?", [body.id]);
571
+ }
572
+ deletePeer.run(body.id);
573
+ return { ok: true };
574
+ }
575
+
576
+ // --- Role & rename ---
577
+
578
+ function handleSetRole(body: SetRoleRequest): { ok: boolean } {
579
+ const slotId = body.slot_id;
580
+ if (slotId) {
581
+ db.run(
582
+ "UPDATE slots SET role = ?, role_description = ?, role_assigned_by = ? WHERE id = ?",
583
+ [body.role, body.role_description, body.assigner_id, slotId]
584
+ );
585
+ }
586
+ // Insert system message to target
587
+ const now = new Date().toISOString();
588
+ const text = JSON.stringify({ role: body.role, role_description: body.role_description });
589
+ insertMessage.run(body.assigner_id, body.peer_id, text, now, null, null, slotId ?? null, "role_assignment", 0);
590
+ return { ok: true };
591
+ }
592
+
593
+ function handleRenamePeer(body: RenamePeerRequest): { ok: boolean } {
594
+ if (body.slot_id) {
595
+ db.run("UPDATE slots SET display_name = ? WHERE id = ?", [body.display_name, body.slot_id]);
596
+ }
597
+ const now = new Date().toISOString();
598
+ const text = JSON.stringify({ display_name: body.display_name });
599
+ insertMessage.run(body.assigner_id, body.peer_id, text, now, null, null, body.slot_id ?? null, "rename", 0);
600
+ return { ok: true };
601
+ }
602
+
603
+ // --- Sessions ---
604
+
605
+ function handleCreateSession(body: CreateSessionRequest): Session {
606
+ const now = Date.now();
607
+ db.run(
608
+ "INSERT INTO sessions (id, name, project_dir, git_root, status, config, created_at, last_active_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)",
609
+ [body.id, body.name, body.project_dir, body.git_root ?? null, JSON.stringify(body.config ?? {}), now, now]
610
+ );
611
+ return db.query("SELECT * FROM sessions WHERE id = ?").get(body.id) as Session;
612
+ }
613
+
614
+ function handleGetSession(body: { id: string }): Session | null {
615
+ return (db.query("SELECT * FROM sessions WHERE id = ?").get(body.id) as Session) ?? null;
616
+ }
617
+
618
+ function handleListSessions(): Session[] {
619
+ return db.query("SELECT * FROM sessions ORDER BY last_active_at DESC").all() as Session[];
620
+ }
621
+
622
+ function handleUpdateSession(body: UpdateSessionRequest): Session | null {
623
+ const fields: string[] = [];
624
+ const values: any[] = [];
625
+
626
+ if (body.status !== undefined) { fields.push("status = ?"); values.push(body.status); }
627
+ if (body.pause_reason !== undefined) { fields.push("pause_reason = ?"); values.push(body.pause_reason); }
628
+ if (body.paused_at !== undefined) { fields.push("paused_at = ?"); values.push(body.paused_at); }
629
+ if (body.config !== undefined) { fields.push("config = ?"); values.push(JSON.stringify(body.config)); }
630
+
631
+ fields.push("last_active_at = ?");
632
+ values.push(Date.now());
633
+ values.push(body.id);
634
+
635
+ if (fields.length > 0) {
636
+ db.run(`UPDATE sessions SET ${fields.join(", ")} WHERE id = ?`, values);
637
+ }
638
+ return (db.query("SELECT * FROM sessions WHERE id = ?").get(body.id) as Session) ?? null;
639
+ }
640
+
641
+ // --- Slots ---
642
+
643
+ function handleCreateSlot(body: CreateSlotRequest): Slot {
644
+ const res = db.run(
645
+ "INSERT INTO slots (session_id, agent_type, display_name, role, role_description) VALUES (?, ?, ?, ?, ?)",
646
+ [body.session_id, body.agent_type, body.display_name ?? null, body.role ?? null, body.role_description ?? null]
647
+ );
648
+ return db.query("SELECT * FROM slots WHERE id = ?").get(Number(res.lastInsertRowid)) as Slot;
649
+ }
650
+
651
+ function handleGetSlot(body: { id: number }): Slot | null {
652
+ return (db.query("SELECT * FROM slots WHERE id = ?").get(body.id) as Slot) ?? null;
653
+ }
654
+
655
+ function handleListSlots(body: { session_id: string }): Slot[] {
656
+ return db.query("SELECT * FROM slots WHERE session_id = ?").all(body.session_id) as Slot[];
657
+ }
658
+
659
+ function handleUpdateSlot(body: UpdateSlotRequest): Slot | null {
660
+ const fields: string[] = [];
661
+ const values: any[] = [];
662
+
663
+ if (body.paused !== undefined) { fields.push("paused = ?"); values.push(body.paused ? 1 : 0); }
664
+ if (body.paused_at !== undefined) { fields.push("paused_at = ?"); values.push(body.paused_at); }
665
+ if (body.status !== undefined) { fields.push("status = ?"); values.push(body.status); }
666
+ if (body.context_snapshot !== undefined) { fields.push("context_snapshot = ?"); values.push(body.context_snapshot); }
667
+ if (body.display_name !== undefined) { fields.push("display_name = ?"); values.push(body.display_name); }
668
+ if (body.role !== undefined) { fields.push("role = ?"); values.push(body.role); }
669
+ if (body.role_description !== undefined) { fields.push("role_description = ?"); values.push(body.role_description); }
670
+ if (body.task_state !== undefined) { fields.push("task_state = ?"); values.push(body.task_state); }
671
+ if (body.input_tokens !== undefined) { fields.push("input_tokens = input_tokens + ?"); values.push(body.input_tokens); }
672
+ if (body.output_tokens !== undefined) { fields.push("output_tokens = output_tokens + ?"); values.push(body.output_tokens); }
673
+ if (body.cache_read_tokens !== undefined) { fields.push("cache_read_tokens = cache_read_tokens + ?"); values.push(body.cache_read_tokens); }
674
+
675
+ if (fields.length > 0) {
676
+ values.push(body.id);
677
+ db.run(`UPDATE slots SET ${fields.join(", ")} WHERE id = ?`, values);
678
+ }
679
+ return (db.query("SELECT * FROM slots WHERE id = ?").get(body.id) as Slot) ?? null;
680
+ }
681
+
682
+ // --- File locks & ownership ---
683
+
684
+ function handleAcquireFile(body: AcquireFileRequest): AcquireFileResult {
685
+ const now = Date.now();
686
+ const timeout = Math.min(body.timeout_ms ?? DEFAULT_LOCK_TIMEOUT_MS, MAX_LOCK_TIMEOUT_MS);
687
+ const expiresAt = now + timeout;
688
+
689
+ // Normalize the file path and strip leading ../ segments
690
+ const filePath = normalize(body.file_path).replace(/^(\.\.[/\\])+/, "");
691
+
692
+ // Check ownership zones — deny if file matches another slot's pattern
693
+ const ownerships = db.query(
694
+ "SELECT * FROM file_ownership WHERE session_id = ? AND slot_id != ?"
695
+ ).all(body.session_id, body.slot_id) as FileOwnership[];
696
+
697
+ for (const own of ownerships) {
698
+ if (fileMatchesPattern(filePath, own.path_pattern)) {
699
+ const ownerSlot = db.query("SELECT display_name FROM slots WHERE id = ?").get(own.slot_id) as { display_name: string | null } | null;
700
+ return {
701
+ status: "denied",
702
+ owner: ownerSlot?.display_name ?? `slot-${own.slot_id}`,
703
+ pattern: own.path_pattern,
704
+ message: `File is in ownership zone of ${ownerSlot?.display_name ?? `slot-${own.slot_id}`} (pattern: ${own.path_pattern})`,
705
+ };
706
+ }
707
+ }
708
+
709
+ // Check existing locks
710
+ const existing = db.query(
711
+ "SELECT * FROM file_locks WHERE session_id = ? AND file_path = ? AND expires_at > ?"
712
+ ).get(body.session_id, filePath, now) as FileLock | null;
713
+
714
+ if (existing) {
715
+ if (existing.held_by_slot === body.slot_id) {
716
+ // Extend
717
+ db.run("UPDATE file_locks SET expires_at = ? WHERE id = ?", [expiresAt, existing.id]);
718
+ return { status: "extended", expires_at: expiresAt, message: "Lock extended" };
719
+ }
720
+ const holderSlot = db.query("SELECT display_name FROM slots WHERE id = ?").get(existing.held_by_slot) as { display_name: string | null } | null;
721
+ return {
722
+ status: "locked",
723
+ held_by: holderSlot?.display_name ?? `slot-${existing.held_by_slot}`,
724
+ expires_at: existing.expires_at,
725
+ wait_estimate_ms: existing.expires_at - now,
726
+ message: `File locked by ${holderSlot?.display_name ?? `slot-${existing.held_by_slot}`}`,
727
+ };
728
+ }
729
+
730
+ // Acquire
731
+ db.run(
732
+ "INSERT INTO file_locks (session_id, file_path, held_by_slot, held_by_peer, acquired_at, expires_at, lock_type, purpose) VALUES (?, ?, ?, ?, ?, ?, 'exclusive', ?)",
733
+ [body.session_id, filePath, body.slot_id, body.peer_id, now, expiresAt, body.purpose ?? null]
734
+ );
735
+ return { status: "acquired", expires_at: expiresAt, message: "Lock acquired" };
736
+ }
737
+
738
+ function handleReleaseFile(body: ReleaseFileRequest): { ok: boolean } {
739
+ // Normalize the file path and strip leading ../ segments
740
+ const filePath = normalize(body.file_path).replace(/^(\.\.[/\\])+/, "");
741
+ db.run(
742
+ "DELETE FROM file_locks WHERE session_id = ? AND file_path = ? AND held_by_peer = ?",
743
+ [body.session_id, filePath, body.peer_id]
744
+ );
745
+ return { ok: true };
746
+ }
747
+
748
+ function handleAssignOwnership(body: AssignOwnershipRequest): { ok: boolean; status: string; message?: string } {
749
+ for (const pattern of body.path_patterns) {
750
+ // Check for overlapping patterns from other slots
751
+ const conflict = db.query(
752
+ "SELECT * FROM file_ownership WHERE session_id = ? AND slot_id != ? AND path_pattern = ?"
753
+ ).get(body.session_id, body.slot_id, pattern) as FileOwnership | null;
754
+
755
+ if (conflict) {
756
+ return {
757
+ ok: false,
758
+ status: "conflict",
759
+ message: `Pattern "${pattern}" already assigned to slot ${conflict.slot_id}`,
760
+ };
761
+ }
762
+ }
763
+
764
+ for (const pattern of body.path_patterns) {
765
+ db.run(
766
+ "INSERT OR REPLACE INTO file_ownership (session_id, slot_id, path_pattern, assigned_at, assigned_by) VALUES (?, ?, ?, ?, ?)",
767
+ [body.session_id, body.slot_id, pattern, Date.now(), body.assigned_by]
768
+ );
769
+ }
770
+ return { ok: true, status: "assigned" };
771
+ }
772
+
773
+ function handleListLocks(body: { session_id: string }): FileLock[] {
774
+ const now = Date.now();
775
+ return db.query(
776
+ "SELECT * FROM file_locks WHERE session_id = ? AND expires_at > ?"
777
+ ).all(body.session_id, now) as FileLock[];
778
+ }
779
+
780
+ function handleListOwnership(body: { session_id: string }): FileOwnership[] {
781
+ return db.query(
782
+ "SELECT * FROM file_ownership WHERE session_id = ?"
783
+ ).all(body.session_id) as FileOwnership[];
784
+ }
785
+
786
+ function fileMatchesPattern(filePath: string, pattern: string): boolean {
787
+ // Simple glob: "src/auth/*" matches "src/auth/login.ts"
788
+ // Convert glob to regex
789
+ const escaped = pattern
790
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
791
+ .replace(/\*\*/g, "<<<GLOBSTAR>>>")
792
+ .replace(/\*/g, "[^/]*")
793
+ .replace(/<<<GLOBSTAR>>>/g, ".*");
794
+ const re = new RegExp(`^${escaped}$`);
795
+ return re.test(filePath);
796
+ }
797
+
798
+ // --- Guardrails ---
799
+
800
+ function computeGuardrailUsage(guardrailId: string, limit: number, warnPct: number, sessionId: string, action: string): { current: number; limit: number; percent: number; status: "ok" | "warning" | "triggered" } {
801
+ let current = 0;
802
+
803
+ switch (guardrailId) {
804
+ case "session_duration": {
805
+ const session = db.query("SELECT created_at FROM sessions WHERE id = ?").get(sessionId) as { created_at: number } | null;
806
+ current = session ? (Date.now() - session.created_at) / 60000 : 0;
807
+ break;
808
+ }
809
+ case "messages_total": {
810
+ const row = db.query(
811
+ "SELECT COUNT(*) as cnt FROM messages WHERE session_id = ?"
812
+ ).get(sessionId) as { cnt: number };
813
+ current = row.cnt;
814
+ break;
815
+ }
816
+ case "messages_per_agent": {
817
+ const rows = db.query(
818
+ "SELECT from_slot_id, COUNT(*) as cnt FROM messages WHERE session_id = ? AND from_slot_id IS NOT NULL GROUP BY from_slot_id"
819
+ ).all(sessionId) as { from_slot_id: number; cnt: number }[];
820
+ current = rows.reduce((max, r) => Math.max(max, r.cnt), 0);
821
+ break;
822
+ }
823
+ case "agent_count": {
824
+ const row = db.query(
825
+ "SELECT COUNT(*) as cnt FROM slots WHERE session_id = ? AND status = 'connected'"
826
+ ).get(sessionId) as { cnt: number };
827
+ current = row.cnt;
828
+ break;
829
+ }
830
+ case "max_restarts": {
831
+ const rows = db.query(
832
+ "SELECT slot_id, COUNT(*) as cnt FROM guardrail_events WHERE session_id = ? AND event_type = 'agent_exited' AND slot_id IS NOT NULL GROUP BY slot_id"
833
+ ).all(sessionId) as { slot_id: number; cnt: number }[];
834
+ current = rows.reduce((max, r) => Math.max(max, r.cnt), 0);
835
+ break;
836
+ }
837
+ case "idle_max": {
838
+ const peers = db.query(
839
+ "SELECT last_seen FROM peers WHERE session_id = ?"
840
+ ).all(sessionId) as { last_seen: string }[];
841
+ if (peers.length > 0) {
842
+ const now = Date.now();
843
+ const maxAge = peers.reduce((max, p) => {
844
+ const age = (now - new Date(p.last_seen).getTime()) / 60000;
845
+ return Math.max(max, age);
846
+ }, 0);
847
+ current = maxAge;
848
+ }
849
+ break;
850
+ }
851
+ }
852
+
853
+ // Monitor-only stats: always "ok", no enforcement
854
+ if (action === "monitor") {
855
+ return { current, limit: 0, percent: 0, status: "ok" };
856
+ }
857
+
858
+ const percent = limit > 0 ? current / limit : 0;
859
+ const status = percent >= 1 ? "triggered" : percent >= warnPct ? "warning" : "ok";
860
+ return { current, limit, percent, status };
861
+ }
862
+
863
+ function handleGetGuardrails(body: { session_id: string }) {
864
+ const overrides = db.query(
865
+ "SELECT guardrail_id, value FROM guardrail_overrides WHERE session_id = ?"
866
+ ).all(body.session_id) as { guardrail_id: string; value: number }[];
867
+
868
+ const overrideMap = new Map(overrides.map((o) => [o.guardrail_id, o.value]));
869
+
870
+ return DEFAULT_GUARDRAILS.map((g) => {
871
+ const overridden = overrideMap.has(g.id);
872
+ const currentValue = overrideMap.get(g.id) ?? g.default_value;
873
+ const usage = computeGuardrailUsage(g.id, currentValue, g.warn_at_percent, body.session_id, g.action);
874
+ return {
875
+ ...g,
876
+ current_value: currentValue,
877
+ is_overridden: overridden,
878
+ usage,
879
+ };
880
+ });
881
+ }
882
+
883
+ function handleUpdateGuardrail(body: UpdateGuardrailRequest) {
884
+ const now = Date.now();
885
+ db.run(
886
+ "INSERT OR REPLACE INTO guardrail_overrides (session_id, guardrail_id, value, changed_at, changed_by, reason) VALUES (?, ?, ?, ?, ?, ?)",
887
+ [body.session_id, body.guardrail_id, body.new_value, now, body.changed_by, body.reason ?? null]
888
+ );
889
+ // Log event
890
+ db.run(
891
+ "INSERT INTO guardrail_events (session_id, guardrail_id, event_type, limit_value, timestamp, metadata) VALUES (?, ?, 'override', ?, ?, ?)",
892
+ [body.session_id, body.guardrail_id, body.new_value, now, JSON.stringify({ changed_by: body.changed_by, reason: body.reason })]
893
+ );
894
+ // Return the full updated guardrail state
895
+ const allGuardrails = handleGetGuardrails({ session_id: body.session_id });
896
+ return allGuardrails.find((g) => g.id === body.guardrail_id) ?? allGuardrails[0];
897
+ }
898
+
899
+ // --- Plans ---
900
+
901
+ interface CreatePlanRequest {
902
+ session_id: string;
903
+ title: string;
904
+ items: { label: string; assigned_to_slot?: number; parent_id?: number }[];
905
+ }
906
+
907
+ function handleCreatePlan(body: CreatePlanRequest) {
908
+ const now = Date.now();
909
+ const existing = db.query("SELECT id FROM plans WHERE session_id = ?").get(body.session_id) as { id: number } | null;
910
+ if (existing) {
911
+ // Delete old plan items and plan, then recreate
912
+ db.run("DELETE FROM plan_items WHERE plan_id = ?", [existing.id]);
913
+ db.run("DELETE FROM plans WHERE id = ?", [existing.id]);
914
+ }
915
+
916
+ db.run(
917
+ "INSERT INTO plans (session_id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
918
+ [body.session_id, body.title, now, now],
919
+ );
920
+ const plan = db.query("SELECT * FROM plans WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(body.session_id) as any;
921
+
922
+ for (let i = 0; i < body.items.length; i++) {
923
+ const item = body.items[i];
924
+ db.run(
925
+ "INSERT INTO plan_items (plan_id, parent_id, label, status, assigned_to_slot, sort_order) VALUES (?, ?, ?, 'pending', ?, ?)",
926
+ [plan.id, item.parent_id ?? null, item.label, item.assigned_to_slot ?? null, i],
927
+ );
928
+ }
929
+
930
+ return handleGetPlan({ session_id: body.session_id });
931
+ }
932
+
933
+ function handleGetPlan(body: { session_id: string }) {
934
+ const plan = db.query("SELECT * FROM plans WHERE session_id = ? ORDER BY id DESC LIMIT 1").get(body.session_id) as any;
935
+ if (!plan) return { plan: null, items: [], completion: 0 };
936
+
937
+ const items = db.query(
938
+ "SELECT pi.*, s.display_name as assigned_name FROM plan_items pi LEFT JOIN slots s ON s.id = pi.assigned_to_slot WHERE pi.plan_id = ? ORDER BY pi.sort_order",
939
+ ).all(plan.id) as any[];
940
+
941
+ const total = items.length;
942
+ const done = items.filter((i: any) => i.status === "done").length;
943
+ const completion = total > 0 ? Math.round((done / total) * 100) : 0;
944
+
945
+ return { plan, items, completion };
946
+ }
947
+
948
+ function handleUpdatePlanItem(body: { item_id: number; status: string; session_id?: string }) {
949
+ const now = Date.now();
950
+ const completedAt = body.status === "done" ? now : null;
951
+ db.run(
952
+ "UPDATE plan_items SET status = ?, completed_at = ? WHERE id = ?",
953
+ [body.status, completedAt, body.item_id],
954
+ );
955
+
956
+ // Update the plan's updated_at timestamp
957
+ const item = db.query("SELECT plan_id FROM plan_items WHERE id = ?").get(body.item_id) as any;
958
+ if (item) {
959
+ db.run("UPDATE plans SET updated_at = ? WHERE id = ?", [now, item.plan_id]);
960
+ }
961
+
962
+ // If session_id provided, return full plan state
963
+ if (body.session_id) {
964
+ return handleGetPlan({ session_id: body.session_id });
965
+ }
966
+ return { ok: true };
967
+ }
968
+
969
+ // --- Message log ---
970
+
971
+ function handleMessageLog(body: { session_id: string } & MessageLogOptions) {
972
+ const limit = body.limit ?? 50;
973
+ const conditions = ["m.session_id = ?"];
974
+ const params: any[] = [body.session_id];
975
+
976
+ if (body.since) {
977
+ conditions.push("m.sent_at > ?");
978
+ params.push(new Date(body.since).toISOString());
979
+ }
980
+ if (body.with_slot) {
981
+ conditions.push("(m.from_slot_id = ? OR m.to_slot_id = ?)");
982
+ params.push(body.with_slot, body.with_slot);
983
+ }
984
+ if (body.msg_type) {
985
+ conditions.push("m.msg_type = ?");
986
+ params.push(body.msg_type);
987
+ }
988
+
989
+ params.push(limit);
990
+
991
+ const sql = `
992
+ SELECT m.*,
993
+ p.summary as from_summary,
994
+ s.display_name as from_display_name,
995
+ s.role as from_role,
996
+ ts.display_name as to_display_name,
997
+ ts.role as to_role
998
+ FROM messages m
999
+ LEFT JOIN peers p ON p.id = m.from_id
1000
+ LEFT JOIN slots s ON s.id = m.from_slot_id
1001
+ LEFT JOIN slots ts ON ts.id = m.to_slot_id
1002
+ WHERE ${conditions.join(" AND ")}
1003
+ ORDER BY m.sent_at DESC
1004
+ LIMIT ?
1005
+ `;
1006
+
1007
+ const rows = db.query(sql).all(...params) as any[];
1008
+ rows.reverse();
1009
+ return rows;
1010
+ }
1011
+
1012
+ // --- Hold / release messages ---
1013
+
1014
+ function handleHoldMessages(body: { session_id: string; slot_id: number }): { ok: boolean } {
1015
+ db.run("UPDATE slots SET paused = 1, paused_at = ? WHERE id = ? AND session_id = ?", [Date.now(), body.slot_id, body.session_id]);
1016
+ return { ok: true };
1017
+ }
1018
+
1019
+ function handleReleaseHeld(body: { session_id: string; slot_id: number }) {
1020
+ // Get held messages
1021
+ const messages = db.query(
1022
+ "SELECT * FROM messages WHERE session_id = ? AND to_slot_id = ? AND held = 1 ORDER BY sent_at ASC"
1023
+ ).all(body.session_id, body.slot_id) as Message[];
1024
+
1025
+ // Mark unheld
1026
+ db.run(
1027
+ "UPDATE messages SET held = 0 WHERE session_id = ? AND to_slot_id = ? AND held = 1",
1028
+ [body.session_id, body.slot_id]
1029
+ );
1030
+
1031
+ // Unpause slot
1032
+ db.run("UPDATE slots SET paused = 0, paused_at = NULL WHERE id = ? AND session_id = ?", [body.slot_id, body.session_id]);
1033
+
1034
+ return { messages };
1035
+ }
1036
+
1037
+ // --- Agent event ---
1038
+
1039
+ function handleAgentEvent(body: { session_id: string; event_type: string; slot_id?: number; metadata?: any }) {
1040
+ db.run(
1041
+ "INSERT INTO guardrail_events (session_id, guardrail_id, event_type, slot_id, timestamp, metadata) VALUES (?, '', ?, ?, ?, ?)",
1042
+ [body.session_id, body.event_type, body.slot_id ?? null, Date.now(), body.metadata ? JSON.stringify(body.metadata) : null]
1043
+ );
1044
+ return { ok: true };
1045
+ }
1046
+
1047
+ // --- HTTP Server ---
1048
+
1049
+ Bun.serve({
1050
+ port: PORT,
1051
+ hostname: "127.0.0.1",
1052
+ async fetch(req) {
1053
+ const url = new URL(req.url);
1054
+ const path = url.pathname;
1055
+
1056
+ if (req.method !== "POST") {
1057
+ if (path === "/health") {
1058
+ return Response.json({ status: "ok", peers: (selectAllPeers.all() as Peer[]).length });
1059
+ }
1060
+ return new Response("multiagents broker", { status: 200 });
1061
+ }
1062
+
1063
+ try {
1064
+ const body = await req.json();
1065
+
1066
+ switch (path) {
1067
+ // --- Original endpoints ---
1068
+ case "/register":
1069
+ return Response.json(handleRegister(body as RegisterRequest));
1070
+ case "/heartbeat":
1071
+ handleHeartbeat(body as HeartbeatRequest);
1072
+ return Response.json({ ok: true });
1073
+ case "/set-summary":
1074
+ handleSetSummary(body as SetSummaryRequest);
1075
+ return Response.json({ ok: true });
1076
+ case "/list-peers":
1077
+ return Response.json(handleListPeers(body as ListPeersRequest));
1078
+ case "/send-message":
1079
+ return Response.json(handleSendMessage(body as SendMessageRequest));
1080
+ case "/poll-messages":
1081
+ return Response.json(handlePollMessages(body as PollMessagesRequest));
1082
+ case "/unregister":
1083
+ return Response.json(handleUnregister(body as { id: string }));
1084
+
1085
+ // --- Role & rename ---
1086
+ case "/set-role":
1087
+ return Response.json(handleSetRole(body as SetRoleRequest));
1088
+ case "/rename-peer":
1089
+ return Response.json(handleRenamePeer(body as RenamePeerRequest));
1090
+
1091
+ // --- Sessions ---
1092
+ case "/sessions/create":
1093
+ return Response.json(handleCreateSession(body as CreateSessionRequest));
1094
+ case "/sessions/get": {
1095
+ const session = handleGetSession(body as { id: string });
1096
+ return session ? Response.json(session) : Response.json({ error: "Session not found" }, { status: 404 });
1097
+ }
1098
+ case "/sessions/list":
1099
+ return Response.json(handleListSessions());
1100
+ case "/sessions/update": {
1101
+ const updated = handleUpdateSession(body as UpdateSessionRequest);
1102
+ return updated ? Response.json(updated) : Response.json({ error: "Session not found" }, { status: 404 });
1103
+ }
1104
+
1105
+ // --- Slots ---
1106
+ case "/slots/create":
1107
+ return Response.json(handleCreateSlot(body as CreateSlotRequest));
1108
+ case "/slots/get": {
1109
+ const slot = handleGetSlot(body as { id: number });
1110
+ return slot ? Response.json(slot) : Response.json({ error: "Slot not found" }, { status: 404 });
1111
+ }
1112
+ case "/slots/list":
1113
+ return Response.json(handleListSlots(body as { session_id: string }));
1114
+ case "/slots/update": {
1115
+ const updatedSlot = handleUpdateSlot(body as UpdateSlotRequest);
1116
+ return updatedSlot ? Response.json(updatedSlot) : Response.json({ error: "Slot not found" }, { status: 404 });
1117
+ }
1118
+
1119
+ // --- File coordination ---
1120
+ case "/files/acquire":
1121
+ return Response.json(handleAcquireFile(body as AcquireFileRequest));
1122
+ case "/files/release":
1123
+ return Response.json(handleReleaseFile(body as ReleaseFileRequest));
1124
+ case "/files/assign-ownership":
1125
+ return Response.json(handleAssignOwnership(body as AssignOwnershipRequest));
1126
+ case "/files/locks":
1127
+ return Response.json(handleListLocks(body as { session_id: string }));
1128
+ case "/files/ownership":
1129
+ return Response.json(handleListOwnership(body as { session_id: string }));
1130
+
1131
+ // --- Guardrails ---
1132
+ case "/guardrails":
1133
+ return Response.json(handleGetGuardrails(body as { session_id: string }));
1134
+ case "/guardrails/update":
1135
+ return Response.json(handleUpdateGuardrail(body as UpdateGuardrailRequest));
1136
+
1137
+ // --- Plans ---
1138
+ case "/plan/create":
1139
+ return Response.json(handleCreatePlan(body as any));
1140
+ case "/plan/get":
1141
+ return Response.json(handleGetPlan(body as { session_id: string }));
1142
+ case "/plan/update-item":
1143
+ return Response.json(handleUpdatePlanItem(body as any));
1144
+
1145
+ // --- Message log ---
1146
+ case "/message-log":
1147
+ return Response.json(handleMessageLog(body));
1148
+
1149
+ // --- Hold / release ---
1150
+ case "/hold-messages":
1151
+ return Response.json(handleHoldMessages(body as { session_id: string; slot_id: number }));
1152
+ case "/release-held":
1153
+ return Response.json(handleReleaseHeld(body as { session_id: string; slot_id: number }));
1154
+
1155
+ // --- Agent events ---
1156
+ case "/agent-event":
1157
+ return Response.json(handleAgentEvent(body));
1158
+
1159
+ // --- Lifecycle ---
1160
+ case "/lifecycle/signal-done": {
1161
+ const { peer_id, session_id, summary } = body;
1162
+ const peer = db.query("SELECT * FROM peers WHERE id = ?").get(peer_id) as any;
1163
+ if (!peer || !peer.slot_id) return Response.json({ error: "Peer not found or no slot" }, { status: 404 });
1164
+
1165
+ db.run("UPDATE slots SET task_state = 'done_pending_review' WHERE id = ?", [peer.slot_id]);
1166
+ db.run("UPDATE peers SET summary = ? WHERE id = ?", [summary, peer_id]);
1167
+
1168
+ const allSlots = db.query("SELECT * FROM slots WHERE session_id = ? AND id != ?").all(session_id, peer.slot_id) as any[];
1169
+ const thisSlot = db.query("SELECT * FROM slots WHERE id = ?").get(peer.slot_id) as any;
1170
+
1171
+ for (const targetSlot of allSlots) {
1172
+ if (targetSlot.status === "connected" && targetSlot.peer_id) {
1173
+ const isReviewerLike = targetSlot.role && /qa|review|test|lead/i.test(targetSlot.role);
1174
+ const msgType = isReviewerLike ? "review_request" : "task_complete";
1175
+ db.run(
1176
+ "INSERT INTO messages (session_id, from_id, from_slot_id, to_id, to_slot_id, text, msg_type, sent_at, delivered, held) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)",
1177
+ [session_id, peer_id, peer.slot_id, targetSlot.peer_id, targetSlot.id,
1178
+ `${thisSlot.display_name || peer_id} (${thisSlot.role || "unknown"}) has completed their work: ${summary}`,
1179
+ msgType, new Date().toISOString(), targetSlot.paused ? 1 : 0]
1180
+ );
1181
+ }
1182
+ }
1183
+
1184
+ return Response.json({ ok: true, task_state: "done_pending_review" });
1185
+ }
1186
+
1187
+ case "/lifecycle/submit-feedback": {
1188
+ const { peer_id, session_id, target_slot_id, feedback, actionable } = body;
1189
+ const peer = db.query("SELECT * FROM peers WHERE id = ?").get(peer_id) as any;
1190
+ const targetSlot = db.query("SELECT * FROM slots WHERE id = ?").get(target_slot_id) as any;
1191
+ if (!targetSlot) return Response.json({ error: "Target slot not found" }, { status: 404 });
1192
+
1193
+ if (actionable) {
1194
+ db.run("UPDATE slots SET task_state = 'addressing_feedback' WHERE id = ?", [target_slot_id]);
1195
+ }
1196
+
1197
+ db.run(
1198
+ "INSERT INTO messages (session_id, from_id, from_slot_id, to_id, to_slot_id, text, msg_type, sent_at, delivered, held) VALUES (?, ?, ?, ?, ?, ?, 'feedback', ?, 0, ?)",
1199
+ [session_id, peer_id, peer?.slot_id ?? null, targetSlot.peer_id, target_slot_id,
1200
+ feedback, new Date().toISOString(), targetSlot.paused ? 1 : 0]
1201
+ );
1202
+
1203
+ return Response.json({ ok: true, task_state: actionable ? "addressing_feedback" : targetSlot.task_state });
1204
+ }
1205
+
1206
+ case "/lifecycle/approve": {
1207
+ const { peer_id, session_id, target_slot_id, message } = body;
1208
+ const peer = db.query("SELECT * FROM peers WHERE id = ?").get(peer_id) as any;
1209
+ const targetSlot = db.query("SELECT * FROM slots WHERE id = ?").get(target_slot_id) as any;
1210
+ if (!targetSlot) return Response.json({ error: "Target slot not found" }, { status: 404 });
1211
+
1212
+ db.run("UPDATE slots SET task_state = 'approved' WHERE id = ?", [target_slot_id]);
1213
+
1214
+ const approverSlot = peer?.slot_id ? db.query("SELECT * FROM slots WHERE id = ?").get(peer.slot_id) as any : null;
1215
+ const approverName = approverSlot?.display_name || peer_id;
1216
+
1217
+ db.run(
1218
+ "INSERT INTO messages (session_id, from_id, from_slot_id, to_id, to_slot_id, text, msg_type, sent_at, delivered, held) VALUES (?, ?, ?, ?, ?, ?, 'approval', ?, 0, ?)",
1219
+ [session_id, peer_id, peer?.slot_id ?? null, targetSlot.peer_id, target_slot_id,
1220
+ `APPROVED by ${approverName}${message ? ": " + message : ""}. Your work has been approved.`,
1221
+ new Date().toISOString(), targetSlot.paused ? 1 : 0]
1222
+ );
1223
+
1224
+ return Response.json({ ok: true, task_state: "approved" });
1225
+ }
1226
+
1227
+ case "/lifecycle/release": {
1228
+ const { session_id, target_slot_id, released_by, message } = body;
1229
+ const targetSlot = db.query("SELECT * FROM slots WHERE id = ?").get(target_slot_id) as any;
1230
+ if (!targetSlot) return Response.json({ error: "Target slot not found" }, { status: 404 });
1231
+
1232
+ db.run("UPDATE slots SET task_state = 'released' WHERE id = ?", [target_slot_id]);
1233
+
1234
+ if (targetSlot.peer_id) {
1235
+ db.run(
1236
+ "INSERT INTO messages (session_id, from_id, from_slot_id, to_id, to_slot_id, text, msg_type, sent_at, delivered, held) VALUES (?, ?, NULL, ?, ?, ?, 'release', ?, 0, 0)",
1237
+ [session_id, released_by, targetSlot.peer_id, target_slot_id,
1238
+ `RELEASED: You are cleared to disconnect.${message ? " " + message : ""} Your work is complete. You may now exit.`,
1239
+ new Date().toISOString()]
1240
+ );
1241
+ }
1242
+
1243
+ return Response.json({ ok: true, task_state: "released" });
1244
+ }
1245
+
1246
+ case "/lifecycle/get-task-state": {
1247
+ const { slot_id } = body;
1248
+ const slot = db.query("SELECT id, task_state, display_name, role FROM slots WHERE id = ?").get(slot_id) as any;
1249
+ if (!slot) return Response.json({ error: "Slot not found" }, { status: 404 });
1250
+ return Response.json(slot);
1251
+ }
1252
+
1253
+ default:
1254
+ return Response.json({ error: "not found" }, { status: 404 });
1255
+ }
1256
+ } catch (e) {
1257
+ const msg = e instanceof Error ? e.message : String(e);
1258
+ return Response.json({ error: msg }, { status: 500 });
1259
+ }
1260
+ },
1261
+ });
1262
+
1263
+ console.error(`[multiagents broker] listening on 127.0.0.1:${PORT} (db: ${DB_PATH})`);