opencode-swarm-plugin 0.45.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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", {
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.1",
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
  ".": {