opencode-swarm-plugin 0.30.7 → 0.31.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.
package/src/hive.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  type HiveAdapter,
24
24
  type Cell as AdapterCell,
25
25
  getSwarmMail,
26
+ resolvePartialId,
26
27
  } from "swarm-mail";
27
28
  import { existsSync, readFileSync } from "node:fs";
28
29
  import { join } from "node:path";
@@ -423,6 +424,78 @@ export async function importJsonlToPGLite(projectPath: string): Promise<{
423
424
  */
424
425
  const adapterCache = new Map<string, HiveAdapter>();
425
426
 
427
+ // ============================================================================
428
+ // Process Exit Hook - Safety Net for Dirty Cells
429
+ // ============================================================================
430
+
431
+ /**
432
+ * Track if exit hook is already registered (prevent duplicate registrations)
433
+ */
434
+ let exitHookRegistered = false;
435
+
436
+ /**
437
+ * Track if exit hook is currently running (prevent re-entry)
438
+ */
439
+ let exitHookRunning = false;
440
+
441
+ /**
442
+ * Register process.on('beforeExit') handler to flush dirty cells
443
+ * This is a safety net - catches any dirty cells that weren't explicitly synced
444
+ *
445
+ * Idempotent - safe to call multiple times (only registers once)
446
+ */
447
+ function registerExitHook(): void {
448
+ if (exitHookRegistered) {
449
+ return; // Already registered
450
+ }
451
+
452
+ exitHookRegistered = true;
453
+
454
+ process.on('beforeExit', async (code) => {
455
+ // Prevent re-entry if already flushing
456
+ if (exitHookRunning) {
457
+ return;
458
+ }
459
+
460
+ exitHookRunning = true;
461
+
462
+ try {
463
+ // Flush all projects that have adapters (and potentially dirty cells)
464
+ const flushPromises: Promise<void>[] = [];
465
+
466
+ for (const [projectKey, adapter] of adapterCache.entries()) {
467
+ const flushPromise = (async () => {
468
+ try {
469
+ ensureHiveDirectory(projectKey);
470
+ const flushManager = new FlushManager({
471
+ adapter,
472
+ projectKey,
473
+ outputPath: `${projectKey}/.hive/issues.jsonl`,
474
+ });
475
+ await flushManager.flush();
476
+ } catch (error) {
477
+ // Non-fatal - log and continue
478
+ console.warn(
479
+ `[hive exit hook] Failed to flush ${projectKey}:`,
480
+ error instanceof Error ? error.message : String(error)
481
+ );
482
+ }
483
+ })();
484
+
485
+ flushPromises.push(flushPromise);
486
+ }
487
+
488
+ // Wait for all flushes to complete
489
+ await Promise.all(flushPromises);
490
+ } finally {
491
+ exitHookRunning = false;
492
+ }
493
+ });
494
+ }
495
+
496
+ // Register exit hook immediately when module is imported
497
+ registerExitHook();
498
+
426
499
  /**
427
500
  * Get or create a HiveAdapter instance for a project
428
501
  * Exported for testing - allows tests to verify state directly
@@ -514,10 +587,10 @@ function formatCellForOutput(adapterCell: AdapterCell): Record<string, unknown>
514
587
  status: adapterCell.status,
515
588
  priority: adapterCell.priority,
516
589
  issue_type: adapterCell.type, // Adapter: type → Schema: issue_type
517
- created_at: new Date(adapterCell.created_at).toISOString(),
518
- updated_at: new Date(adapterCell.updated_at).toISOString(),
590
+ created_at: new Date(Number(adapterCell.created_at)).toISOString(),
591
+ updated_at: new Date(Number(adapterCell.updated_at)).toISOString(),
519
592
  closed_at: adapterCell.closed_at
520
- ? new Date(adapterCell.closed_at).toISOString()
593
+ ? new Date(Number(adapterCell.closed_at)).toISOString()
521
594
  : undefined,
522
595
  parent_id: adapterCell.parent_id || undefined,
523
596
  dependencies: [], // TODO: fetch from adapter if needed
@@ -694,6 +767,23 @@ export const hive_create_epic = tool({
694
767
  }
695
768
  }
696
769
 
770
+ // Sync cells to JSONL so spawned workers can see them immediately
771
+ try {
772
+ ensureHiveDirectory(projectKey);
773
+ const flushManager = new FlushManager({
774
+ adapter,
775
+ projectKey,
776
+ outputPath: `${projectKey}/.hive/issues.jsonl`,
777
+ });
778
+ await flushManager.flush();
779
+ } catch (error) {
780
+ // Non-fatal - log and continue
781
+ console.warn(
782
+ "[hive_create_epic] Failed to sync to JSONL:",
783
+ error,
784
+ );
785
+ }
786
+
697
787
  return JSON.stringify(result, null, 2);
698
788
  } catch (error) {
699
789
  // Partial failure - rollback via deleteCell
@@ -790,7 +880,7 @@ export const hive_query = tool({
790
880
  export const hive_update = tool({
791
881
  description: "Update cell status/description",
792
882
  args: {
793
- id: tool.schema.string().describe("Cell ID"),
883
+ id: tool.schema.string().describe("Cell ID or partial hash"),
794
884
  status: tool.schema
795
885
  .enum(["open", "in_progress", "blocked", "closed"])
796
886
  .optional()
@@ -809,26 +899,29 @@ export const hive_update = tool({
809
899
  const adapter = await getHiveAdapter(projectKey);
810
900
 
811
901
  try {
902
+ // Resolve partial ID to full ID
903
+ const cellId = await resolvePartialId(adapter, projectKey, validated.id) || validated.id;
904
+
812
905
  let cell: AdapterCell;
813
906
 
814
907
  // Status changes use changeCellStatus, other fields use updateCell
815
908
  if (validated.status) {
816
909
  cell = await adapter.changeCellStatus(
817
910
  projectKey,
818
- validated.id,
911
+ cellId,
819
912
  validated.status,
820
913
  );
821
914
  }
822
915
 
823
916
  // Update other fields if provided
824
917
  if (validated.description !== undefined || validated.priority !== undefined) {
825
- cell = await adapter.updateCell(projectKey, validated.id, {
918
+ cell = await adapter.updateCell(projectKey, cellId, {
826
919
  description: validated.description,
827
920
  priority: validated.priority,
828
921
  });
829
922
  } else if (!validated.status) {
830
923
  // No changes requested
831
- const existingCell = await adapter.getCell(projectKey, validated.id);
924
+ const existingCell = await adapter.getCell(projectKey, cellId);
832
925
  if (!existingCell) {
833
926
  throw new HiveError(
834
927
  `Cell not found: ${validated.id}`,
@@ -838,12 +931,27 @@ export const hive_update = tool({
838
931
  cell = existingCell;
839
932
  }
840
933
 
841
- await adapter.markDirty(projectKey, validated.id);
934
+ await adapter.markDirty(projectKey, cellId);
842
935
 
843
936
  const formatted = formatCellForOutput(cell!);
844
937
  return JSON.stringify(formatted, null, 2);
845
938
  } catch (error) {
846
939
  const message = error instanceof Error ? error.message : String(error);
940
+
941
+ // Provide helpful error messages
942
+ if (message.includes("Ambiguous hash")) {
943
+ throw new HiveError(
944
+ `Ambiguous ID '${validated.id}': multiple cells match. Please provide more characters.`,
945
+ "hive_update",
946
+ );
947
+ }
948
+ if (message.includes("Bead not found") || message.includes("Cell not found")) {
949
+ throw new HiveError(
950
+ `No cell found matching ID '${validated.id}'`,
951
+ "hive_update",
952
+ );
953
+ }
954
+
847
955
  throw new HiveError(
848
956
  `Failed to update cell: ${message}`,
849
957
  "hive_update",
@@ -858,7 +966,7 @@ export const hive_update = tool({
858
966
  export const hive_close = tool({
859
967
  description: "Close a cell with reason",
860
968
  args: {
861
- id: tool.schema.string().describe("Cell ID"),
969
+ id: tool.schema.string().describe("Cell ID or partial hash"),
862
970
  reason: tool.schema.string().describe("Completion reason"),
863
971
  },
864
972
  async execute(args, ctx) {
@@ -867,17 +975,35 @@ export const hive_close = tool({
867
975
  const adapter = await getHiveAdapter(projectKey);
868
976
 
869
977
  try {
978
+ // Resolve partial ID to full ID
979
+ const cellId = await resolvePartialId(adapter, projectKey, validated.id) || validated.id;
980
+
870
981
  const cell = await adapter.closeCell(
871
982
  projectKey,
872
- validated.id,
983
+ cellId,
873
984
  validated.reason,
874
985
  );
875
986
 
876
- await adapter.markDirty(projectKey, validated.id);
987
+ await adapter.markDirty(projectKey, cellId);
877
988
 
878
989
  return `Closed ${cell.id}: ${validated.reason}`;
879
990
  } catch (error) {
880
991
  const message = error instanceof Error ? error.message : String(error);
992
+
993
+ // Provide helpful error messages
994
+ if (message.includes("Ambiguous hash")) {
995
+ throw new HiveError(
996
+ `Ambiguous ID '${validated.id}': multiple cells match. Please provide more characters.`,
997
+ "hive_close",
998
+ );
999
+ }
1000
+ if (message.includes("Bead not found") || message.includes("Cell not found")) {
1001
+ throw new HiveError(
1002
+ `No cell found matching ID '${validated.id}'`,
1003
+ "hive_close",
1004
+ );
1005
+ }
1006
+
881
1007
  throw new HiveError(
882
1008
  `Failed to close cell: ${message}`,
883
1009
  "hive_close",
@@ -893,24 +1019,42 @@ export const hive_start = tool({
893
1019
  description:
894
1020
  "Mark a cell as in-progress (shortcut for update --status in_progress)",
895
1021
  args: {
896
- id: tool.schema.string().describe("Cell ID"),
1022
+ id: tool.schema.string().describe("Cell ID or partial hash"),
897
1023
  },
898
1024
  async execute(args, ctx) {
899
1025
  const projectKey = getHiveWorkingDirectory();
900
1026
  const adapter = await getHiveAdapter(projectKey);
901
1027
 
902
1028
  try {
1029
+ // Resolve partial ID to full ID
1030
+ const cellId = await resolvePartialId(adapter, projectKey, args.id) || args.id;
1031
+
903
1032
  const cell = await adapter.changeCellStatus(
904
1033
  projectKey,
905
- args.id,
1034
+ cellId,
906
1035
  "in_progress",
907
1036
  );
908
1037
 
909
- await adapter.markDirty(projectKey, args.id);
1038
+ await adapter.markDirty(projectKey, cellId);
910
1039
 
911
1040
  return `Started: ${cell.id}`;
912
1041
  } catch (error) {
913
1042
  const message = error instanceof Error ? error.message : String(error);
1043
+
1044
+ // Provide helpful error messages
1045
+ if (message.includes("Ambiguous hash")) {
1046
+ throw new HiveError(
1047
+ `Ambiguous ID '${args.id}': multiple cells match. Please provide more characters.`,
1048
+ "hive_start",
1049
+ );
1050
+ }
1051
+ if (message.includes("Bead not found") || message.includes("Cell not found")) {
1052
+ throw new HiveError(
1053
+ `No cell found matching ID '${args.id}'`,
1054
+ "hive_start",
1055
+ );
1056
+ }
1057
+
914
1058
  throw new HiveError(
915
1059
  `Failed to start cell: ${message}`,
916
1060
  "hive_start",
@@ -52,7 +52,7 @@ Agents MUST update their bead status as they work. No silent progress.
52
52
 
53
53
  ## Requirements
54
54
 
55
- 1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
55
+ 1. **Break into independent subtasks** that can run in parallel (as many as needed)
56
56
  2. **Assign files** - each subtask must specify which files it will modify
57
57
  3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
58
58
  4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
@@ -129,7 +129,7 @@ Agents MUST update their bead status as they work. No silent progress.
129
129
 
130
130
  ## Requirements
131
131
 
132
- 1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
132
+ 1. **Break into independent subtasks** that can run in parallel (as many as needed)
133
133
  2. **Assign files** - each subtask must specify which files it will modify
134
134
  3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
135
135
  4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
@@ -437,10 +437,9 @@ export const swarm_decompose = tool({
437
437
  max_subtasks: tool.schema
438
438
  .number()
439
439
  .int()
440
- .min(2)
441
- .max(10)
442
- .default(5)
443
- .describe("Maximum number of subtasks (default: 5)"),
440
+ .min(1)
441
+ .optional()
442
+ .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
444
443
  context: tool.schema
445
444
  .string()
446
445
  .optional()
@@ -453,7 +452,6 @@ export const swarm_decompose = tool({
453
452
  .number()
454
453
  .int()
455
454
  .min(1)
456
- .max(10)
457
455
  .optional()
458
456
  .describe("Max CASS results to include (default: 3)"),
459
457
  },
@@ -702,11 +700,9 @@ export const swarm_delegate_planning = tool({
702
700
  max_subtasks: tool.schema
703
701
  .number()
704
702
  .int()
705
- .min(2)
706
- .max(10)
703
+ .min(1)
707
704
  .optional()
708
- .default(5)
709
- .describe("Maximum number of subtasks (default: 5)"),
705
+ .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
710
706
  strategy: tool.schema
711
707
  .enum(["auto", "file-based", "feature-based", "risk-based"])
712
708
  .optional()
@@ -72,7 +72,7 @@ import {
72
72
  isToolAvailable,
73
73
  warnMissingTool,
74
74
  } from "./tool-availability";
75
- import { getHiveAdapter } from "./hive";
75
+ import { getHiveAdapter, hive_sync, setHiveWorkingDirectory, getHiveWorkingDirectory } from "./hive";
76
76
  import { listSkills } from "./skills";
77
77
  import {
78
78
  canUseWorktreeIsolation,
@@ -1570,6 +1570,30 @@ This will be recorded as a negative learning signal.`;
1570
1570
  );
1571
1571
  }
1572
1572
 
1573
+ // Sync cell to .hive/issues.jsonl (auto-sync on complete)
1574
+ // This ensures the worker's completed work persists before process exits
1575
+ let syncSuccess = false;
1576
+ let syncError: string | undefined;
1577
+ try {
1578
+ // Save current working directory and set to project path
1579
+ const previousWorkingDir = getHiveWorkingDirectory();
1580
+ setHiveWorkingDirectory(args.project_key);
1581
+
1582
+ try {
1583
+ const syncResult = await hive_sync.execute({ auto_pull: false }, _ctx);
1584
+ syncSuccess = !syncResult.includes("error");
1585
+ } finally {
1586
+ // Restore previous working directory
1587
+ setHiveWorkingDirectory(previousWorkingDir);
1588
+ }
1589
+ } catch (error) {
1590
+ // Non-fatal - log warning but don't block completion
1591
+ syncError = error instanceof Error ? error.message : String(error);
1592
+ console.warn(
1593
+ `[swarm_complete] Auto-sync failed (non-fatal): ${syncError}`,
1594
+ );
1595
+ }
1596
+
1573
1597
  // Emit SubtaskOutcomeEvent for learning system
1574
1598
  try {
1575
1599
  const epicId = args.bead_id.includes(".")
@@ -1709,6 +1733,8 @@ This will be recorded as a negative learning signal.`;
1709
1733
  bead_id: args.bead_id,
1710
1734
  closed: true,
1711
1735
  reservations_released: true,
1736
+ synced: syncSuccess,
1737
+ sync_error: syncError,
1712
1738
  message_sent: messageSent,
1713
1739
  message_error: messageError,
1714
1740
  agent_registration: {
@@ -46,7 +46,7 @@ Agents MUST update their cell status as they work. No silent progress.
46
46
 
47
47
  ## Requirements
48
48
 
49
- 1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
49
+ 1. **Break into independent subtasks** that can run in parallel (as many as needed)
50
50
  2. **Assign files** - each subtask must specify which files it will modify
51
51
  3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
52
52
  4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
@@ -123,7 +123,7 @@ Agents MUST update their cell status as they work. No silent progress.
123
123
 
124
124
  ## Requirements
125
125
 
126
- 1. **Break into 2-{max_subtasks} independent subtasks** that can run in parallel
126
+ 1. **Break into independent subtasks** that can run in parallel (as many as needed)
127
127
  2. **Assign files** - each subtask must specify which files it will modify
128
128
  3. **No file overlap** - files cannot appear in multiple subtasks (they get exclusive locks)
129
129
  4. **Order by dependency** - if subtask B needs subtask A's output, A must come first in the array
@@ -927,10 +927,9 @@ export const swarm_plan_prompt = tool({
927
927
  max_subtasks: tool.schema
928
928
  .number()
929
929
  .int()
930
- .min(2)
931
- .max(10)
932
- .default(5)
933
- .describe("Maximum number of subtasks (default: 5)"),
930
+ .min(1)
931
+ .optional()
932
+ .describe("Suggested max subtasks (optional - LLM decides if not specified)"),
934
933
  context: tool.schema
935
934
  .string()
936
935
  .optional()
@@ -943,7 +942,6 @@ export const swarm_plan_prompt = tool({
943
942
  .number()
944
943
  .int()
945
944
  .min(1)
946
- .max(10)
947
945
  .optional()
948
946
  .describe("Max CASS results to include (default: 3)"),
949
947
  include_skills: tool.schema
@@ -2341,4 +2341,74 @@ describe("Contract Validation", () => {
2341
2341
  expect(parsed.error).toBeUndefined();
2342
2342
  });
2343
2343
  });
2344
+
2345
+ describe("swarm_complete auto-sync", () => {
2346
+ it("calls hive_sync after closing cell on successful completion", async () => {
2347
+ const testProjectPath = "/tmp/swarm-auto-sync-test-" + Date.now();
2348
+ const { getHiveAdapter } = await import("./hive");
2349
+ const adapter = await getHiveAdapter(testProjectPath);
2350
+
2351
+ // Create a task cell directly
2352
+ const cell = await adapter.createCell(testProjectPath, {
2353
+ title: "Test task for auto-sync",
2354
+ type: "task",
2355
+ priority: 2,
2356
+ });
2357
+
2358
+ // Start the task
2359
+ await adapter.updateCell(testProjectPath, cell.id, {
2360
+ status: "in_progress",
2361
+ });
2362
+
2363
+ // Complete with skip_review and skip_verification
2364
+ const result = await swarm_complete.execute(
2365
+ {
2366
+ project_key: testProjectPath,
2367
+ agent_name: "TestAgent",
2368
+ bead_id: cell.id,
2369
+ summary: "Done - testing auto-sync",
2370
+ files_touched: [],
2371
+ skip_verification: true,
2372
+ skip_review: true,
2373
+ },
2374
+ mockContext,
2375
+ );
2376
+
2377
+ const parsed = JSON.parse(result);
2378
+
2379
+ // Should complete successfully
2380
+ expect(parsed.success).toBe(true);
2381
+ expect(parsed.closed).toBe(true);
2382
+
2383
+ // Check that cell is actually closed in database
2384
+ const closedCell = await adapter.getCell(testProjectPath, cell.id);
2385
+ expect(closedCell?.status).toBe("closed");
2386
+
2387
+ // The sync should have flushed the cell to .hive/issues.jsonl
2388
+ // We can verify the cell appears in the JSONL
2389
+ const hivePath = `${testProjectPath}/.hive/issues.jsonl`;
2390
+ const hiveFile = Bun.file(hivePath);
2391
+ const exists = await hiveFile.exists();
2392
+
2393
+ // The file should exist after sync
2394
+ expect(exists).toBe(true);
2395
+
2396
+ if (exists) {
2397
+ const content = await hiveFile.text();
2398
+ const lines = content.trim().split("\n");
2399
+
2400
+ // Should have at least one cell exported
2401
+ expect(lines.length).toBeGreaterThan(0);
2402
+
2403
+ // Parse the exported cells to find our closed cell
2404
+ const cells = lines.map((line) => JSON.parse(line));
2405
+ const exportedCell = cells.find((c) => c.id === cell.id);
2406
+
2407
+ // Our cell should be in the export
2408
+ expect(exportedCell).toBeDefined();
2409
+ expect(exportedCell.status).toBe("closed");
2410
+ expect(exportedCell.title).toBe("Test task for auto-sync");
2411
+ }
2412
+ });
2413
+ });
2344
2414
  });