opencode-swarm-plugin 0.12.19 → 0.12.22

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/agent-mail.ts CHANGED
@@ -27,6 +27,35 @@ const AGENT_MAIL_URL = "http://127.0.0.1:8765";
27
27
  const DEFAULT_TTL_SECONDS = 3600; // 1 hour
28
28
  const MAX_INBOX_LIMIT = 5; // HARD CAP - never exceed this
29
29
 
30
+ /**
31
+ * Default project directory for Agent Mail operations
32
+ *
33
+ * This is set by the plugin init to the actual working directory (from OpenCode).
34
+ * Without this, tools might use the plugin's directory instead of the project's.
35
+ *
36
+ * Set this via setAgentMailProjectDirectory() before using tools.
37
+ */
38
+ let agentMailProjectDirectory: string | null = null;
39
+
40
+ /**
41
+ * Set the default project directory for Agent Mail operations
42
+ *
43
+ * Called during plugin initialization with the actual project directory.
44
+ * This ensures agentmail_init uses the correct project path by default.
45
+ */
46
+ export function setAgentMailProjectDirectory(directory: string): void {
47
+ agentMailProjectDirectory = directory;
48
+ }
49
+
50
+ /**
51
+ * Get the default project directory
52
+ *
53
+ * Returns the configured directory, or falls back to cwd if not set.
54
+ */
55
+ export function getAgentMailProjectDirectory(): string {
56
+ return agentMailProjectDirectory || process.cwd();
57
+ }
58
+
30
59
  // Retry configuration
