opencode-swarm-plugin 0.45.0 → 0.45.2

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.
@@ -1,16 +1,37 @@
1
1
  /**
2
- * OpenCode Swarm Plugin Wrapper
2
+ * ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ * ║ ║
4
+ * ║ 🐝 OPENCODE SWARM PLUGIN WRAPPER 🐝 ║
5
+ * ║ ║
6
+ * ║ This file lives at: ~/.config/opencode/plugin/swarm.ts ║
7
+ * ║ Generated by: swarm setup ║
8
+ * ║ ║
9
+ * ╠═══════════════════════════════════════════════════════════════════════════╣
10
+ * ║ ║
11
+ * ║ ⚠️ CRITICAL: THIS FILE MUST BE 100% SELF-CONTAINED ⚠️ ║
12
+ * ║ ║
13
+ * ║ ❌ NEVER import from "opencode-swarm-plugin" npm package ║
14
+ * ║ ❌ NEVER import from any package with transitive deps (evalite, etc) ║
15
+ * ║ ❌ NEVER add dependencies that aren't provided by OpenCode ║
16
+ * ║ ║
17
+ * ║ ✅ ONLY import from: @opencode-ai/plugin, @opencode-ai/sdk, node:* ║
18
+ * ║ ✅ Shell out to `swarm` CLI for all tool execution ║
19
+ * ║ ✅ Inline any logic that would otherwise require imports ║
20
+ * ║ ║
21
+ * ║ WHY? The npm package has dependencies (evalite, etc) that aren't ║
22
+ * ║ available in OpenCode's plugin context. Importing causes: ║
23
+ * ║ "Cannot find module 'evalite/runner'" → trace trap → OpenCode crash ║
24
+ * ║ ║
25
+ * ║ PATTERN: Plugin wrapper is DUMB. CLI is SMART. ║
26
+ * ║ - Wrapper: thin shell, no logic, just bridges to CLI ║
27
+ * ║ - CLI: all the smarts, all the deps, runs in its own context ║
28
+ * ║ ║
29
+ * ╚═══════════════════════════════════════════════════════════════════════════╝
3
30
  *
4
- * This is a thin wrapper that shells out to the `swarm` CLI for all tool execution.
5
- * Generated by: swarm setup
6
- *
7
- * The plugin only depends on @opencode-ai/plugin (provided by OpenCode).
8
- * All tool logic lives in the npm package - this just bridges to it.
9
- *
10
- * Environment variables:
11
- * - OPENCODE_SESSION_ID: Passed to CLI for session state persistence
12
- * - OPENCODE_MESSAGE_ID: Passed to CLI for context
13
- * - OPENCODE_AGENT: Passed to CLI for context
31
+ * Environment variables passed to CLI:
32
+ * - OPENCODE_SESSION_ID: Session state persistence
33
+ * - OPENCODE_MESSAGE_ID: Message context
34
+ * - OPENCODE_AGENT: Agent context
14
35
  * - SWARM_PROJECT_DIR: Project directory (critical for database path)
15
36
  */
16
37
  import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin";
@@ -21,15 +42,267 @@ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
21
42
  import { join } from "node:path";
22
43
  import { homedir } from "node:os";
23
44
 
