opencode-swarm-plugin 0.12.30 → 0.13.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.
Files changed (48) hide show
  1. package/.beads/issues.jsonl +204 -10
  2. package/.opencode/skills/tdd/SKILL.md +182 -0
  3. package/README.md +165 -17
  4. package/bin/swarm.ts +120 -31
  5. package/bun.lock +23 -0
  6. package/dist/index.js +4020 -438
  7. package/dist/pglite.data +0 -0
  8. package/dist/pglite.wasm +0 -0
  9. package/dist/plugin.js +4008 -514
  10. package/examples/commands/swarm.md +114 -19
  11. package/examples/skills/beads-workflow/SKILL.md +75 -28
  12. package/examples/skills/swarm-coordination/SKILL.md +92 -1
  13. package/global-skills/testing-patterns/SKILL.md +430 -0
  14. package/global-skills/testing-patterns/references/dependency-breaking-catalog.md +586 -0
  15. package/package.json +11 -5
  16. package/src/index.ts +44 -5
  17. package/src/streams/agent-mail.test.ts +777 -0
  18. package/src/streams/agent-mail.ts +535 -0
  19. package/src/streams/debug.test.ts +500 -0
  20. package/src/streams/debug.ts +629 -0
  21. package/src/streams/effect/ask.integration.test.ts +314 -0
  22. package/src/streams/effect/ask.ts +202 -0
  23. package/src/streams/effect/cursor.integration.test.ts +418 -0
  24. package/src/streams/effect/cursor.ts +288 -0
  25. package/src/streams/effect/deferred.test.ts +357 -0
  26. package/src/streams/effect/deferred.ts +445 -0
  27. package/src/streams/effect/index.ts +17 -0
  28. package/src/streams/effect/layers.ts +73 -0
  29. package/src/streams/effect/lock.test.ts +385 -0
  30. package/src/streams/effect/lock.ts +399 -0
  31. package/src/streams/effect/mailbox.test.ts +260 -0
  32. package/src/streams/effect/mailbox.ts +318 -0
  33. package/src/streams/events.test.ts +628 -0
  34. package/src/streams/events.ts +214 -0
  35. package/src/streams/index.test.ts +229 -0
  36. package/src/streams/index.ts +492 -0
  37. package/src/streams/migrations.test.ts +355 -0
  38. package/src/streams/migrations.ts +269 -0
  39. package/src/streams/projections.test.ts +611 -0
  40. package/src/streams/projections.ts +302 -0
  41. package/src/streams/store.integration.test.ts +548 -0
  42. package/src/streams/store.ts +546 -0
  43. package/src/streams/swarm-mail.ts +552 -0
  44. package/src/swarm-mail.integration.test.ts +970 -0
  45. package/src/swarm-mail.ts +739 -0
  46. package/src/swarm.ts +84 -59
  47. package/src/tool-availability.ts +35 -2
  48. package/global-skills/mcp-tool-authoring/SKILL.md +0 -695
