opencode-swarm-plugin 0.12.12 → 0.12.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-swarm-plugin",
3
- "version": "0.12.12",
3
+ "version": "0.12.15",
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",
@@ -12,10 +12,13 @@
12
12
  ".": {
13
13
  "import": "./dist/index.js",
14
14
  "types": "./dist/index.d.ts"
15
+ },
16
+ "./plugin": {
17
+ "import": "./dist/plugin.js"
15
18
  }
16
19
  },
17
20
  "scripts": {
18
- "build": "bun build ./src/index.ts --outdir ./dist --target bun && bun build ./src/plugin.ts --outfile ./dist/plugin.js --target bun",
21
+ "build": "bun build ./src/index.ts --outdir ./dist --target node && bun build ./src/plugin.ts --outfile ./dist/plugin.js --target node",
19
22
  "dev": "bun --watch src/index.ts",
20
23
  "test": "bun test src/schemas/",
21
24
  "test:watch": "bun test --watch",
package/src/agent-mail.ts CHANGED
@@ -231,22 +231,46 @@ interface ThreadSummary {
231
231
  // Errors
232
232
  // ============================================================================
233
233
 
234
+ /**
235
+ * AgentMailError - Custom error for Agent Mail operations
236
+ *
237
+ * Note: Using a factory pattern to avoid "Cannot call a class constructor without |new|"
238
+ * errors in some bundled environments (OpenCode's plugin runtime).
239
+ */
234
240
  export class AgentMailError extends Error {
235
- constructor(
236
- message: string,
237
- public readonly tool: string,
238
- public readonly code?: number,
239
- public readonly data?: unknown,
240
- ) {
241
+ public readonly tool: string;
242
+ public readonly code?: number;
243
+ public readonly data?: unknown;
244
+
245
+ constructor(message: string, tool: string, code?: number, data?: unknown) {
241
246
  super(message);
247
+ this.tool = tool;
248
+ this.code = code;
249
+ this.data = data;
242
250
  this.name = "AgentMailError";
251
+ // Fix prototype chain for instanceof checks
252
+ Object.setPrototypeOf(this, AgentMailError.prototype);
243
253
  }
244
254
  }
245
255
 
256
+ /**
257
+ * Factory function to create AgentMailError
258
+ * Use this instead of `new AgentMailError()` for compatibility
259
+ */
260
+ export function createAgentMailError(
261
+ message: string,
262
+ tool: string,
263
+ code?: number,
264
+ data?: unknown,
265
+ ): AgentMailError {
266
+ return new AgentMailError(message, tool, code, data);
267
+ }
268
+
246
269
  export class AgentMailNotInitializedError extends Error {
247
270
  constructor() {
248
271
  super("Agent Mail not initialized. Call agent-mail:init first.");
249
272
  this.name = "AgentMailNotInitializedError";
273
+ Object.setPrototypeOf(this, AgentMailNotInitializedError.prototype);
250
274
  }
251
275
  }
252
276
 
package/src/index.ts CHANGED
@@ -226,14 +226,15 @@ export * from "./beads";
226
226
  * - AgentMailError, FileReservationConflictError - Error classes
227
227
  * - AgentMailState - Session state type
228
228
  *
229
- * NOTE: We selectively export to avoid exporting constants like AGENT_MAIL_URL
230
- * which would confuse the plugin loader (it tries to call all exports as functions)
229
+ * NOTE: For OpenCode plugin usage, import from "opencode-swarm-plugin/plugin" instead
230
+ * to avoid the plugin loader trying to call these classes as functions.
231
231
  */
232
232
  export {
233
233
  agentMailTools,
234
234
  AgentMailError,
235
235
  AgentMailNotInitializedError,
236
236
  FileReservationConflictError,
237
+ createAgentMailError,
237
238
  type AgentMailState,
238
239
  } from "./agent-mail";
239
240
 
package/src/learning.ts CHANGED
@@ -231,11 +231,14 @@ export function calculateDecayedValue(
231
231
  now: Date = new Date(),
232
232
  halfLifeDays: number = 90,
233
233
  ): number {
234
+ // Prevent division by zero
235
+ const safeHalfLife = halfLifeDays <= 0 ? 1 : halfLifeDays;
236
+
234
237
  const eventTime = new Date(timestamp).getTime();
235
238
  const nowTime = now.getTime();
236
239
  const ageDays = Math.max(0, (nowTime - eventTime) / (24 * 60 * 60 * 1000));
237
240
 
238
- return Math.pow(0.5, ageDays / halfLifeDays);
241
+ return Math.pow(0.5, ageDays / safeHalfLife);
239
242
  }
240
243
 
241
244
  /**
@@ -252,6 +255,18 @@ export function calculateCriterionWeight(
252
255
  events: FeedbackEvent[],
253
256
  config: LearningConfig = DEFAULT_LEARNING_CONFIG,
254
257
  ): CriterionWeight {
258
+ // Return early with default weight if events array is empty
259
+ if (events.length === 0) {
260
+ return {
261
+ criterion: "unknown",
262
+ weight: 1.0,
263
+ helpful_count: 0,
264
+ harmful_count: 0,
265
+ last_validated: undefined,
266
+ half_life_days: config.halfLifeDays,
267
+ };
268
+ }
269
+
255
270
  const now = new Date();
256
271
  let helpfulSum = 0;
257
272
  let harmfulSum = 0;
@@ -284,7 +299,7 @@ export function calculateCriterionWeight(
284
299
  const weight = total > 0 ? Math.max(0.1, helpfulSum / total) : 1.0;
285
300
 
286
301
  return {
287
- criterion: events[0]?.criterion ?? "unknown",
302
+ criterion: events[0].criterion,
288
303
  weight,
289
304
  helpful_count: helpfulCount,
290
305
  harmful_count: harmfulCount,
@@ -476,12 +491,24 @@ export interface FeedbackStorage {
476
491
 
477
492
  /**
478
493
  * In-memory feedback storage (for testing and short-lived sessions)
494
+ *
495
+ * Uses LRU eviction to prevent unbounded memory growth.
479
496
  */
480
497
  export class InMemoryFeedbackStorage implements FeedbackStorage {
481
498
  private events: FeedbackEvent[] = [];
499
+ private readonly maxSize: number;
500
+
501
+ constructor(maxSize: number = 10000) {
502
+ this.maxSize = maxSize;
503
+ }
482
504
 
483
505
  async store(event: FeedbackEvent): Promise<void> {
484
506
  this.events.push(event);
507
+
508
+ // Evict oldest events if we exceed max size (LRU)
509
+ if (this.events.length > this.maxSize) {
510
+ this.events = this.events.slice(this.events.length - this.maxSize);
511
+ }
485
512
  }
486
513
 
487
514
  async getByCriterion(criterion: string): Promise<FeedbackEvent[]> {
@@ -28,11 +28,40 @@
28
28
  */
29
29
 
30
30
  import Redis from "ioredis";
31
- import { Database } from "bun:sqlite";
32
31
  import { mkdirSync, existsSync } from "node:fs";
33
32
  import { dirname, join } from "node:path";
34
33
  import { homedir } from "node:os";
35
34
 
35
+ // SQLite is optional - only available in Bun runtime
36
+ // We use dynamic import to avoid breaking Node.js environments
37
+ interface BunDatabase {
38
+ run(sql: string, params?: unknown[]): void;
39
+ query<T>(sql: string): {
40
+ get(...params: unknown[]): T | null;
41
+ };
42
+ prepare(sql: string): {
43
+ run(...params: unknown[]): void;
44
+ };
45
+ close(): void;
46
+ }
47
+
48
+ let sqliteAvailable = false;
49
+ let createDatabase: ((path: string) => BunDatabase) | null = null;
50
+
51
+ // Try to load bun:sqlite at module load time
52
+ try {
53
+ if (typeof globalThis.Bun !== "undefined") {
54
+ // We're in Bun runtime - dynamic import will work
55
+ const bunSqlite = await import("bun:sqlite");
56
+ createDatabase = (path: string) =>
57
+ new bunSqlite.Database(path) as unknown as BunDatabase;
58
+ sqliteAvailable = true;
59
+ }
60
+ } catch {
61
+ // Not in Bun runtime, SQLite fallback unavailable
62
+ sqliteAvailable = false;
63
+ }
64
+
36
65
  // ============================================================================
37
66
  // Types
38
67
  // ============================================================================
@@ -290,16 +319,20 @@ export class RedisRateLimiter implements RateLimiter {
290
319
  * Uses sliding window via COUNT query with timestamp filter.
291
320
  */
292
321
  export class SqliteRateLimiter implements RateLimiter {
293
- private db: Database;
322
+ private db: BunDatabase;
294
323
 
295
324
  constructor(dbPath: string) {
325
+ if (!sqliteAvailable || !createDatabase) {
326
+ throw new Error("SQLite is not available in this runtime (requires Bun)");
327
+ }
328
+
296
329
  // Ensure directory exists
297
330
  const dir = dirname(dbPath);
298
331
  if (!existsSync(dir)) {
299
332
  mkdirSync(dir, { recursive: true });
300
333
  }
301
334
 
302
- this.db = new Database(dbPath);
335
+ this.db = createDatabase(dbPath);
303
336
  this.initialize();
304
337
  }
305
338
 
@@ -381,13 +414,23 @@ export class SqliteRateLimiter implements RateLimiter {
381
414
 
382
415
  // Count requests in window
383
416
  const result = this.db
384
- .query<{ count: number }, [string, string, string, number]>(
417
+ .query<{ count: number }>(
385
418
  `SELECT COUNT(*) as count FROM rate_limits
386
419
  WHERE agent_name = ? AND endpoint = ? AND window = ? AND timestamp > ?`,
387
420
  )
388
421
  .get(agentName, endpoint, window, windowStart);
389
422
 
390
- const count = result?.count || 0;
423
+ // Validate result before accessing properties
424
+ if (!result || typeof result.count !== "number") {
425
+ // Query failed or returned invalid data, assume no usage
426
+ return {
427
+ allowed: true,
428
+ remaining: limit,
429
+ resetAt: now + windowDuration,
430
+ };
431
+ }
432
+
433
+ const count = result.count;
391
434
  const remaining = Math.max(0, limit - count);
392
435
  const allowed = count < limit;
393
436
 
@@ -395,13 +438,14 @@ export class SqliteRateLimiter implements RateLimiter {
395
438
  let resetAt = now + windowDuration;
396
439
  if (!allowed) {
397
440
  const oldest = this.db
398
- .query<{ timestamp: number }, [string, string, string, number]>(
441
+ .query<{ timestamp: number }>(
399
442
  `SELECT MIN(timestamp) as timestamp FROM rate_limits
400
443
  WHERE agent_name = ? AND endpoint = ? AND window = ? AND timestamp > ?`,
401
444
  )
402
445
  .get(agentName, endpoint, window, windowStart);
403
446
 
404
- if (oldest?.timestamp) {
447
+ // Validate oldest result before accessing properties
448
+ if (oldest && typeof oldest.timestamp === "number") {
405
449
  resetAt = oldest.timestamp + windowDuration;
406
450
  }
407
451
  }
@@ -582,6 +626,11 @@ export async function createRateLimiter(options?: {
582
626
  }
583
627
 
584
628
  if (backend === "sqlite") {
629
+ if (!sqliteAvailable) {
630
+ throw new Error(
631
+ "SQLite backend requested but not available (requires Bun runtime)",
632
+ );
633
+ }
585
634
  return new SqliteRateLimiter(sqlitePath);
586
635
  }
587
636
 
@@ -590,31 +639,55 @@ export async function createRateLimiter(options?: {
590
639
  return new RedisRateLimiter(redis);
591
640
  }
592
641
 
593
- // Auto-select: try Redis first, fall back to SQLite
594
- try {
595
- const redis = new Redis(redisUrl, {
596
- connectTimeout: 2000,
597
- maxRetriesPerRequest: 1,
598
- retryStrategy: () => null, // Don't retry on failure
599
- lazyConnect: true,
600
- });
601
-
602
- // Test connection
603
- await redis.connect();
604
- await redis.ping();
642
+ // Auto-select: try Redis first with retry, fall back to SQLite
643
+ const maxRetries = 3;
644
+ const retryDelays = [100, 500, 1000]; // exponential backoff
645
+
646
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
647
+ try {
648
+ const redis = new Redis(redisUrl, {
649
+ connectTimeout: 2000,
650
+ maxRetriesPerRequest: 1,
651
+ retryStrategy: () => null, // Don't retry on failure
652
+ lazyConnect: true,
653
+ });
654
+
655
+ // Test connection
656
+ await redis.connect();
657
+ await redis.ping();
658
+
659
+ return new RedisRateLimiter(redis);
660
+ } catch (error) {
661
+ const isLastAttempt = attempt === maxRetries - 1;
662
+
663
+ if (isLastAttempt) {
664
+ // All retries exhausted, fall back to SQLite or in-memory
665
+ if (!hasWarnedAboutFallback) {
666
+ const fallbackType = sqliteAvailable ? "SQLite" : "in-memory";
667
+ const fallbackLocation = sqliteAvailable ? ` at ${sqlitePath}` : "";
668
+ console.warn(
669
+ `[rate-limiter] Redis connection failed after ${maxRetries} attempts (${redisUrl}), falling back to ${fallbackType}${fallbackLocation}`,
670
+ );
671
+ hasWarnedAboutFallback = true;
672
+ }
673
+
674
+ // Use SQLite if available, otherwise fall back to in-memory
675
+ if (sqliteAvailable) {
676
+ return new SqliteRateLimiter(sqlitePath);
677
+ }
678
+ return new InMemoryRateLimiter();
679
+ }
605
680
 
606
- return new RedisRateLimiter(redis);
607
- } catch (error) {
608
- // Redis connection failed, fall back to SQLite
609
- if (!hasWarnedAboutFallback) {
610
- console.warn(
611
- `[rate-limiter] Redis connection failed (${redisUrl}), falling back to SQLite at ${sqlitePath}`,
612
- );
613
- hasWarnedAboutFallback = true;
681
+ // Wait before retrying (exponential backoff)
682
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
614
683
  }
684
+ }
615
685
 
686
+ // Fallback (should never reach here due to return in last attempt)
687
+ if (sqliteAvailable) {
616
688
  return new SqliteRateLimiter(sqlitePath);
617
689
  }
690
+ return new InMemoryRateLimiter();
618
691
  }
619
692
 
620
693
  /**
package/src/swarm.ts CHANGED
@@ -901,9 +901,10 @@ async function queryEpicSubtasks(epicId: string): Promise<Bead[]> {
901
901
  .nothrow();
902
902
 
903
903
  if (result.exitCode !== 0) {
904
- // Don't throw - just return empty and warn
905
- console.warn(
906
- `[swarm] Failed to query subtasks: ${result.stderr.toString()}`,
904
+ // Don't throw - just return empty and log error prominently
905
+ console.error(
906
+ `[swarm] ERROR: Failed to query subtasks for epic ${epicId}:`,
907
+ result.stderr.toString(),
907
908
  );
908
909
  return [];
909
910
  }
@@ -913,9 +914,16 @@ async function queryEpicSubtasks(epicId: string): Promise<Bead[]> {
913
914
  return z.array(BeadSchema).parse(parsed);
914
915
  } catch (error) {
915
916
  if (error instanceof z.ZodError) {
916
- console.warn(`[swarm] Invalid bead data: ${error.message}`);
917
+ console.error(
918
+ `[swarm] ERROR: Invalid bead data for epic ${epicId}:`,
919
+ error.message,
920
+ );
917
921
  return [];
918
922
  }
923
+ console.error(
924
+ `[swarm] ERROR: Failed to parse beads for epic ${epicId}:`,
925
+ error,
926
+ );
919
927
  throw error;
920
928
  }
921
929
  }
@@ -944,8 +952,12 @@ async function querySwarmMessages(
944
952
  llm_mode: false, // Just need the count
945
953
  });
946
954
  return summary.summary.total_messages;
947
- } catch {
948
- // Thread might not exist yet
955
+ } catch (error) {
956
+ // Thread might not exist yet, or query failed
957
+ console.warn(
958
+ `[swarm] Failed to query swarm messages for thread ${threadId}:`,
959
+ error,
960
+ );
949
961
  return 0;
950
962
  }
951
963
  }
@@ -989,22 +1001,31 @@ interface CassSearchResult {
989
1001
  }>;
990
1002
  }
991
1003
 
1004
+ /**
1005
+ * CASS query result with status
1006
+ */
1007
+ type CassQueryResult =
1008
+ | { status: "unavailable" }
1009
+ | { status: "failed"; error?: string }
1010
+ | { status: "empty"; query: string }
1011
+ | { status: "success"; data: CassSearchResult };
1012
+
992
1013
  /**
993
1014
  * Query CASS for similar past tasks
994
1015
  *
995
1016
  * @param task - Task description to search for
996
1017
  * @param limit - Maximum results to return
997
- * @returns Search results or null if CASS unavailable
1018
+ * @returns Structured result with status indicator
998
1019
  */
999
1020
  async function queryCassHistory(
1000
1021
  task: string,
1001
1022
  limit: number = 3,
1002
- ): Promise<CassSearchResult | null> {
1023
+ ): Promise<CassQueryResult> {
1003
1024
  // Check if CASS is available first
1004
1025
  const cassAvailable = await isToolAvailable("cass");
1005
1026
  if (!cassAvailable) {
1006
1027
  warnMissingTool("cass");
1007
- return null;
1028
+ return { status: "unavailable" };
1008
1029
  }
1009
1030
 
1010
1031
  try {
@@ -1013,25 +1034,38 @@ async function queryCassHistory(
1013
1034
  .nothrow();
1014
1035
 
1015
1036
  if (result.exitCode !== 0) {
1016
- return null;
1037
+ const error = result.stderr.toString();
1038
+ console.warn(
1039
+ `[swarm] CASS search failed (exit ${result.exitCode}):`,
1040
+ error,
1041
+ );
1042
+ return { status: "failed", error };
1017
1043
  }
1018
1044
 
1019
1045
  const output = result.stdout.toString();
1020
1046
  if (!output.trim()) {
1021
- return { query: task, results: [] };
1047
+ return { status: "empty", query: task };
1022
1048
  }
1023
1049
 
1024
1050
  try {
1025
1051
  const parsed = JSON.parse(output);
1026
- return {
1052
+ const searchResult: CassSearchResult = {
1027
1053
  query: task,
1028
1054
  results: Array.isArray(parsed) ? parsed : parsed.results || [],
1029
1055
  };
1030
- } catch {
1031
- return { query: task, results: [] };
1056
+
1057
+ if (searchResult.results.length === 0) {
1058
+ return { status: "empty", query: task };
1059
+ }
1060
+
1061
+ return { status: "success", data: searchResult };
1062
+ } catch (error) {
1063
+ console.warn(`[swarm] Failed to parse CASS output:`, error);
1064
+ return { status: "failed", error: String(error) };
1032
1065
  }
1033
- } catch {
1034
- return null;
1066
+ } catch (error) {
1067
+ console.error(`[swarm] CASS query error:`, error);
1068
+ return { status: "failed", error: String(error) };
1035
1069
  }
1036
1070
  }
1037
1071
 
@@ -1223,13 +1257,35 @@ export const swarm_plan_prompt = tool({
1223
1257
 
1224
1258
  // Query CASS for similar past tasks
1225
1259
  let cassContext = "";
1226
- let cassResult: CassSearchResult | null = null;
1260
+ let cassResultInfo: {
1261
+ queried: boolean;
1262
+ results_found?: number;
1263
+ included_in_context?: boolean;
1264
+ reason?: string;
1265
+ };
1227
1266
 
1228
1267
  if (args.query_cass !== false) {
1229
- cassResult = await queryCassHistory(args.task, args.cass_limit ?? 3);
1230
- if (cassResult && cassResult.results.length > 0) {
1231
- cassContext = formatCassHistoryForPrompt(cassResult);
1268
+ const cassResult = await queryCassHistory(
1269
+ args.task,
1270
+ args.cass_limit ?? 3,
1271
+ );
1272
+ if (cassResult.status === "success") {
1273
+ cassContext = formatCassHistoryForPrompt(cassResult.data);
1274
+ cassResultInfo = {
1275
+ queried: true,
1276
+ results_found: cassResult.data.results.length,
1277
+ included_in_context: true,
1278
+ };
1279
+ } else {
1280
+ cassResultInfo = {
1281
+ queried: true,
1282
+ results_found: 0,
1283
+ included_in_context: false,
1284
+ reason: cassResult.status,
1285
+ };
1232
1286
  }
1287
+ } else {
1288
+ cassResultInfo = { queried: false, reason: "disabled" };
1233
1289
  }
1234
1290
 
1235
1291
  // Format strategy guidelines
@@ -1271,13 +1327,7 @@ export const swarm_plan_prompt = tool({
1271
1327
  },
1272
1328
  validation_note:
1273
1329
  "Parse agent response as JSON and validate with swarm_validate_decomposition",
1274
- cass_history: cassResult
1275
- ? {
1276
- queried: true,
1277
- results_found: cassResult.results.length,
1278
- included_in_context: cassResult.results.length > 0,
1279
- }
1280
- : { queried: false, reason: "disabled or unavailable" },
1330
+ cass_history: cassResultInfo,
1281
1331
  },
1282
1332
  null,
1283
1333
  2,
@@ -1324,13 +1374,35 @@ export const swarm_decompose = tool({
1324
1374
  async execute(args) {
1325
1375
  // Query CASS for similar past tasks
1326
1376
  let cassContext = "";
1327
- let cassResult: CassSearchResult | null = null;
1377
+ let cassResultInfo: {
1378
+ queried: boolean;
1379
+ results_found?: number;
1380
+ included_in_context?: boolean;
1381
+ reason?: string;
1382
+ };
1328
1383
 
1329
1384
  if (args.query_cass !== false) {
1330
- cassResult = await queryCassHistory(args.task, args.cass_limit ?? 3);
1331
- if (cassResult && cassResult.results.length > 0) {
1332
- cassContext = formatCassHistoryForPrompt(cassResult);
1385
+ const cassResult = await queryCassHistory(
1386
+ args.task,
1387
+ args.cass_limit ?? 3,
1388
+ );
1389
+ if (cassResult.status === "success") {
1390
+ cassContext = formatCassHistoryForPrompt(cassResult.data);
1391
+ cassResultInfo = {
1392
+ queried: true,
1393
+ results_found: cassResult.data.results.length,
1394
+ included_in_context: true,
1395
+ };
1396
+ } else {
1397
+ cassResultInfo = {
1398
+ queried: true,
1399
+ results_found: 0,
1400
+ included_in_context: false,
1401
+ reason: cassResult.status,
1402
+ };
1333
1403
  }
1404
+ } else {
1405
+ cassResultInfo = { queried: false, reason: "disabled" };
1334
1406
  }
1335
1407
 
1336
1408
  // Combine user context with CASS history
@@ -1363,13 +1435,7 @@ export const swarm_decompose = tool({
1363
1435
  },
1364
1436
  validation_note:
1365
1437
  "Parse agent response as JSON and validate with BeadTreeSchema from schemas/bead.ts",
1366
- cass_history: cassResult
1367
- ? {
1368
- queried: true,
1369
- results_found: cassResult.results.length,
1370
- included_in_context: cassResult.results.length > 0,
1371
- }
1372
- : { queried: false, reason: "disabled or unavailable" },
1438
+ cass_history: cassResultInfo,
1373
1439
  },
1374
1440
  null,
1375
1441
  2,
@@ -1726,9 +1792,21 @@ async function runUbsScan(files: string[]): Promise<UbsScanResult | null> {
1726
1792
 
1727
1793
  try {
1728
1794
  const parsed = JSON.parse(output);
1795
+
1796
+ // Basic validation of structure
1797
+ if (typeof parsed !== "object" || parsed === null) {
1798
+ throw new Error("UBS output is not an object");
1799
+ }
1800
+ if (!Array.isArray(parsed.bugs)) {
1801
+ console.warn("[swarm] UBS output missing bugs array, using empty");
1802
+ }
1803
+ if (typeof parsed.summary !== "object" || parsed.summary === null) {
1804
+ console.warn("[swarm] UBS output missing summary object, using empty");
1805
+ }
1806
+
1729
1807
  return {
1730
1808
  exitCode: result.exitCode,
1731
- bugs: parsed.bugs || [],
1809
+ bugs: Array.isArray(parsed.bugs) ? parsed.bugs : [],
1732
1810
  summary: parsed.summary || {
1733
1811
  total: 0,
1734
1812
  critical: 0,
@@ -1737,8 +1815,13 @@ async function runUbsScan(files: string[]): Promise<UbsScanResult | null> {
1737
1815
  low: 0,
1738
1816
  },
1739
1817
  };
1740
- } catch {
1741
- // UBS output wasn't JSON, return basic result
1818
+ } catch (error) {
1819
+ // UBS output wasn't JSON - this is an error condition
1820
+ console.error(
1821
+ `[swarm] CRITICAL: UBS scan failed to parse JSON output:`,
1822
+ error,
1823
+ );
1824
+ console.error(`[swarm] Raw output:`, output);
1742
1825
  return {
1743
1826
  exitCode: result.exitCode,
1744
1827
  bugs: [],
@@ -1937,10 +2020,17 @@ export const swarm_complete = tool({
1937
2020
  }
1938
2021
 
1939
2022
  // Release file reservations for this agent
1940
- await mcpCall("release_file_reservations", {
1941
- project_key: args.project_key,
1942
- agent_name: args.agent_name,
1943
- });
2023
+ try {
2024
+ await mcpCall("release_file_reservations", {
2025
+ project_key: args.project_key,
2026
+ agent_name: args.agent_name,
2027
+ });
2028
+ } catch (error) {
2029
+ console.warn(
2030
+ `[swarm] Failed to release file reservations for ${args.agent_name}:`,
2031
+ error,
2032
+ );
2033
+ }
1944
2034
 
1945
2035
  // Extract epic ID
1946
2036
  const epicId = args.bead_id.includes(".")