31
60
  const RETRY_CONFIG = {
32
61
  maxRetries: parseInt(process.env.OPENCODE_AGENT_MAIL_MAX_RETRIES || "3"),
@@ -541,6 +570,39 @@ function isRetryableError(error: unknown): boolean {
541
570
  return false;
542
571
  }
543
572
 
573
+ /**
574
+ * Check if an error indicates the project was not found
575
+ *
576
+ * This happens when Agent Mail server restarts and loses project registrations.
577
+ * The fix is to re-register the project and retry the operation.
578
+ */
579
+ export function isProjectNotFoundError(error: unknown): boolean {
580
+ if (error instanceof Error) {
581
+ const message = error.message.toLowerCase();
582
+ return (
583
+ message.includes("project") &&
584
+ (message.includes("not found") || message.includes("does not exist"))
585
+ );
586
+ }
587
+ return false;
588
+ }
589
+
590
+ /**
591
+ * Check if an error indicates the agent was not found
592
+ *
593
+ * Similar to project not found - server restart loses agent registrations.
594
+ */
595
+ export function isAgentNotFoundError(error: unknown): boolean {
596
+ if (error instanceof Error) {
597
+ const message = error.message.toLowerCase();
598
+ return (
599
+ message.includes("agent") &&
600
+ (message.includes("not found") || message.includes("does not exist"))
601
+ );
602
+ }
603
+ return false;
604
+ }
605
+
544
606
  // ============================================================================
545
607
  // MCP Client
546
608
  // ============================================================================
@@ -823,6 +885,153 @@ export async function mcpCall<T>(
823
885
  throw lastError || new Error("Unknown error in mcpCall");
824
886
  }
825
887
 
888
+ /**
889
+ * Re-register a project with Agent Mail server
890
+ *
891
+ * Called when we detect "Project not found" error, indicating server restart.
892
+ * This is a lightweight operation that just ensures the project exists.
893
+ */
894
+ async function reRegisterProject(projectKey: string): Promise<boolean> {
895
+ try {
896
+ console.warn(
897
+ `[agent-mail] Re-registering project "${projectKey}" after server restart...`,
898
+ );
899
+ await mcpCall<ProjectInfo>("ensure_project", {
900
+ human_key: projectKey,
901
+ });
902
+ console.warn(
903
+ `[agent-mail] Project "${projectKey}" re-registered successfully`,
904
+ );
905
+ return true;
906
+ } catch (error) {
907
+ console.error(
908
+ `[agent-mail] Failed to re-register project "${projectKey}":`,
909
+ error,
910
+ );
911
+ return false;
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Re-register an agent with Agent Mail server
917
+ *
918
+ * Called when we detect "Agent not found" error, indicating server restart.
919
+ */
920
+ async function reRegisterAgent(
921
+ projectKey: string,
922
+ agentName: string,
923
+ taskDescription?: string,
924
+ ): Promise<boolean> {
925
+ try {
926
+ console.warn(
927
+ `[agent-mail] Re-registering agent "${agentName}" for project "${projectKey}"...`,
928
+ );
929
+ await mcpCall<AgentInfo>("register_agent", {
930
+ project_key: projectKey,
931
+ program: "opencode",
932
+ model: "claude-opus-4",
933
+ name: agentName,
934
+ task_description: taskDescription || "Re-registered after server restart",
935
+ });
936
+ console.warn(
937
+ `[agent-mail] Agent "${agentName}" re-registered successfully`,
938
+ );
939
+ return true;
940
+ } catch (error) {
941
+ console.error(
942
+ `[agent-mail] Failed to re-register agent "${agentName}":`,
943
+ error,
944
+ );
945
+ return false;
946
+ }
947
+ }
948
+
949
+ /**
950
+ * MCP call with automatic project/agent re-registration on "not found" errors
951
+ *
952
+ * This is the self-healing wrapper that handles Agent Mail server restarts.
953
+ * When the server restarts, it loses all project and agent registrations.
954
+ * This wrapper detects those errors and automatically re-registers before retrying.
955
+ *
956
+ * Use this instead of raw mcpCall when you have project_key and agent_name context.
957
+ *
958
+ * @param toolName - The MCP tool to call
959
+ * @param args - Arguments including project_key and optionally agent_name
960
+ * @param options - Optional configuration for re-registration
961
+ * @returns The result of the MCP call
962
+ */
963
+ export async function mcpCallWithAutoInit<T>(
964
+ toolName: string,
965
+ args: Record<string, unknown> & { project_key: string; agent_name?: string },
966
+ options?: {
967
+ /** Task description for agent re-registration */
968
+ taskDescription?: string;
969
+ /** Max re-registration attempts (default: 1) */
970
+ maxReregistrationAttempts?: number;
971
+ },
972
+ ): Promise<T> {
973
+ const maxAttempts = options?.maxReregistrationAttempts ?? 1;
974
+ let reregistrationAttempts = 0;
975
+
976
+ while (true) {
977
+ try {
978
+ return await mcpCall<T>(toolName, args);
979
+ } catch (error) {
980
+ // Check if this is a recoverable "not found" error
981
+ const isProjectError = isProjectNotFoundError(error);
982
+ const isAgentError = isAgentNotFoundError(error);
983
+
984
+ if (!isProjectError && !isAgentError) {
985
+ // Not a recoverable error, rethrow
986
+ throw error;
987
+ }
988
+
989
+ // Check if we've exhausted re-registration attempts
990
+ if (reregistrationAttempts >= maxAttempts) {
991
+ console.error(
992
+ `[agent-mail] Exhausted ${maxAttempts} re-registration attempt(s) for ${toolName}`,
993
+ );
994
+ throw error;
995
+ }
996
+
997
+ reregistrationAttempts++;
998
+ console.warn(
999
+ `[agent-mail] Detected "${isProjectError ? "project" : "agent"} not found" for ${toolName}, ` +
1000
+ `attempting re-registration (attempt ${reregistrationAttempts}/${maxAttempts})...`,
1001
+ );
1002
+
1003
+ // Re-register project first (always needed)
1004
+ const projectOk = await reRegisterProject(args.project_key);
1005
+ if (!projectOk) {
1006
+ throw error; // Can't recover without project
1007
+ }
1008
+
1009
+ // Re-register agent if we have one and it was an agent error
1010
+ // (or if the original call needs an agent)
1011
+ if (args.agent_name && (isAgentError || toolName !== "ensure_project")) {
1012
+ const agentOk = await reRegisterAgent(
1013
+ args.project_key,
1014
+ args.agent_name,
1015
+ options?.taskDescription,
1016
+ );
1017
+ if (!agentOk) {
1018
+ // Agent re-registration failed, but project is OK
1019
+ // Some operations might still work, so continue
1020
+ console.warn(
1021
+ `[agent-mail] Agent re-registration failed, but continuing with retry...`,
1022
+ );
1023
+ }
1024
+ }
1025
+
1026
+ // Retry the original call
1027
+ console.warn(
1028
+ `[agent-mail] Retrying ${toolName} after re-registration...`,
1029
+ );
1030
+ // Loop continues to retry
1031
+ }
1032
+ }
1033
+ }
1034
+
826
1035
  /**
827
1036
  * Get Agent Mail state for a session, or throw if not initialized
828
1037
  *
@@ -897,7 +1106,10 @@ export const agentmail_init = tool({
897
1106
  args: {
898
1107
  project_path: tool.schema
899
1108
  .string()
900
- .describe("Absolute path to the project/repo"),
1109
+ .optional()
1110
+ .describe(
1111
+ "Absolute path to the project/repo (defaults to current working directory)",
1112
+ ),
901
1113
  agent_name: tool.schema
902
1114
  .string()
903
1115
  .optional()
@@ -908,6 +1120,10 @@ export const agentmail_init = tool({
908
1120
  .describe("Description of current task"),
909
1121
  },
910
1122
  async execute(args, ctx) {
1123
+ // Use provided path or fall back to configured project directory
1124
+ // This prevents using the plugin's directory when working in a different project
1125
+ const projectPath = args.project_path || getAgentMailProjectDirectory();
1126
+
911
1127
  // Check if Agent Mail is available
912
1128
  const available = await checkAgentMailAvailable();
913
1129
  if (!available) {
@@ -933,12 +1149,12 @@ export const agentmail_init = tool({
933
1149
  try {
934
1150
  // 1. Ensure project exists
935
1151
  const project = await mcpCall<ProjectInfo>("ensure_project", {
936
- human_key: args.project_path,
1152
+ human_key: projectPath,
937
1153
  });
938
1154
 
939
1155
  // 2. Register agent
940
1156
  const agent = await mcpCall<AgentInfo>("register_agent", {
941
- project_key: args.project_path,
1157
+ project_key: projectPath,
942
1158
  program: "opencode",
943
1159
  model: "claude-opus-4",
944
1160
  name: args.agent_name, // undefined = auto-generate
@@ -947,7 +1163,7 @@ export const agentmail_init = tool({
947
1163
 
948
1164
  // 3. Store state using sessionID
949
1165
  const state: AgentMailState = {
950
- projectKey: args.project_path,
1166
+ projectKey: projectPath,
951
1167
  agentName: agent.name,
952
1168
  reservations: [],
953
1169
  startedAt: new Date().toISOString(),
@@ -1490,4 +1706,6 @@ export {
1490
1706
  restartServer,
1491
1707
  RETRY_CONFIG,
1492
1708
  RECOVERY_CONFIG,
1709
+ // Note: isProjectNotFoundError, isAgentNotFoundError, mcpCallWithAutoInit
1710
+ // are exported at their definitions
1493
1711
  };
package/src/beads.ts CHANGED
@@ -159,6 +159,11 @@ function buildCreateCommand(args: BeadCreateArgs): string[] {
159
159
  parts.push("--parent", args.parent_id);
160
160
  }
161
161
 
162
+ // Custom ID for human-readable bead names (e.g., 'phase-0', 'phase-1.e2e-test')
163
+ if (args.id) {
164
+ parts.push("--id", args.id);
165
+ }
166
+
162
167
  parts.push("--json");
163
168
  return parts;
164
169
  }
@@ -288,12 +293,22 @@ export const beads_create_epic = tool({
288
293
  .string()
289
294
  .optional()
290
295
  .describe("Epic description"),
296
+ epic_id: tool.schema
297
+ .string()
298
+ .optional()
299
+ .describe("Custom ID for the epic (e.g., 'phase-0')"),
291
300
  subtasks: tool.schema
292
301
  .array(
293
302
  tool.schema.object({
294
303
  title: tool.schema.string(),
295
304
  priority: tool.schema.number().min(0).max(3).optional(),
296
305
  files: tool.schema.array(tool.schema.string()).optional(),
306
+ id_suffix: tool.schema
307
+ .string()
308
+ .optional()
309
+ .describe(
310
+ "Custom ID suffix (e.g., 'e2e-test' becomes 'phase-0.e2e-test')",
311
+ ),
297
312
  }),
298
313
  )
299
314
  .describe("Subtasks to create under the epic"),
@@ -309,6 +324,7 @@ export const beads_create_epic = tool({
309
324
  type: "epic",
310
325
  priority: 1,
311
326
  description: validated.epic_description,
327
+ id: validated.epic_id,
312
328
  });
313
329
 
314
330
  const epicResult = await runBdCommand(epicCmd.slice(1)); // Remove 'bd' prefix
@@ -326,11 +342,19 @@ export const beads_create_epic = tool({
326
342
 
327
343
  // 2. Create subtasks
328
344
  for (const subtask of validated.subtasks) {
345
+ // Build subtask ID: if epic has custom ID and subtask has suffix, combine them
346
+ // e.g., epic_id='phase-0', id_suffix='e2e-test' → 'phase-0.e2e-test'
347
+ let subtaskId: string | undefined;
348
+ if (validated.epic_id && subtask.id_suffix) {
349
+ subtaskId = `${validated.epic_id}.${subtask.id_suffix}`;
350
+ }
351
+
329
352
  const subtaskCmd = buildCreateCommand({
330
353
  title: subtask.title,
331
354
  type: "task",
332
355
  priority: subtask.priority ?? 2,
333
356
  parent_id: epic.id,
357
+ id: subtaskId,
334
358
  });
335
359
 
336
360
  const subtaskResult = await runBdCommand(subtaskCmd.slice(1)); // Remove 'bd' prefix
@@ -717,11 +741,61 @@ export const beads_sync = tool({
717
741
 
718
742
  // 5. Pull if requested (with rebase to avoid merge commits)
719
743
  if (autoPull) {
744
+ // Check for unstaged changes that would block pull --rebase
745
+ const dirtyCheckResult = await runGitCommand([
746
+ "status",
747
+ "--porcelain",
748
+ "--untracked-files=no",
749
+ ]);
750
+ const hasDirtyFiles = dirtyCheckResult.stdout.trim() !== "";
751
+ let didStash = false;
752
+
753
+ // Stash dirty files before pull (self-healing for "unstaged changes" error)
754
+ if (hasDirtyFiles) {
755
+ console.warn(
756
+ "[beads] Detected unstaged changes, stashing before pull...",
757
+ );
758
+ const stashResult = await runGitCommand([
759
+ "stash",
760
+ "push",
761
+ "-m",
762
+ "beads_sync: auto-stash before pull",
763
+ "--include-untracked",
764
+ ]);
765
+ if (stashResult.exitCode === 0) {
766
+ didStash = true;
767
+ console.warn("[beads] Changes stashed successfully");
768
+ } else {
769
+ // Stash failed - try pull anyway, it might work
770
+ console.warn(
771
+ `[beads] Stash failed (${stashResult.stderr}), attempting pull anyway...`,
772
+ );
773
+ }
774
+ }
775
+
720
776
  const pullResult = await withTimeout(
721
777
  runGitCommand(["pull", "--rebase"]),
722
778
  TIMEOUT_MS,
723
779
  "git pull --rebase",
724
780
  );
781
+
782
+ // Restore stashed changes regardless of pull result
783
+ if (didStash) {
784
+ console.warn("[beads] Restoring stashed changes...");
785
+ const unstashResult = await runGitCommand(["stash", "pop"]);
786
+ if (unstashResult.exitCode !== 0) {
787
+ // Unstash failed - this is bad, user needs to know
788
+ console.error(
789
+ `[beads] WARNING: Failed to restore stashed changes: ${unstashResult.stderr}`,
790
+ );
791
+ console.error(
792
+ "[beads] Your changes are in 'git stash list' - run 'git stash pop' manually",
793
+ );
794
+ } else {
795
+ console.warn("[beads] Stashed changes restored");
796
+ }
797
+ }
798
+
725
799
  if (pullResult.exitCode !== 0) {
726
800
  throw new BeadError(
727
801
  `Failed to pull: ${pullResult.stderr}`,
package/src/index.ts CHANGED
@@ -25,12 +25,14 @@ import type { Plugin, PluginInput, Hooks } from "@opencode-ai/plugin";
25
25
  import { beadsTools, setBeadsWorkingDirectory } from "./beads";
26
26
  import {
27
27
  agentMailTools,
28
+ setAgentMailProjectDirectory,
28
29
  type AgentMailState,
29
30
  AGENT_MAIL_URL,
30
31
  } from "./agent-mail";
31
32
  import { structuredTools } from "./structured";
32
33
  import { swarmTools } from "./swarm";
33
34
  import { repoCrawlTools } from "./repo-crawl";
35
+ import { skillsTools, setSkillsProjectDirectory } from "./skills";
34
36
 
35
37
  /**
36
38
  * OpenCode Swarm Plugin
@@ -41,6 +43,7 @@ import { repoCrawlTools } from "./repo-crawl";
41
43
  * - structured:* - Structured output parsing and validation
42
44
  * - swarm:* - Swarm orchestration and task decomposition
43
45
  * - repo-crawl:* - GitHub API tools for repository research
46
+ * - skills:* - Agent skills discovery, activation, and execution
44
47
  *
45
48
  * @param input - Plugin context from OpenCode
46
49
  * @returns Plugin hooks including tools, events, and tool execution hooks
@@ -54,6 +57,15 @@ export const SwarmPlugin: Plugin = async (
54
57
  // This ensures bd runs in the project directory, not ~/.config/opencode
55
58
  setBeadsWorkingDirectory(directory);
56
59
 
60
+ // Set the project directory for skills discovery
61
+ // Skills are discovered from .opencode/skills/, .claude/skills/, or skills/
62
+ setSkillsProjectDirectory(directory);
63
+
64
+ // Set the project directory for Agent Mail
65
+ // This ensures agentmail_init uses the correct project path by default
66
+ // (prevents using plugin directory when working in a different project)
67
+ setAgentMailProjectDirectory(directory);
68
+
57
69
  /** Track active sessions for cleanup */
58
70
  let activeAgentMailState: AgentMailState | null = null;
59
71
 
@@ -116,6 +128,7 @@ export const SwarmPlugin: Plugin = async (
116
128
  ...structuredTools,
117
129
  ...swarmTools,
118
130
  ...repoCrawlTools,
131
+ ...skillsTools,
119
132
  },
120
133
 
121
134
  /**
@@ -239,6 +252,11 @@ export {
239
252
  AgentMailNotInitializedError,
240
253
  FileReservationConflictError,
241
254
  createAgentMailError,
255
+ setAgentMailProjectDirectory,
256
+ getAgentMailProjectDirectory,
257
+ mcpCallWithAutoInit,
258
+ isProjectNotFoundError,
259
+ isAgentNotFoundError,
242
260
  type AgentMailState,
243
261
  } from "./agent-mail";
244
262
 
@@ -305,6 +323,7 @@ export const allTools = {
305
323
  ...structuredTools,
306
324
  ...swarmTools,
307
325
  ...repoCrawlTools,
326
+ ...skillsTools,
308
327
  } as const;
309
328
 
310
329
  /**
@@ -387,3 +406,33 @@ export {
387
406
  * - Graceful rate limit handling
388
407
  */
389
408
  export { repoCrawlTools, RepoCrawlError } from "./repo-crawl";
409
+
410
+ /**
411
+ * Re-export skills module
412
+ *
413
+ * Implements Anthropic's Agent Skills specification for OpenCode.
414
+ *
415
+ * Includes:
416
+ * - skillsTools - All skills tools (list, use, execute, read)
417
+ * - discoverSkills, getSkill, listSkills - Discovery functions
418
+ * - parseFrontmatter - YAML frontmatter parser
419
+ * - getSkillsContextForSwarm - Swarm integration helper
420
+ * - findRelevantSkills - Task-based skill matching
421
+ *
422
+ * Types:
423
+ * - Skill, SkillMetadata, SkillRef - Skill data types
424
+ */
425
+ export {
426
+ skillsTools,
427
+ discoverSkills,
428
+ getSkill,
429
+ listSkills,
430
+ parseFrontmatter,
431
+ setSkillsProjectDirectory,
432
+ invalidateSkillsCache,
433
+ getSkillsContextForSwarm,
434
+ findRelevantSkills,
435
+ type Skill,
436
+ type SkillMetadata,
437
+ type SkillRef,
438
+ } from "./skills";
@@ -38,11 +38,13 @@ export type BeadDependency = z.infer<typeof BeadDependencySchema>;
38
38
  * ID format:
39
39
  * - Standard: `{project}-{hash}` (e.g., `opencode-swarm-plugin-1i8`)
40
40
  * - Subtask: `{project}-{hash}.{index}` (e.g., `opencode-swarm-plugin-1i8.1`)
41
+ * - Custom: `{project}-{custom-id}` (e.g., `migrate-egghead-phase-0`)
42
+ * - Custom subtask: `{project}-{custom-id}.{suffix}` (e.g., `migrate-egghead-phase-0.e2e-test`)
41
43
  */
42
44
  export const BeadSchema = z.object({
43
45
  id: z
44
46
  .string()
45
- .regex(/^[a-z0-9]+(-[a-z0-9]+)+(\.\d+)?$/, "Invalid bead ID format"),
47
+ .regex(/^[a-z0-9]+(-[a-z0-9]+)+(\.[\w-]+)?$/, "Invalid bead ID format"),
46
48
  title: z.string().min(1, "Title required"),
47
49
  description: z.string().optional().default(""),
48
50
  status: BeadStatusSchema.default("open"),
@@ -64,6 +66,12 @@ export const BeadCreateArgsSchema = z.object({
64
66
  priority: z.number().int().min(0).max(3).default(2),
65
67
  description: z.string().optional(),
66
68
  parent_id: z.string().optional(),
69
+ /**
70
+ * Custom ID for human-readable bead names.
71
+ * MUST include project prefix (e.g., 'migrate-egghead-phase-0', not just 'phase-0').
72
+ * For subtasks, use dot notation: 'migrate-egghead-phase-0.e2e-test'
73
+ */
74
+ id: z.string().optional(),
67
75
  });
68
76
  export type BeadCreateArgs = z.infer<typeof BeadCreateArgsSchema>;
69
77
 
@@ -125,12 +133,24 @@ export type BeadTree = z.infer<typeof BeadTreeSchema>;
125
133
  export const EpicCreateArgsSchema = z.object({
126
134
  epic_title: z.string().min(1),
127
135
  epic_description: z.string().optional(),
136
+ /**
137
+ * Custom ID for the epic. MUST include project prefix.
138
+ * Example: 'migrate-egghead-phase-0' (not just 'phase-0')
139
+ * If not provided, bd generates a random ID.
140
+ */
141
+ epic_id: z.string().optional(),
128
142
  subtasks: z
129
143
  .array(
130
144
  z.object({
131
145
  title: z.string().min(1),
132
146
  priority: z.number().int().min(0).max(3).default(2),
133
147
  files: z.array(z.string()).optional().default([]),
148
+ /**
149
+ * Custom ID suffix for subtask. Combined with epic_id using dot notation.
150
+ * Example: epic_id='migrate-egghead-phase-0', id_suffix='e2e-test'
151
+ * → subtask ID: 'migrate-egghead-phase-0.e2e-test'
152
+ */
153
+ id_suffix: z.string().optional(),
134
154
  }),
135
155
  )
136
156
  .min(1),