24
- // Import swarm signature projection for deterministic swarm detection
25
- import {
26
- projectSwarmState,
27
- hasSwarmSignature,
28
- isSwarmActive,
29
- getSwarmSummary,
30
- type SwarmProjection,
31
- type ToolCallEvent,
32
- } from "opencode-swarm-plugin";
45
+ // =============================================================================
46
+ // Swarm Signature Detection (INLINED - do not import from opencode-swarm-plugin)
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Subtask lifecycle status derived from events
51
+ */
52
+ type SubtaskStatus = "created" | "spawned" | "in_progress" | "completed" | "closed";
53
+
54
+ /**
55
+ * Subtask state projected from events
56
+ */
57
+ interface SubtaskState {
58
+ id: string;
59
+ title: string;
60
+ status: SubtaskStatus;
61
+ files: string[];
62
+ worker?: string;
63
+ spawnedAt?: number;
64
+ completedAt?: number;
65
+ }
66
+
67
+ /**
68
+ * Epic state projected from events
69
+ */
70
+ interface EpicState {
71
+ id: string;
72
+ title: string;
73
+ status: "open" | "in_progress" | "closed";
74
+ createdAt: number;
75
+ }
76
+
77
+ /**
78
+ * Complete swarm state projected from session events
79
+ */
80
+ interface SwarmProjection {
81
+ isSwarm: boolean;
82
+ epic?: EpicState;
83
+ subtasks: Map<string, SubtaskState>;
84
+ projectPath?: string;
85
+ coordinatorName?: string;
86
+ lastEventAt?: number;
87
+ counts: {
88
+ total: number;
89
+ created: number;
90
+ spawned: number;
91
+ inProgress: number;
92
+ completed: number;
93
+ closed: number;
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Tool call event extracted from session messages
99
+ */
100
+ interface ToolCallEvent {
101
+ tool: string;
102
+ input: Record<string, unknown>;
103
+ output: string;
104
+ timestamp: number;
105
+ }
106
+
107
+ /** Parse epic ID from hive_create_epic output */
108
+ function parseEpicId(output: string): string | undefined {
109
+ try {
110
+ const parsed = JSON.parse(output);
111
+ return parsed.epic?.id || parsed.id;
112
+ } catch {
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /** Parse subtask IDs from hive_create_epic output */
118
+ function parseSubtaskIds(output: string): string[] {
119
+ try {
120
+ const parsed = JSON.parse(output);
121
+ const subtasks = parsed.subtasks || parsed.epic?.subtasks || [];
122
+ return subtasks
123
+ .map((s: unknown) => {
124
+ if (typeof s === "object" && s !== null && "id" in s) {
125
+ return (s as { id: string }).id;
126
+ }
127
+ return undefined;
128
+ })
129
+ .filter((id: unknown): id is string => typeof id === "string");
130
+ } catch {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Project swarm state from session tool call events
137
+ */
138
+ function projectSwarmState(events: ToolCallEvent[]): SwarmProjection {
139
+ const state: SwarmProjection = {
140
+ isSwarm: false,
141
+ subtasks: new Map(),
142
+ counts: { total: 0, created: 0, spawned: 0, inProgress: 0, completed: 0, closed: 0 },
143
+ };
144
+
145
+ let hasEpic = false;
146
+ let hasSpawn = false;
147
+
148
+ for (const event of events) {
149
+ state.lastEventAt = event.timestamp;
150
+
151
+ switch (event.tool) {
152
+ case "hive_create_epic": {
153
+ const epicId = parseEpicId(event.output);
154
+ const epicTitle = typeof event.input.epic_title === "string" ? event.input.epic_title : undefined;
155
+
156
+ if (epicId) {
157
+ state.epic = { id: epicId, title: epicTitle || "Unknown Epic", status: "open", createdAt: event.timestamp };
158
+ hasEpic = true;
159
+
160
+ const subtasks = event.input.subtasks;
161
+ if (Array.isArray(subtasks)) {
162
+ for (const subtask of subtasks) {
163
+ if (typeof subtask === "object" && subtask !== null) {
164
+ state.counts.created++;
165
+ state.counts.total++;
166
+ }
167
+ }
168
+ }
169
+
170
+ const subtaskIds = parseSubtaskIds(event.output);
171
+ for (const id of subtaskIds) {
172
+ if (!state.subtasks.has(id)) {
173
+ state.subtasks.set(id, { id, title: "Unknown", status: "created", files: [] });
174
+ state.counts.total++;
175
+ state.counts.created++;
176
+ }
177
+ }
178
+ }
179
+ break;
180
+ }
181
+
182
+ case "swarm_spawn_subtask": {
183
+ const beadId = typeof event.input.bead_id === "string" ? event.input.bead_id : undefined;
184
+ const title = typeof event.input.subtask_title === "string" ? event.input.subtask_title : "Unknown";
185
+ const files = Array.isArray(event.input.files) ? (event.input.files as string[]) : [];
186
+
187
+ if (beadId) {
188
+ hasSpawn = true;
189
+ const existing = state.subtasks.get(beadId);
190
+ if (existing) {
191
+ if (existing.status === "created") { state.counts.created--; state.counts.spawned++; }
192
+ existing.status = "spawned";
193
+ existing.title = title;
194
+ existing.files = files;
195
+ existing.spawnedAt = event.timestamp;
196
+ } else {
197
+ state.subtasks.set(beadId, { id: beadId, title, status: "spawned", files, spawnedAt: event.timestamp });
198
+ state.counts.total++;
199
+ state.counts.spawned++;
200
+ }
201
+
202
+ const epicId = typeof event.input.epic_id === "string" ? event.input.epic_id : undefined;
203
+ if (epicId && !state.epic) {
204
+ state.epic = { id: epicId, title: "Unknown Epic", status: "in_progress", createdAt: event.timestamp };
205
+ }
206
+ }
207
+ break;
208
+ }
209
+
210
+ case "hive_start": {
211
+ const id = typeof event.input.id === "string" ? event.input.id : undefined;
212
+ if (id) {
213
+ const subtask = state.subtasks.get(id);
214
+ if (subtask && subtask.status !== "completed" && subtask.status !== "closed") {
215
+ if (subtask.status === "created") state.counts.created--;
216
+ else if (subtask.status === "spawned") state.counts.spawned--;
217
+ subtask.status = "in_progress";
218
+ state.counts.inProgress++;
219
+ }
220
+ if (state.epic && state.epic.id === id) state.epic.status = "in_progress";
221
+ }
222
+ break;
223
+ }
224
+
225
+ case "swarm_complete": {
226
+ const beadId = typeof event.input.bead_id === "string" ? event.input.bead_id : undefined;
227
+ if (beadId) {
228
+ const subtask = state.subtasks.get(beadId);
229
+ if (subtask && subtask.status !== "closed") {
230
+ if (subtask.status === "created") state.counts.created--;
231
+ else if (subtask.status === "spawned") state.counts.spawned--;
232
+ else if (subtask.status === "in_progress") state.counts.inProgress--;
233
+ subtask.status = "completed";
234
+ subtask.completedAt = event.timestamp;
235
+ state.counts.completed++;
236
+ }
237
+ }
238
+ break;
239
+ }
240
+
241
+ case "hive_close": {
242
+ const id = typeof event.input.id === "string" ? event.input.id : undefined;
243
+ if (id) {
244
+ const subtask = state.subtasks.get(id);
245
+ if (subtask) {
246
+ if (subtask.status === "created") state.counts.created--;
247
+ else if (subtask.status === "spawned") state.counts.spawned--;
248
+ else if (subtask.status === "in_progress") state.counts.inProgress--;
249
+ else if (subtask.status === "completed") state.counts.completed--;
250
+ subtask.status = "closed";
251
+ state.counts.closed++;
252
+ }
253
+ if (state.epic && state.epic.id === id) state.epic.status = "closed";
254
+ }
255
+ break;
256
+ }
257
+
258
+ case "swarmmail_init": {
259
+ try {
260
+ const parsed = JSON.parse(event.output);
261
+ if (parsed.agent_name) state.coordinatorName = parsed.agent_name;
262
+ if (parsed.project_key) state.projectPath = parsed.project_key;
263
+ } catch { /* skip */ }
264
+ break;
265
+ }
266
+ }
267
+ }
268
+
269
+ state.isSwarm = hasEpic && hasSpawn;
270
+ return state;
271
+ }
272
+
273
+ /** Quick check for swarm signature without full projection */
274
+ function hasSwarmSignature(events: ToolCallEvent[]): boolean {
275
+ let hasEpic = false;
276
+ let hasSpawn = false;
277
+ for (const event of events) {
278
+ if (event.tool === "hive_create_epic") hasEpic = true;
279
+ else if (event.tool === "swarm_spawn_subtask") hasSpawn = true;
280
+ if (hasEpic && hasSpawn) return true;
281
+ }
282
+ return false;
283
+ }
284
+
285
+ /** Check if swarm is still active (has pending work) */
286
+ function isSwarmActive(projection: SwarmProjection): boolean {
287
+ if (!projection.isSwarm) return false;
288
+ return projection.counts.created > 0 || projection.counts.spawned > 0 ||
289
+ projection.counts.inProgress > 0 || projection.counts.completed > 0;
290
+ }
291
+
292
+ /** Get human-readable swarm status summary */
293
+ function getSwarmSummary(projection: SwarmProjection): string {
294
+ if (!projection.isSwarm) return "No swarm detected";
295
+ const { counts, epic } = projection;
296
+ const parts: string[] = [];
297
+ if (epic) parts.push(`Epic: ${epic.id} - ${epic.title} [${epic.status}]`);
298
+ parts.push(`Subtasks: ${counts.total} total (${counts.spawned} spawned, ${counts.inProgress} in_progress, ${counts.completed} completed, ${counts.closed} closed)`);
299
+ parts.push(isSwarmActive(projection) ? "Status: ACTIVE - has pending work" : "Status: COMPLETE - all work closed");
300
+ return parts.join("\n");
301
+ }
302
+
303
+ // =============================================================================
304
+ // Constants
305
+ // =============================================================================
33
306
 
34
307
  const SWARM_CLI = "swarm";
35
308
 
@@ -76,10 +349,10 @@ function logCompaction(
76
349
  }
77
350
 
78
351
  /**
79
- * Capture compaction event for evals (non-fatal dynamic import)
352
+ * Capture compaction event for evals via CLI
80
353
  *
81
- * Uses dynamic import to avoid circular dependencies and keep the plugin wrapper
82
- * self-contained. Captures COMPACTION events to session JSONL for eval analysis.
354
+ * Shells out to `swarm capture` command to avoid import issues.
355
+ * The CLI handles all the logic - plugin wrapper stays dumb.
83
356
  *
84
357
  * @param sessionID - Session ID
85
358
  * @param epicID - Epic ID (or "unknown" if not detected)
@@ -93,14 +366,22 @@ async function captureCompaction(
93
366
  payload: any,
94
367
  ): Promise<void> {
95
368
  try {
96
- // Dynamic import to avoid circular deps (plugin wrapper src → plugin wrapper)
97
- const { captureCompactionEvent } = await import("../src/eval-capture");
98
- captureCompactionEvent({
99
- session_id: sessionID,
100
- epic_id: epicID,
101
- compaction_type: compactionType,
102
- payload,
369
+ // Shell out to CLI - no imports needed, version always matches
370
+ const args = [
371
+ "capture",
372
+ "--session", sessionID,
373
+ "--epic", epicID,
374
+ "--type", compactionType,
375
+ "--payload", JSON.stringify(payload),
376
+ ];
377
+
378
+ const proc = spawn(SWARM_CLI, args, {
379
+ env: { ...process.env, SWARM_PROJECT_DIR: projectDirectory },
380
+ stdio: ["ignore", "ignore", "ignore"], // Fire and forget
103
381
  });
382
+
383
+ // Don't wait - capture is non-blocking
384
+ proc.unref();
104
385
  } catch (err) {
105
386
  // Non-fatal - capture failures shouldn't break compaction
106
387
  logCompaction("warn", "compaction_capture_failed", {
@@ -2063,6 +2344,97 @@ Extract from session context:
2063
2344
  **You are not waiting for instructions. You are the coordinator. Coordinate.**
2064
2345
  `;
2065
2346
 
2347
+ /**
2348
+ * Build dynamic swarm state section from snapshot
2349
+ *
2350
+ * This creates a concrete state summary with actual IDs and status
2351
+ * to prepend to the static compaction context.
2352
+ */
2353
+ function buildDynamicStateFromSnapshot(snapshot: SwarmStateSnapshot): string {
2354
+ if (!snapshot.epic) {
2355
+ return "";
2356
+ }
2357
+
2358
+ const parts: string[] = [];
2359
+
2360
+ // Header with epic info
2361
+ parts.push(`## 🐝 Current Swarm State\n`);
2362
+ parts.push(`**Epic:** ${snapshot.epic.id} - ${snapshot.epic.title}`);
2363
+ parts.push(`**Status:** ${snapshot.epic.status}`);
2364
+ parts.push(`**Project:** ${projectDirectory}\n`);
2365
+
2366
+ // Subtask breakdown
2367
+ const subtasks = snapshot.epic.subtasks || [];
2368
+ const completed = subtasks.filter(s => s.status === "closed");
2369
+ const inProgress = subtasks.filter(s => s.status === "in_progress");
2370
+ const blocked = subtasks.filter(s => s.status === "blocked");
2371
+ const pending = subtasks.filter(s => s.status === "open");
2372
+
2373
+ parts.push(`**Progress:** ${completed.length}/${subtasks.length} subtasks complete\n`);
2374
+
2375
+ // Immediate actions with real IDs
2376
+ parts.push(`## 1️⃣ IMMEDIATE ACTIONS (Do These FIRST)\n`);
2377
+ parts.push(`1. \`swarm_status(epic_id="${snapshot.epic.id}", project_key="${projectDirectory}")\` - Get current state`);
2378
+ parts.push(`2. \`swarmmail_inbox(limit=5)\` - Check for worker messages`);
2379
+
2380
+ if (inProgress.length > 0) {
2381
+ parts.push(`3. Review in-progress work when workers complete`);
2382
+ }
2383
+ if (pending.length > 0) {
2384
+ const next = pending[0];
2385
+ parts.push(`4. Spawn next subtask: \`swarm_spawn_subtask(bead_id="${next.id}", ...)\``);
2386
+ }
2387
+ if (blocked.length > 0) {
2388
+ parts.push(`5. Unblock: ${blocked.map(s => s.id).join(", ")}`);
2389
+ }
2390
+ parts.push("");
2391
+
2392
+ // Detailed subtask status
2393
+ if (inProgress.length > 0) {
2394
+ parts.push(`### 🚧 In Progress (${inProgress.length})`);
2395
+ for (const s of inProgress) {
2396
+ const files = s.files?.length ? ` (${s.files.slice(0, 3).join(", ")}${s.files.length > 3 ? "..." : ""})` : "";
2397
+ parts.push(`- ${s.id}: ${s.title}${files}`);
2398
+ }
2399
+ parts.push("");
2400
+ }
2401
+
2402
+ if (blocked.length > 0) {
2403
+ parts.push(`### 🚫 Blocked (${blocked.length})`);
2404
+ for (const s of blocked) {
2405
+ parts.push(`- ${s.id}: ${s.title}`);
2406
+ }
2407
+ parts.push("");
2408
+ }
2409
+
2410
+ if (pending.length > 0) {
2411
+ parts.push(`### ⏳ Ready to Spawn (${pending.length})`);
2412
+ for (const s of pending.slice(0, 5)) { // Show first 5
2413
+ const files = s.files?.length ? ` (${s.files.slice(0, 2).join(", ")}${s.files.length > 2 ? "..." : ""})` : "";
2414
+ parts.push(`- ${s.id}: ${s.title}${files}`);
2415
+ }
2416
+ if (pending.length > 5) {
2417
+ parts.push(`- ... and ${pending.length - 5} more`);
2418
+ }
2419
+ parts.push("");
2420
+ }
2421
+
2422
+ if (completed.length > 0) {
2423
+ parts.push(`### ✅ Completed (${completed.length})`);
2424
+ for (const s of completed.slice(-3)) { // Show last 3
2425
+ parts.push(`- ${s.id}: ${s.title} ✓`);
2426
+ }
2427
+ if (completed.length > 3) {
2428
+ parts.push(`- ... and ${completed.length - 3} more`);
2429
+ }
2430
+ parts.push("");
2431
+ }
2432
+
2433
+ parts.push("---\n");
2434
+
2435
+ return parts.join("\n");
2436
+ }
2437
+
2066
2438
  /**
2067
2439
  * Fallback detection prompt - tells the compactor what to look for
2068
2440
  *
@@ -2329,6 +2701,9 @@ const SwarmPlugin: Plugin = async (
2329
2701
  has_projection: !!sessionScan.projection?.isSwarm,
2330
2702
  });
2331
2703
 
2704
+ // Hoist snapshot outside try block so it's available in fallback path
2705
+ let snapshot: SwarmStateSnapshot | undefined;
2706
+
2332
2707
  try {
2333
2708
  // =======================================================================
2334
2709
  // PREFER PROJECTION (ground truth from events) OVER HIVE QUERY
@@ -2336,8 +2711,6 @@ const SwarmPlugin: Plugin = async (
2336
2711
  // The projection is derived from session events - it's the source of truth.
2337
2712
  // Hive query may show all cells closed even if swarm was active.
2338
2713
 
2339
- let snapshot: SwarmStateSnapshot;
2340
-
2341
2714
  if (sessionScan.projection?.isSwarm) {
2342
2715
  // Use projection as primary source - convert to snapshot format
2343
2716
  const proj = sessionScan.projection;
@@ -2520,9 +2893,12 @@ const SwarmPlugin: Plugin = async (
2520
2893
  });
2521
2894
  }
2522
2895
 
2523
- // Level 3: Fall back to static context
2896
+ // Level 3: Fall back to static context WITH dynamic state from snapshot
2524
2897
  const header = `[Swarm detected: ${detection.reasons.join(", ")}]\n\n`;
2525
- const staticContent = header + SWARM_COMPACTION_CONTEXT;
2898
+
2899
+ // Build dynamic state section if we have snapshot data
2900
+ const dynamicState = snapshot ? buildDynamicStateFromSnapshot(snapshot) : "";
2901
+ const staticContent = header + dynamicState + SWARM_COMPACTION_CONTEXT;
2526
2902
  output.context.push(staticContent);
2527
2903
 
2528
2904
  // =======================================================================
@@ -2530,13 +2906,16 @@ const SwarmPlugin: Plugin = async (
2530
2906
  // =======================================================================
2531
2907
  await captureCompaction(
2532
2908
  input.sessionID,
2533
- "unknown", // No snapshot available in this path
2909
+ snapshot?.epic?.id || "unknown",
2534
2910
  "context_injected",
2535
2911
  {
2536
2912
  full_content: staticContent,
2537
2913
  content_length: staticContent.length,
2538
2914
  injection_method: "output.context.push",
2539
- context_type: "static_swarm_context",
2915
+ context_type: "static_with_dynamic_state",
2916
+ has_dynamic_state: !!dynamicState,
2917
+ epic_id: snapshot?.epic?.id,
2918
+ subtask_count: snapshot?.epic?.subtasks?.length ?? 0,
2540
2919
  },
2541
2920
  );
2542
2921
 
@@ -2545,9 +2924,12 @@ const SwarmPlugin: Plugin = async (
2545
2924
  session_id: input.sessionID,
2546
2925
  total_duration_ms: totalDuration,
2547
2926
  confidence: detection.confidence,
2548
- context_type: "static_swarm_context",
2927
+ context_type: dynamicState ? "static_with_dynamic_state" : "static_swarm_context",
2549
2928
  content_length: staticContent.length,
2550
2929
  context_count_after: output.context.length,
2930
+ has_dynamic_state: !!dynamicState,
2931
+ epic_id: snapshot?.epic?.id,
2932
+ subtask_count: snapshot?.epic?.subtasks?.length ?? 0,
2551
2933
  });
2552
2934
  } else if (detection.confidence === "low") {
2553
2935
  // Level 4: Possible swarm - inject fallback detection prompt
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "opencode-swarm-plugin",
3
- "version": "0.45.0",
3
+ "version": "0.45.2",
4
4
  "description": "Multi-agent swarm coordination for OpenCode with learning capabilities, beads integration, and Agent Mail",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "bin": {
9
- "swarm": "./bin/swarm.ts"
9
+ "swarm": "./dist/bin/swarm.js"
10
10
  },
11
11
  "exports": {
12
12
  ".": {
@@ -63,7 +63,7 @@
63
63
  "minimatch": "^10.1.1",
64
64
  "pino": "^9.6.0",
65
65
  "pino-roll": "^1.3.0",
66
- "swarm-mail": "1.6.1",
66
+ "swarm-mail": "1.6.2",
67
67
  "yaml": "^2.8.2",
68
68
  "zod": "4.1.8"
69
69
  },