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,546 @@
1
+ /**
2
+ * Event Store - Append-only event log with PGLite
3
+ *
4
+ * Core operations:
5
+ * - append(): Add events to the log
6
+ * - read(): Read events with filters
7
+ * - replay(): Rebuild state from events
8
+ *
9
+ * All state changes go through events. Projections compute current state.
10
+ */
11
+ import { getDatabase } from "./index";
12
+ import {
13
+ type AgentEvent,
14
+ createEvent,
15
+ type AgentRegisteredEvent,
16
+ type MessageSentEvent,
17
+ type FileReservedEvent,
18
+ } from "./events";
19
+
20
+ // ============================================================================
21
+ // Event Store Operations
22
+ // ============================================================================
23
+
24
+ /**
25
+ * Append an event to the log
26
+ *
27
+ * Also updates materialized views (agents, messages, reservations)
28
+ */
29
+ export async function appendEvent(
30
+ event: AgentEvent,
31
+ projectPath?: string,
32
+ ): Promise<AgentEvent & { id: number; sequence: number }> {
33
+ const db = await getDatabase(projectPath);
34
+
35
+ // Extract common fields
36
+ const { type, project_key, timestamp, ...rest } = event;
37
+
38
+ // Insert event
39
+ const result = await db.query<{ id: number; sequence: number }>(
40
+ `INSERT INTO events (type, project_key, timestamp, data)
41
+ VALUES ($1, $2, $3, $4)
42
+ RETURNING id, sequence`,
43
+ [type, project_key, timestamp, JSON.stringify(rest)],
44
+ );
45
+
46
+ const row = result.rows[0];
47
+ if (!row) {
48
+ throw new Error("Failed to insert event - no row returned");
49
+ }
50
+ const { id, sequence } = row;
51
+
52
+ // Update materialized views based on event type
53
+ await updateMaterializedViews(db, { ...event, id, sequence });
54
+
55
+ return { ...event, id, sequence };
56
+ }
57
+
58
+ /**
59
+ * Append multiple events in a transaction
60
+ */
61
+ export async function appendEvents(
62
+ events: AgentEvent[],
63
+ projectPath?: string,
64
+ ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
65
+ const db = await getDatabase(projectPath);
66
+ const results: Array<AgentEvent & { id: number; sequence: number }> = [];
67
+
68
+ await db.exec("BEGIN");
69
+ try {
70
+ for (const event of events) {
71
+ const { type, project_key, timestamp, ...rest } = event;
72
+
73
+ const result = await db.query<{ id: number; sequence: number }>(
74
+ `INSERT INTO events (type, project_key, timestamp, data)
75
+ VALUES ($1, $2, $3, $4)
76
+ RETURNING id, sequence`,
77
+ [type, project_key, timestamp, JSON.stringify(rest)],
78
+ );
79
+
80
+ const row = result.rows[0];
81
+ if (!row) {
82
+ throw new Error("Failed to insert event - no row returned");
83
+ }
84
+ const { id, sequence } = row;
85
+ const enrichedEvent = { ...event, id, sequence };
86
+
87
+ await updateMaterializedViews(db, enrichedEvent);
88
+ results.push(enrichedEvent);
89
+ }
90
+ await db.exec("COMMIT");
91
+ } catch (error) {
92
+ await db.exec("ROLLBACK");
93
+ throw error;
94
+ }
95
+
96
+ return results;
97
+ }
98
+
99
+ /**
100
+ * Read events with optional filters
101
+ */
102
+ export async function readEvents(
103
+ options: {
104
+ projectKey?: string;
105
+ types?: AgentEvent["type"][];
106
+ since?: number; // timestamp
107
+ until?: number; // timestamp
108
+ afterSequence?: number;
109
+ limit?: number;
110
+ offset?: number;
111
+ } = {},
112
+ projectPath?: string,
113
+ ): Promise<Array<AgentEvent & { id: number; sequence: number }>> {
114
+ const db = await getDatabase(projectPath);
115
+
116
+ const conditions: string[] = [];
117
+ const params: unknown[] = [];
118
+ let paramIndex = 1;
119
+
120
+ if (options.projectKey) {
121
+ conditions.push(`project_key = $${paramIndex++}`);
122
+ params.push(options.projectKey);
123
+ }
124
+
125
+ if (options.types && options.types.length > 0) {
126
+ conditions.push(`type = ANY($${paramIndex++})`);
127
+ params.push(options.types);
128
+ }
129
+
130
+ if (options.since !== undefined) {
131
+ conditions.push(`timestamp >= $${paramIndex++}`);
132
+ params.push(options.since);
133
+ }
134
+
135
+ if (options.until !== undefined) {
136
+ conditions.push(`timestamp <= $${paramIndex++}`);
137
+ params.push(options.until);
138
+ }
139
+
140
+ if (options.afterSequence !== undefined) {
141
+ conditions.push(`sequence > $${paramIndex++}`);
142
+ params.push(options.afterSequence);
143
+ }
144
+
145
+ const whereClause =
146
+ conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
147
+
148
+ let query = `
149
+ SELECT id, type, project_key, timestamp, sequence, data
150
+ FROM events
151
+ ${whereClause}
152
+ ORDER BY sequence ASC
153
+ `;
154
+
155
+ if (options.limit) {
156
+ query += ` LIMIT $${paramIndex++}`;
157
+ params.push(options.limit);
158
+ }
159
+
160
+ if (options.offset) {
161
+ query += ` OFFSET $${paramIndex++}`;
162
+ params.push(options.offset);
163
+ }
164
+
165
+ const result = await db.query<{
166
+ id: number;
167
+ type: string;
168
+ project_key: string;
169
+ timestamp: string;
170
+ sequence: number;
171
+ data: string;
172
+ }>(query, params);
173
+
174
+ return result.rows.map((row) => {
175
+ const data = typeof row.data === "string" ? JSON.parse(row.data) : row.data;
176
+ return {
177
+ id: row.id,
178
+ type: row.type as AgentEvent["type"],
179
+ project_key: row.project_key,
180
+ timestamp: parseInt(row.timestamp as string),
181
+ sequence: row.sequence,
182
+ ...data,
183
+ } as AgentEvent & { id: number; sequence: number };
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Get the latest sequence number
189
+ */
190
+ export async function getLatestSequence(
191
+ projectKey?: string,
192
+ projectPath?: string,
193
+ ): Promise<number> {
194
+ const db = await getDatabase(projectPath);
195
+
196
+ const query = projectKey
197
+ ? "SELECT MAX(sequence) as seq FROM events WHERE project_key = $1"
198
+ : "SELECT MAX(sequence) as seq FROM events";
199
+
200
+ const params = projectKey ? [projectKey] : [];
201
+ const result = await db.query<{ seq: number | null }>(query, params);
202
+
203
+ return result.rows[0]?.seq ?? 0;
204
+ }
205
+
206
+ /**
207
+ * Replay events to rebuild materialized views
208
+ *
209
+ * Useful for:
210
+ * - Recovering from corruption
211
+ * - Migrating to new schema
212
+ * - Debugging state issues
213
+ */
214
+ export async function replayEvents(
215
+ options: {
216
+ projectKey?: string;
217
+ fromSequence?: number;
218
+ clearViews?: boolean;
219
+ } = {},
220
+ projectPath?: string,
221
+ ): Promise<{ eventsReplayed: number; duration: number }> {
222
+ const startTime = Date.now();
223
+ const db = await getDatabase(projectPath);
224
+
225
+ // Optionally clear materialized views
226
+ if (options.clearViews) {
227
+ if (options.projectKey) {
228
+ // Use parameterized queries to prevent SQL injection
229
+ await db.query(
230
+ `DELETE FROM message_recipients WHERE message_id IN (
231
+ SELECT id FROM messages WHERE project_key = $1
232
+ )`,
233
+ [options.projectKey],
234
+ );
235
+ await db.query(`DELETE FROM messages WHERE project_key = $1`, [
236
+ options.projectKey,
237
+ ]);
238
+ await db.query(`DELETE FROM reservations WHERE project_key = $1`, [
239
+ options.projectKey,
240
+ ]);
241
+ await db.query(`DELETE FROM agents WHERE project_key = $1`, [
242
+ options.projectKey,
243
+ ]);
244
+ } else {
245
+ await db.exec(`
246
+ DELETE FROM message_recipients;
247
+ DELETE FROM messages;
248
+ DELETE FROM reservations;
249
+ DELETE FROM agents;
250
+ `);
251
+ }
252
+ }
253
+
254
+ // Read all events
255
+ const events = await readEvents(
256
+ {
257
+ projectKey: options.projectKey,
258
+ afterSequence: options.fromSequence,
259
+ },
260
+ projectPath,
261
+ );
262
+
263
+ // Replay each event
264
+ for (const event of events) {
265
+ await updateMaterializedViews(db, event);
266
+ }
267
+
268
+ return {
269
+ eventsReplayed: events.length,
270
+ duration: Date.now() - startTime,
271
+ };
272
+ }
273
+
274
+ // ============================================================================
275
+ // Materialized View Updates
276
+ // ============================================================================
277
+
278
+ /**
279
+ * Update materialized views based on event type
280
+ *
281
+ * This is called after each event is appended.
282
+ * Views are denormalized for fast reads.
283
+ */
284
+ async function updateMaterializedViews(
285
+ db: Awaited<ReturnType<typeof getDatabase>>,
286
+ event: AgentEvent & { id: number; sequence: number },
287
+ ): Promise<void> {
288
+ switch (event.type) {
289
+ case "agent_registered":
290
+ await handleAgentRegistered(
291
+ db,
292
+ event as AgentRegisteredEvent & { id: number; sequence: number },
293
+ );
294
+ break;
295
+
296
+ case "agent_active":
297
+ await db.query(
298
+ `UPDATE agents SET last_active_at = $1 WHERE project_key = $2 AND name = $3`,
299
+ [event.timestamp, event.project_key, event.agent_name],
300
+ );
301
+ break;
302
+
303
+ case "message_sent":
304
+ await handleMessageSent(
305
+ db,
306
+ event as MessageSentEvent & { id: number; sequence: number },
307
+ );
308
+ break;
309
+
310
+ case "message_read":
311
+ await db.query(
312
+ `UPDATE message_recipients SET read_at = $1 WHERE message_id = $2 AND agent_name = $3`,
313
+ [event.timestamp, event.message_id, event.agent_name],
314
+ );
315
+ break;
316
+
317
+ case "message_acked":
318
+ await db.query(
319
+ `UPDATE message_recipients SET acked_at = $1 WHERE message_id = $2 AND agent_name = $3`,
320
+ [event.timestamp, event.message_id, event.agent_name],
321
+ );
322
+ break;
323
+
324
+ case "file_reserved":
325
+ await handleFileReserved(
326
+ db,
327
+ event as FileReservedEvent & { id: number; sequence: number },
328
+ );
329
+ break;
330
+
331
+ case "file_released":
332
+ await handleFileReleased(db, event);
333
+ break;
334
+
335
+ // Task events don't need materialized views (query events directly)
336
+ case "task_started":
337
+ case "task_progress":
338
+ case "task_completed":
339
+ case "task_blocked":
340
+ // No-op for now - could add task tracking table later
341
+ break;
342
+ }
343
+ }
344
+
345
+ async function handleAgentRegistered(
346
+ db: Awaited<ReturnType<typeof getDatabase>>,
347
+ event: AgentRegisteredEvent & { id: number; sequence: number },
348
+ ): Promise<void> {
349
+ await db.query(
350
+ `INSERT INTO agents (project_key, name, program, model, task_description, registered_at, last_active_at)
351
+ VALUES ($1, $2, $3, $4, $5, $6, $6)
352
+ ON CONFLICT (project_key, name) DO UPDATE SET
353
+ program = EXCLUDED.program,
354
+ model = EXCLUDED.model,
355
+ task_description = EXCLUDED.task_description,
356
+ last_active_at = EXCLUDED.last_active_at`,
357
+ [
358
+ event.project_key,
359
+ event.agent_name,
360
+ event.program,
361
+ event.model,
362
+ event.task_description || null,
363
+ event.timestamp,
364
+ ],
365
+ );
366
+ }
367
+
368
+ async function handleMessageSent(
369
+ db: Awaited<ReturnType<typeof getDatabase>>,
370
+ event: MessageSentEvent & { id: number; sequence: number },
371
+ ): Promise<void> {
372
+ // Insert message
373
+ const result = await db.query<{ id: number }>(
374
+ `INSERT INTO messages (project_key, from_agent, subject, body, thread_id, importance, ack_required, created_at)
375
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
376
+ RETURNING id`,
377
+ [
378
+ event.project_key,
379
+ event.from_agent,
380
+ event.subject,
381
+ event.body,
382
+ event.thread_id || null,
383
+ event.importance,
384
+ event.ack_required,
385
+ event.timestamp,
386
+ ],
387
+ );
388
+
389
+ const msgRow = result.rows[0];
390
+ if (!msgRow) {
391
+ throw new Error("Failed to insert message - no row returned");
392
+ }
393
+ const messageId = msgRow.id;
394
+
395
+ // Insert recipients
396
+ for (const agent of event.to_agents) {
397
+ await db.query(
398
+ `INSERT INTO message_recipients (message_id, agent_name)
399
+ VALUES ($1, $2)
400
+ ON CONFLICT DO NOTHING`,
401
+ [messageId, agent],
402
+ );
403
+ }
404
+ }
405
+
406
+ async function handleFileReserved(
407
+ db: Awaited<ReturnType<typeof getDatabase>>,
408
+ event: FileReservedEvent & { id: number; sequence: number },
409
+ ): Promise<void> {
410
+ for (const path of event.paths) {
411
+ await db.query(
412
+ `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, reason, created_at, expires_at)
413
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
414
+ [
415
+ event.project_key,
416
+ event.agent_name,
417
+ path,
418
+ event.exclusive,
419
+ event.reason || null,
420
+ event.timestamp,
421
+ event.expires_at,
422
+ ],
423
+ );
424
+ }
425
+ }
426
+
427
+ async function handleFileReleased(
428
+ db: Awaited<ReturnType<typeof getDatabase>>,
429
+ event: AgentEvent & { id: number; sequence: number },
430
+ ): Promise<void> {
431
+ if (event.type !== "file_released") return;
432
+
433
+ if (event.reservation_ids && event.reservation_ids.length > 0) {
434
+ // Release specific reservations
435
+ await db.query(
436
+ `UPDATE reservations SET released_at = $1 WHERE id = ANY($2)`,
437
+ [event.timestamp, event.reservation_ids],
438
+ );
439
+ } else if (event.paths && event.paths.length > 0) {
440
+ // Release by path
441
+ await db.query(
442
+ `UPDATE reservations SET released_at = $1
443
+ WHERE project_key = $2 AND agent_name = $3 AND path_pattern = ANY($4) AND released_at IS NULL`,
444
+ [event.timestamp, event.project_key, event.agent_name, event.paths],
445
+ );
446
+ } else {
447
+ // Release all for agent
448
+ await db.query(
449
+ `UPDATE reservations SET released_at = $1
450
+ WHERE project_key = $2 AND agent_name = $3 AND released_at IS NULL`,
451
+ [event.timestamp, event.project_key, event.agent_name],
452
+ );
453
+ }
454
+ }
455
+
456
+ // ============================================================================
457
+ // Convenience Functions
458
+ // ============================================================================
459
+
460
+ /**
461
+ * Register an agent (creates event + updates view)
462
+ */
463
+ export async function registerAgent(
464
+ projectKey: string,
465
+ agentName: string,
466
+ options: {
467
+ program?: string;
468
+ model?: string;
469
+ taskDescription?: string;
470
+ } = {},
471
+ projectPath?: string,
472
+ ): Promise<AgentRegisteredEvent & { id: number; sequence: number }> {
473
+ const event = createEvent("agent_registered", {
474
+ project_key: projectKey,
475
+ agent_name: agentName,
476
+ program: options.program || "opencode",
477
+ model: options.model || "unknown",
478
+ task_description: options.taskDescription,
479
+ });
480
+
481
+ return appendEvent(event, projectPath) as Promise<
482
+ AgentRegisteredEvent & { id: number; sequence: number }
483
+ >;
484
+ }
485
+
486
+ /**
487
+ * Send a message (creates event + updates view)
488
+ */
489
+ export async function sendMessage(
490
+ projectKey: string,
491
+ fromAgent: string,
492
+ toAgents: string[],
493
+ subject: string,
494
+ body: string,
495
+ options: {
496
+ threadId?: string;
497
+ importance?: "low" | "normal" | "high" | "urgent";
498
+ ackRequired?: boolean;
499
+ } = {},
500
+ projectPath?: string,
501
+ ): Promise<MessageSentEvent & { id: number; sequence: number }> {
502
+ const event = createEvent("message_sent", {
503
+ project_key: projectKey,
504
+ from_agent: fromAgent,
505
+ to_agents: toAgents,
506
+ subject,
507
+ body,
508
+ thread_id: options.threadId,
509
+ importance: options.importance || "normal",
510
+ ack_required: options.ackRequired || false,
511
+ });
512
+
513
+ return appendEvent(event, projectPath) as Promise<
514
+ MessageSentEvent & { id: number; sequence: number }
515
+ >;
516
+ }
517
+
518
+ /**
519
+ * Reserve files (creates event + updates view)
520
+ */
521
+ export async function reserveFiles(
522
+ projectKey: string,
523
+ agentName: string,
524
+ paths: string[],
525
+ options: {
526
+ reason?: string;
527
+ exclusive?: boolean;
528
+ ttlSeconds?: number;
529
+ } = {},
530
+ projectPath?: string,
531
+ ): Promise<FileReservedEvent & { id: number; sequence: number }> {
532
+ const ttlSeconds = options.ttlSeconds || 3600;
533
+ const event = createEvent("file_reserved", {
534
+ project_key: projectKey,
535
+ agent_name: agentName,
536
+ paths,
537
+ reason: options.reason,
538
+ exclusive: options.exclusive ?? true,
539
+ ttl_seconds: ttlSeconds,
540
+ expires_at: Date.now() + ttlSeconds * 1000,
541
+ });
542
+
543
+ return appendEvent(event, projectPath) as Promise<
544
+ FileReservedEvent & { id: number; sequence: number }
545
+ >;
546
+ }