@@ -0,0 +1,492 @@
1
+ /**
2
+ * PGLite Event Store Setup
3
+ *
4
+ * Embedded PostgreSQL database for event sourcing.
5
+ * No external server required - runs in-process.
6
+ *
7
+ * Database location: .opencode/streams.db (project-local)
8
+ * or ~/.opencode/streams.db (global fallback)
9
+ */
10
+ import { PGlite } from "@electric-sql/pglite";
11
+ import { existsSync, mkdirSync, appendFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+
15
+ // ============================================================================
16
+ // Debug Logging
17
+ // ============================================================================
18
+
19
+ const DEBUG_LOG_PATH = join(homedir(), ".opencode", "streams-debug.log");
20
+
21
+ function debugLog(message: string, data?: unknown): void {
22
+ const timestamp = new Date().toISOString();
23
+ const logLine = data
24
+ ? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
25
+ : `[${timestamp}] ${message}\n`;
26
+ try {
27
+ appendFileSync(DEBUG_LOG_PATH, logLine);
28
+ } catch {
29
+ // Ignore write errors
30
+ }
31
+ }
32
+
33
+ // ============================================================================
34
+ // Configuration
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Get the database path for a project
39
+ *
40
+ * Prefers project-local .opencode/streams.db
41
+ * Falls back to global ~/.opencode/streams.db
42
+ */
43
+ export function getDatabasePath(projectPath?: string): string {
44
+ // Try project-local first
45
+ if (projectPath) {
46
+ const localDir = join(projectPath, ".opencode");
47
+ if (existsSync(localDir) || existsSync(projectPath)) {
48
+ if (!existsSync(localDir)) {
49
+ mkdirSync(localDir, { recursive: true });
50
+ }
51
+ return join(localDir, "streams");
52
+ }
53
+ }
54
+
55
+ // Fall back to global
56
+ const globalDir = join(homedir(), ".opencode");
57
+ if (!existsSync(globalDir)) {
58
+ mkdirSync(globalDir, { recursive: true });
59
+ }
60
+ return join(globalDir, "streams");
61
+ }
62
+
63
+ // ============================================================================
64
+ // Database Instance Management
65
+ // ============================================================================
66
+
67
+ /** Singleton database instances keyed by path */
68
+ const instances = new Map<string, PGlite>();
69
+
70
+ /** Pending database initialization promises to prevent race conditions */
71
+ const pendingInstances = new Map<string, Promise<PGlite>>();
72
+
73
+ /** Whether schema has been initialized for each instance */
74
+ const schemaInitialized = new Map<string, boolean>();
75
+
76
+ /** Track degraded instances (path -> error) */
77
+ const degradedInstances = new Map<string, Error>();
78
+
79
+ /** LRU tracking: path -> last access timestamp */
80
+ const lastAccess = new Map<string, number>();
81
+
82
+ /** Maximum number of cached database instances */
83
+ const MAX_CACHE_SIZE = 10;
84
+
85
+ /**
86
+ * Evict least recently used instance if cache is full
87
+ */
88
+ function evictLRU(): void {
89
+ if (instances.size < MAX_CACHE_SIZE) {
90
+ return;
91
+ }
92
+
93
+ let oldestPath: string | null = null;
94
+ let oldestTime = Number.POSITIVE_INFINITY;
95
+
96
+ for (const [path, time] of lastAccess) {
97
+ if (time < oldestTime) {
98
+ oldestTime = time;
99
+ oldestPath = path;
100
+ }
101
+ }
102
+
103
+ if (oldestPath) {
104
+ const db = instances.get(oldestPath);
105
+ if (db) {
106
+ db.close().catch((err) => {
107
+ console.error(
108
+ `[swarm-mail] Failed to close evicted database: ${err.message}`,
109
+ );
110
+ });
111
+ }
112
+ instances.delete(oldestPath);
113
+ pendingInstances.delete(oldestPath);
114
+ schemaInitialized.delete(oldestPath);
115
+ degradedInstances.delete(oldestPath);
116
+ lastAccess.delete(oldestPath);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Get or create a PGLite instance for the given path
122
+ *
123
+ * If initialization fails, falls back to in-memory database and marks instance as degraded.
124
+ *
125
+ * Uses Promise-based caching to prevent race conditions when multiple concurrent
126
+ * calls occur before the first one completes.
127
+ */
128
+ export async function getDatabase(projectPath?: string): Promise<PGlite> {
129
+ const dbPath = getDatabasePath(projectPath);
130
+
131
+ // Return existing instance if available
132
+ const existingDb = instances.get(dbPath);
133
+ if (existingDb) {
134
+ lastAccess.set(dbPath, Date.now());
135
+ return existingDb;
136
+ }
137
+
138
+ // Return pending promise if initialization is in progress (fixes race condition)
139
+ const pendingPromise = pendingInstances.get(dbPath);
140
+ if (pendingPromise) {
141
+ return pendingPromise;
142
+ }
143
+
144
+ // Create new initialization promise
145
+ const initPromise = createDatabaseInstance(dbPath);
146
+ pendingInstances.set(dbPath, initPromise);
147
+
148
+ try {
149
+ const db = await initPromise;
150
+ instances.set(dbPath, db);
151
+ lastAccess.set(dbPath, Date.now());
152
+ return db;
153
+ } finally {
154
+ // Clean up pending promise once resolved/rejected
155
+ pendingInstances.delete(dbPath);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Create and initialize a database instance
161
+ *
162
+ * Separated from getDatabase for cleaner Promise-based caching logic
163
+ */
164
+ async function createDatabaseInstance(dbPath: string): Promise<PGlite> {
165
+ // Evict LRU if cache is full
166
+ evictLRU();
167
+
168
+ debugLog("createDatabaseInstance called", { dbPath, cwd: process.cwd() });
169
+
170
+ let db: PGlite;
171
+
172
+ // Try to create new instance
173
+ try {
174
+ debugLog("Creating PGlite instance", { dbPath });
175
+ db = new PGlite(dbPath);
176
+ debugLog("PGlite instance created successfully");
177
+
178
+ // Initialize schema if needed
179
+ if (!schemaInitialized.get(dbPath)) {
180
+ debugLog("Initializing schema");
181
+ await initializeSchema(db);
182
+ schemaInitialized.set(dbPath, true);
183
+ debugLog("Schema initialized");
184
+ }
185
+
186
+ return db;
187
+ } catch (error) {
188
+ const err = error as Error;
189
+ debugLog("Failed to initialize database", {
190
+ dbPath,
191
+ error: err.message,
192
+ stack: err.stack,
193
+ });
194
+ console.error(
195
+ `[swarm-mail] Failed to initialize database at ${dbPath}:`,
196
+ err.message,
197
+ );
198
+ degradedInstances.set(dbPath, err);
199
+
200
+ // Fall back to in-memory database
201
+ console.warn(
202
+ `[swarm-mail] Falling back to in-memory database (data will not persist)`,
203
+ );
204
+
205
+ try {
206
+ db = new PGlite(); // in-memory mode
207
+
208
+ // Initialize schema for in-memory instance
209
+ await initializeSchema(db);
210
+ schemaInitialized.set(dbPath, true);
211
+
212
+ return db;
213
+ } catch (fallbackError) {
214
+ const fallbackErr = fallbackError as Error;
215
+ console.error(
216
+ `[swarm-mail] CRITICAL: In-memory fallback failed:`,
217
+ fallbackErr.message,
218
+ );
219
+ throw new Error(
220
+ `Database initialization failed: ${err.message}. Fallback also failed: ${fallbackErr.message}`,
221
+ );
222
+ }
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Close a database instance
228
+ */
229
+ export async function closeDatabase(projectPath?: string): Promise<void> {
230
+ const dbPath = getDatabasePath(projectPath);
231
+ const db = instances.get(dbPath);
232
+ if (db) {
233
+ await db.close();
234
+ instances.delete(dbPath);
235
+ pendingInstances.delete(dbPath);
236
+ schemaInitialized.delete(dbPath);
237
+ degradedInstances.delete(dbPath);
238
+ lastAccess.delete(dbPath);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Close all database instances
244
+ */
245
+ export async function closeAllDatabases(): Promise<void> {
246
+ for (const [path, db] of instances) {
247
+ await db.close();
248
+ instances.delete(path);
249
+ schemaInitialized.delete(path);
250
+ }
251
+ pendingInstances.clear();
252
+ degradedInstances.clear();
253
+ lastAccess.clear();
254
+ }
255
+
256
+ /**
257
+ * Reset database for testing - clears all data but keeps schema
258
+ */
259
+ export async function resetDatabase(projectPath?: string): Promise<void> {
260
+ const db = await getDatabase(projectPath);
261
+ await db.exec(`
262
+ DELETE FROM message_recipients;
263
+ DELETE FROM messages;
264
+ DELETE FROM reservations;
265
+ DELETE FROM agents;
266
+ DELETE FROM events;
267
+ DELETE FROM locks;
268
+ DELETE FROM cursors;
269
+ `);
270
+ }
271
+
272
+ // ============================================================================
273
+ // Schema Initialization
274
+ // ============================================================================
275
+
276
+ /**
277
+ * Initialize the database schema
278
+ *
279
+ * Creates tables for:
280
+ * - events: The append-only event log
281
+ * - agents: Materialized view of registered agents
282
+ * - messages: Materialized view of messages
283
+ * - reservations: Materialized view of file reservations
284
+ * - cursors, deferred: Effect-TS durable primitives (via migrations)
285
+ * - locks: Distributed mutual exclusion (DurableLock)
286
+ */
287
+ async function initializeSchema(db: PGlite): Promise<void> {
288
+ // Create core event store tables
289
+ await db.exec(`
290
+ -- Events table: The source of truth (append-only)
291
+ CREATE TABLE IF NOT EXISTS events (
292
+ id SERIAL PRIMARY KEY,
293
+ type TEXT NOT NULL,
294
+ project_key TEXT NOT NULL,
295
+ timestamp BIGINT NOT NULL,
296
+ sequence SERIAL,
297
+ data JSONB NOT NULL,
298
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
299
+ );
300
+
301
+ -- Index for efficient queries
302
+ CREATE INDEX IF NOT EXISTS idx_events_project_key ON events(project_key);
303
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
304
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
305
+ CREATE INDEX IF NOT EXISTS idx_events_project_type ON events(project_key, type);
306
+
307
+ -- Agents materialized view (rebuilt from events)
308
+ CREATE TABLE IF NOT EXISTS agents (
309
+ id SERIAL PRIMARY KEY,
310
+ project_key TEXT NOT NULL,
311
+ name TEXT NOT NULL,
312
+ program TEXT DEFAULT 'opencode',
313
+ model TEXT DEFAULT 'unknown',
314
+ task_description TEXT,
315
+ registered_at BIGINT NOT NULL,
316
+ last_active_at BIGINT NOT NULL,
317
+ UNIQUE(project_key, name)
318
+ );
319
+
320
+ CREATE INDEX IF NOT EXISTS idx_agents_project ON agents(project_key);
321
+
322
+ -- Messages materialized view
323
+ CREATE TABLE IF NOT EXISTS messages (
324
+ id SERIAL PRIMARY KEY,
325
+ project_key TEXT NOT NULL,
326
+ from_agent TEXT NOT NULL,
327
+ subject TEXT NOT NULL,
328
+ body TEXT NOT NULL,
329
+ thread_id TEXT,
330
+ importance TEXT DEFAULT 'normal',
331
+ ack_required BOOLEAN DEFAULT FALSE,
332
+ created_at BIGINT NOT NULL
333
+ );
334
+
335
+ CREATE INDEX IF NOT EXISTS idx_messages_project ON messages(project_key);
336
+ CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
337
+ CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC);
338
+
339
+ -- Message recipients (many-to-many)
340
+ CREATE TABLE IF NOT EXISTS message_recipients (
341
+ message_id INTEGER REFERENCES messages(id) ON DELETE CASCADE,
342
+ agent_name TEXT NOT NULL,
343
+ read_at BIGINT,
344
+ acked_at BIGINT,
345
+ PRIMARY KEY(message_id, agent_name)
346
+ );
347
+
348
+ CREATE INDEX IF NOT EXISTS idx_recipients_agent ON message_recipients(agent_name);
349
+
350
+ -- File reservations materialized view
351
+ CREATE TABLE IF NOT EXISTS reservations (
352
+ id SERIAL PRIMARY KEY,
353
+ project_key TEXT NOT NULL,
354
+ agent_name TEXT NOT NULL,
355
+ path_pattern TEXT NOT NULL,
356
+ exclusive BOOLEAN DEFAULT TRUE,
357
+ reason TEXT,
358
+ created_at BIGINT NOT NULL,
359
+ expires_at BIGINT NOT NULL,
360
+ released_at BIGINT
361
+ );
362
+
363
+ CREATE INDEX IF NOT EXISTS idx_reservations_project ON reservations(project_key);
364
+ CREATE INDEX IF NOT EXISTS idx_reservations_agent ON reservations(agent_name);
365
+ CREATE INDEX IF NOT EXISTS idx_reservations_expires ON reservations(expires_at);
366
+ CREATE INDEX IF NOT EXISTS idx_reservations_active ON reservations(project_key, released_at) WHERE released_at IS NULL;
367
+
368
+ -- Locks table for distributed mutual exclusion (DurableLock)
369
+ CREATE TABLE IF NOT EXISTS locks (
370
+ resource TEXT PRIMARY KEY,
371
+ holder TEXT NOT NULL,
372
+ seq INTEGER NOT NULL DEFAULT 0,
373
+ acquired_at BIGINT NOT NULL,
374
+ expires_at BIGINT NOT NULL
375
+ );
376
+
377
+ CREATE INDEX IF NOT EXISTS idx_locks_expires ON locks(expires_at);
378
+ CREATE INDEX IF NOT EXISTS idx_locks_holder ON locks(holder);
379
+ `);
380
+
381
+ // Run schema migrations for Effect-TS durable primitives (cursors, deferred)
382
+ const { runMigrations } = await import("./migrations");
383
+ await runMigrations(db);
384
+ }
385
+
386
+ // ============================================================================
387
+ // Health Check
388
+ // ============================================================================
389
+
390
+ /**
391
+ * Check if the database is healthy
392
+ *
393
+ * Returns false if database is in degraded mode (using in-memory fallback)
394
+ */
395
+ export async function isDatabaseHealthy(
396
+ projectPath?: string,
397
+ ): Promise<boolean> {
398
+ const dbPath = getDatabasePath(projectPath);
399
+
400
+ // Check if instance is degraded
401
+ if (degradedInstances.has(dbPath)) {
402
+ const err = degradedInstances.get(dbPath);
403
+ console.error(
404
+ `[swarm-mail] Database is in degraded mode (using in-memory fallback). Original error: ${err?.message}`,
405
+ );
406
+ return false;
407
+ }
408
+
409
+ try {
410
+ const db = await getDatabase(projectPath);
411
+ const result = await db.query("SELECT 1 as ok");
412
+ return result.rows.length > 0;
413
+ } catch (error) {
414
+ const err = error as Error;
415
+ console.error(`[swarm-mail] Health check failed: ${err.message}`);
416
+ return false;
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Get database statistics
422
+ */
423
+ export async function getDatabaseStats(projectPath?: string): Promise<{
424
+ events: number;
425
+ agents: number;
426
+ messages: number;
427
+ reservations: number;
428
+ }> {
429
+ const db = await getDatabase(projectPath);
430
+
431
+ const [events, agents, messages, reservations] = await Promise.all([
432
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM events"),
433
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM agents"),
434
+ db.query<{ count: string }>("SELECT COUNT(*) as count FROM messages"),
435
+ db.query<{ count: string }>(
436
+ "SELECT COUNT(*) as count FROM reservations WHERE released_at IS NULL",
437
+ ),
438
+ ]);
439
+
440
+ return {
441
+ events: parseInt(events.rows[0]?.count || "0"),
442
+ agents: parseInt(agents.rows[0]?.count || "0"),
443
+ messages: parseInt(messages.rows[0]?.count || "0"),
444
+ reservations: parseInt(reservations.rows[0]?.count || "0"),
445
+ };
446
+ }
447
+
448
+ // ============================================================================
449
+ // Process Exit Handlers
450
+ // ============================================================================
451
+
452
+ /**
453
+ * Close all databases on process exit
454
+ */
455
+ function handleExit() {
456
+ // Use sync version if available, otherwise fire-and-forget
457
+ const dbsToClose = Array.from(instances.values());
458
+ for (const db of dbsToClose) {
459
+ try {
460
+ // PGlite doesn't have a sync close, so we just attempt async
461
+ db.close().catch(() => {
462
+ // Ignore errors during shutdown
463
+ });
464
+ } catch {
465
+ // Ignore errors
466
+ }
467
+ }
468
+ }
469
+
470
+ // Register exit handlers
471
+ process.on("exit", handleExit);
472
+ process.on("SIGINT", () => {
473
+ handleExit();
474
+ process.exit(0);
475
+ });
476
+ process.on("SIGTERM", () => {
477
+ handleExit();
478
+ process.exit(0);
479
+ });
480
+
481
+ // ============================================================================
482
+ // Exports
483
+ // ============================================================================
484
+
485
+ export { PGlite };
486
+ export * from "./agent-mail";
487
+ export * from "./debug";
488
+ export * from "./events";
489
+ export * from "./migrations";
490
+ export * from "./projections";
491
+ export * from "./store";
492
+ export * from "./swarm-mail";