opencode-swarm-plugin 0.1.0

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.
@@ -0,0 +1,665 @@
1
+ /**
2
+ * Agent Mail Module - MCP client for multi-agent coordination
3
+ *
4
+ * This module provides type-safe wrappers around the Agent Mail MCP server.
5
+ * It enforces context-preservation defaults to prevent session exhaustion.
6
+ *
7
+ * CRITICAL CONSTRAINTS:
8
+ * - fetch_inbox ALWAYS uses include_bodies: false
9
+ * - fetch_inbox ALWAYS limits to 5 messages max
10
+ * - Use summarize_thread instead of fetching all messages
11
+ * - Auto-release reservations when tasks complete
12
+ */
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { z } from "zod";
15
+
16
+ // ============================================================================
17
+ // Configuration
18
+ // ============================================================================
19
+
20
+ const AGENT_MAIL_URL = "http://127.0.0.1:8765";
21
+ const DEFAULT_TTL_SECONDS = 3600; // 1 hour
22
+ const MAX_INBOX_LIMIT = 5; // HARD CAP - never exceed this
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /** Agent Mail session state */
29
+ export interface AgentMailState {
30
+ projectKey: string;
31
+ agentName: string;
32
+ reservations: number[];
33
+ startedAt: string;
34
+ }
35
+
36
+ // ============================================================================
37
+ // Module-level state (keyed by sessionID)
38
+ // ============================================================================
39
+
40
+ /**
41
+ * State storage keyed by sessionID.
42
+ * Since ToolContext doesn't have persistent state, we use a module-level map.
43
+ */
44
+ const sessionStates = new Map<string, AgentMailState>();
45
+
46
+ /** MCP JSON-RPC response */
47
+ interface MCPResponse<T = unknown> {
48
+ jsonrpc: "2.0";
49
+ id: string;
50
+ result?: T;
51
+ error?: {
52
+ code: number;
53
+ message: string;
54
+ data?: unknown;
55
+ };
56
+ }
57
+
58
+ /** Agent registration result */
59
+ interface AgentInfo {
60
+ id: number;
61
+ name: string;
62
+ program: string;
63
+ model: string;
64
+ task_description: string;
65
+ inception_ts: string;
66
+ last_active_ts: string;
67
+ project_id: number;
68
+ }
69
+
70
+ /** Project info */
71
+ interface ProjectInfo {
72
+ id: number;
73
+ slug: string;
74
+ human_key: string;
75
+ created_at: string;
76
+ }
77
+
78
+ /** Message header (no body) */
79
+ interface MessageHeader {
80
+ id: number;
81
+ subject: string;
82
+ from: string;
83
+ created_ts: string;
84
+ importance: string;
85
+ ack_required: boolean;
86
+ thread_id?: string;
87
+ kind?: string;
88
+ }
89
+
90
+ /** File reservation result */
91
+ interface ReservationResult {
92
+ granted: Array<{
93
+ id: number;
94
+ path_pattern: string;
95
+ exclusive: boolean;
96
+ reason: string;
97
+ expires_ts: string;
98
+ }>;
99
+ conflicts: Array<{
100
+ path: string;
101
+ holders: string[];
102
+ }>;
103
+ }
104
+
105
+ /** Thread summary */
106
+ interface ThreadSummary {
107
+ thread_id: string;
108
+ summary: {
109
+ participants: string[];
110
+ key_points: string[];
111
+ action_items: string[];
112
+ total_messages: number;
113
+ };
114
+ examples?: Array<{
115
+ id: number;
116
+ subject: string;
117
+ from: string;
118
+ body_md?: string;
119
+ }>;
120
+ }
121
+
122
+ // ============================================================================
123
+ // Errors
124
+ // ============================================================================
125
+
126
+ export class AgentMailError extends Error {
127
+ constructor(
128
+ message: string,
129
+ public readonly tool: string,
130
+ public readonly code?: number,
131
+ public readonly data?: unknown,
132
+ ) {
133
+ super(message);
134
+ this.name = "AgentMailError";
135
+ }
136
+ }
137
+
138
+ export class AgentMailNotInitializedError extends Error {
139
+ constructor() {
140
+ super("Agent Mail not initialized. Call agent-mail:init first.");
141
+ this.name = "AgentMailNotInitializedError";
142
+ }
143
+ }
144
+
145
+ export class FileReservationConflictError extends Error {
146
+ constructor(
147
+ message: string,
148
+ public readonly conflicts: Array<{ path: string; holders: string[] }>,
149
+ ) {
150
+ super(message);
151
+ this.name = "FileReservationConflictError";
152
+ }
153
+ }
154
+
155
+ // ============================================================================
156
+ // MCP Client
157
+ // ============================================================================
158
+
159
+ /** MCP tool result with content wrapper (real Agent Mail format) */
160
+ interface MCPToolResult<T = unknown> {
161
+ content?: Array<{ type: string; text: string }>;
162
+ structuredContent?: T;
163
+ isError?: boolean;
164
+ }
165
+
166
+ /**
167
+ * Call an Agent Mail MCP tool
168
+ *
169
+ * Handles both direct results (mock server) and wrapped results (real server).
170
+ * Real Agent Mail returns: { content: [...], structuredContent: {...} }
171
+ */
172
+ async function mcpCall<T>(
173
+ toolName: string,
174
+ args: Record<string, unknown>,
175
+ ): Promise<T> {
176
+ const response = await fetch(`${AGENT_MAIL_URL}/mcp/`, {
177
+ method: "POST",
178
+ headers: { "Content-Type": "application/json" },
179
+ body: JSON.stringify({
180
+ jsonrpc: "2.0",
181
+ id: crypto.randomUUID(),
182
+ method: "tools/call",
183
+ params: { name: toolName, arguments: args },
184
+ }),
185
+ });
186
+
187
+ if (!response.ok) {
188
+ throw new AgentMailError(
189
+ `HTTP ${response.status}: ${response.statusText}`,
190
+ toolName,
191
+ );
192
+ }
193
+
194
+ const json = (await response.json()) as MCPResponse<MCPToolResult<T> | T>;
195
+
196
+ if (json.error) {
197
+ throw new AgentMailError(
198
+ json.error.message,
199
+ toolName,
200
+ json.error.code,
201
+ json.error.data,
202
+ );
203
+ }
204
+
205
+ const result = json.result;
206
+
207
+ // Handle wrapped response format (real Agent Mail server)
208
+ // Check for isError first (error responses don't have structuredContent)
209
+ if (result && typeof result === "object") {
210
+ const wrapped = result as MCPToolResult<T>;
211
+
212
+ // Check for error response (has isError: true but no structuredContent)
213
+ if (wrapped.isError) {
214
+ const errorText = wrapped.content?.[0]?.text || "Unknown error";
215
+ throw new AgentMailError(errorText, toolName);
216
+ }
217
+
218
+ // Check for success response with structuredContent
219
+ if ("structuredContent" in wrapped) {
220
+ return wrapped.structuredContent as T;
221
+ }
222
+ }
223
+
224
+ // Handle direct response format (mock server)
225
+ return result as T;
226
+ }
227
+
228
+ /**
229
+ * Get Agent Mail state for a session, or throw if not initialized
230
+ */
231
+ function requireState(sessionID: string): AgentMailState {
232
+ const state = sessionStates.get(sessionID);
233
+ if (!state) {
234
+ throw new AgentMailNotInitializedError();
235
+ }
236
+ return state;
237
+ }
238
+
239
+ /**
240
+ * Store Agent Mail state for a session
241
+ */
242
+ function setState(sessionID: string, state: AgentMailState): void {
243
+ sessionStates.set(sessionID, state);
244
+ }
245
+
246
+ /**
247
+ * Get state if exists (for cleanup hooks)
248
+ */
249
+ function getState(sessionID: string): AgentMailState | undefined {
250
+ return sessionStates.get(sessionID);
251
+ }
252
+
253
+ /**
254
+ * Clear state for a session
255
+ */
256
+ function clearState(sessionID: string): void {
257
+ sessionStates.delete(sessionID);
258
+ }
259
+
260
+ // ============================================================================
261
+ // Tool Definitions
262
+ // ============================================================================
263
+
264
+ /**
265
+ * Initialize Agent Mail session
266
+ */
267
+ export const agentmail_init = tool({
268
+ description:
269
+ "Initialize Agent Mail session (ensure project + register agent)",
270
+ args: {
271
+ project_path: tool.schema
272
+ .string()
273
+ .describe("Absolute path to the project/repo"),
274
+ agent_name: tool.schema
275
+ .string()
276
+ .optional()
277
+ .describe("Agent name (omit for auto-generated adjective+noun)"),
278
+ task_description: tool.schema
279
+ .string()
280
+ .optional()
281
+ .describe("Description of current task"),
282
+ },
283
+ async execute(args, ctx) {
284
+ // 1. Ensure project exists
285
+ const project = await mcpCall<ProjectInfo>("ensure_project", {
286
+ human_key: args.project_path,
287
+ });
288
+
289
+ // 2. Register agent
290
+ const agent = await mcpCall<AgentInfo>("register_agent", {
291
+ project_key: args.project_path,
292
+ program: "opencode",
293
+ model: "claude-opus-4",
294
+ name: args.agent_name, // undefined = auto-generate
295
+ task_description: args.task_description || "",
296
+ });
297
+
298
+ // 3. Store state using sessionID
299
+ const state: AgentMailState = {
300
+ projectKey: args.project_path,
301
+ agentName: agent.name,
302
+ reservations: [],
303
+ startedAt: new Date().toISOString(),
304
+ };
305
+ setState(ctx.sessionID, state);
306
+
307
+ return JSON.stringify({ project, agent }, null, 2);
308
+ },
309
+ });
310
+
311
+ /**
312
+ * Send a message to other agents
313
+ */
314
+ export const agentmail_send = tool({
315
+ description: "Send message to other agents",
316
+ args: {
317
+ to: tool.schema
318
+ .array(tool.schema.string())
319
+ .describe("Recipient agent names"),
320
+ subject: tool.schema.string().describe("Message subject"),
321
+ body: tool.schema.string().describe("Message body (Markdown)"),
322
+ thread_id: tool.schema
323
+ .string()
324
+ .optional()
325
+ .describe("Thread ID (use bead ID for linking)"),
326
+ importance: tool.schema
327
+ .enum(["low", "normal", "high", "urgent"])
328
+ .optional()
329
+ .describe("Message importance (default: normal)"),
330
+ ack_required: tool.schema
331
+ .boolean()
332
+ .optional()
333
+ .describe("Require acknowledgement (default: false)"),
334
+ },
335
+ async execute(args, ctx) {
336
+ const state = requireState(ctx.sessionID);
337
+
338
+ await mcpCall("send_message", {
339
+ project_key: state.projectKey,
340
+ sender_name: state.agentName,
341
+ to: args.to,
342
+ subject: args.subject,
343
+ body_md: args.body,
344
+ thread_id: args.thread_id,
345
+ importance: args.importance || "normal",
346
+ ack_required: args.ack_required || false,
347
+ });
348
+
349
+ return `Message sent to ${args.to.join(", ")}`;
350
+ },
351
+ });
352
+
353
+ /**
354
+ * Fetch inbox (CONTEXT-SAFE: bodies excluded, limit 5)
355
+ */
356
+ export const agentmail_inbox = tool({
357
+ description: "Fetch inbox (CONTEXT-SAFE: bodies excluded, limit 5)",
358
+ args: {
359
+ limit: tool.schema
360
+ .number()
361
+ .max(MAX_INBOX_LIMIT)
362
+ .optional()
363
+ .describe(`Max messages (hard cap: ${MAX_INBOX_LIMIT})`),
364
+ urgent_only: tool.schema
365
+ .boolean()
366
+ .optional()
367
+ .describe("Only show urgent messages"),
368
+ since_ts: tool.schema
369
+ .string()
370
+ .optional()
371
+ .describe("Only messages after this ISO-8601 timestamp"),
372
+ },
373
+ async execute(args, ctx) {
374
+ const state = requireState(ctx.sessionID);
375
+
376
+ // CRITICAL: Enforce context-safe defaults
377
+ const limit = Math.min(args.limit || MAX_INBOX_LIMIT, MAX_INBOX_LIMIT);
378
+
379
+ const messages = await mcpCall<MessageHeader[]>("fetch_inbox", {
380
+ project_key: state.projectKey,
381
+ agent_name: state.agentName,
382
+ limit,
383
+ include_bodies: false, // MANDATORY - never include bodies
384
+ urgent_only: args.urgent_only || false,
385
+ since_ts: args.since_ts,
386
+ });
387
+
388
+ return JSON.stringify(messages, null, 2);
389
+ },
390
+ });
391
+
392
+ /**
393
+ * Read a single message body by ID
394
+ */
395
+ export const agentmail_read_message = tool({
396
+ description: "Fetch ONE message body by ID (use after inbox)",
397
+ args: {
398
+ message_id: tool.schema.number().describe("Message ID from inbox"),
399
+ },
400
+ async execute(args, ctx) {
401
+ const state = requireState(ctx.sessionID);
402
+
403
+ // Mark as read
404
+ await mcpCall("mark_message_read", {
405
+ project_key: state.projectKey,
406
+ agent_name: state.agentName,
407
+ message_id: args.message_id,
408
+ });
409
+
410
+ // Fetch with body - we need to use fetch_inbox with specific message
411
+ // Since there's no get_message, we'll use search
412
+ const messages = await mcpCall<MessageHeader[]>("fetch_inbox", {
413
+ project_key: state.projectKey,
414
+ agent_name: state.agentName,
415
+ limit: 1,
416
+ include_bodies: true, // Only for single message fetch
417
+ });
418
+
419
+ const message = messages.find((m) => m.id === args.message_id);
420
+ if (!message) {
421
+ return `Message ${args.message_id} not found`;
422
+ }
423
+
424
+ return JSON.stringify(message, null, 2);
425
+ },
426
+ });
427
+
428
+ /**
429
+ * Summarize a thread (PREFERRED over fetching all messages)
430
+ */
431
+ export const agentmail_summarize_thread = tool({
432
+ description: "Summarize thread (PREFERRED over fetching all messages)",
433
+ args: {
434
+ thread_id: tool.schema.string().describe("Thread ID (usually bead ID)"),
435
+ include_examples: tool.schema
436
+ .boolean()
437
+ .optional()
438
+ .describe("Include up to 3 sample messages"),
439
+ },
440
+ async execute(args, ctx) {
441
+ const state = requireState(ctx.sessionID);
442
+
443
+ const summary = await mcpCall<ThreadSummary>("summarize_thread", {
444
+ project_key: state.projectKey,
445
+ thread_id: args.thread_id,
446
+ include_examples: args.include_examples || false,
447
+ llm_mode: true, // Use LLM for better summaries
448
+ });
449
+
450
+ return JSON.stringify(summary, null, 2);
451
+ },
452
+ });
453
+
454
+ /**
455
+ * Reserve file paths for exclusive editing
456
+ */
457
+ export const agentmail_reserve = tool({
458
+ description: "Reserve file paths for exclusive editing",
459
+ args: {
460
+ paths: tool.schema
461
+ .array(tool.schema.string())
462
+ .describe("File paths or globs to reserve (e.g., src/auth/**)"),
463
+ ttl_seconds: tool.schema
464
+ .number()
465
+ .optional()
466
+ .describe(`Time to live in seconds (default: ${DEFAULT_TTL_SECONDS})`),
467
+ exclusive: tool.schema
468
+ .boolean()
469
+ .optional()
470
+ .describe("Exclusive lock (default: true)"),
471
+ reason: tool.schema
472
+ .string()
473
+ .optional()
474
+ .describe("Reason for reservation (include bead ID)"),
475
+ },
476
+ async execute(args, ctx) {
477
+ const state = requireState(ctx.sessionID);
478
+
479
+ const result = await mcpCall<ReservationResult>("file_reservation_paths", {
480
+ project_key: state.projectKey,
481
+ agent_name: state.agentName,
482
+ paths: args.paths,
483
+ ttl_seconds: args.ttl_seconds || DEFAULT_TTL_SECONDS,
484
+ exclusive: args.exclusive ?? true,
485
+ reason: args.reason || "",
486
+ });
487
+
488
+ // Handle unexpected response structure
489
+ if (!result) {
490
+ throw new AgentMailError(
491
+ "Unexpected response: file_reservation_paths returned null/undefined",
492
+ "file_reservation_paths",
493
+ );
494
+ }
495
+
496
+ // Check for conflicts
497
+ if (result.conflicts && result.conflicts.length > 0) {
498
+ const conflictDetails = result.conflicts
499
+ .map((c) => `${c.path}: held by ${c.holders.join(", ")}`)
500
+ .join("\n");
501
+
502
+ throw new FileReservationConflictError(
503
+ `Cannot reserve files:\n${conflictDetails}`,
504
+ result.conflicts,
505
+ );
506
+ }
507
+
508
+ // Handle case where granted is undefined/null (alternative response formats)
509
+ const granted = result.granted ?? [];
510
+ if (!Array.isArray(granted)) {
511
+ throw new AgentMailError(
512
+ `Unexpected response format: expected granted to be an array, got ${typeof granted}`,
513
+ "file_reservation_paths",
514
+ );
515
+ }
516
+
517
+ // Store reservation IDs for auto-release
518
+ const reservationIds = granted.map((r) => r.id);
519
+ state.reservations = [...state.reservations, ...reservationIds];
520
+ setState(ctx.sessionID, state);
521
+
522
+ if (granted.length === 0) {
523
+ return "No paths were reserved (empty granted list)";
524
+ }
525
+
526
+ return `Reserved ${granted.length} path(s):\n${granted
527
+ .map((r) => ` - ${r.path_pattern} (expires: ${r.expires_ts})`)
528
+ .join("\n")}`;
529
+ },
530
+ });
531
+
532
+ /**
533
+ * Release file reservations
534
+ */
535
+ export const agentmail_release = tool({
536
+ description: "Release file reservations (auto-called on task completion)",
537
+ args: {
538
+ paths: tool.schema
539
+ .array(tool.schema.string())
540
+ .optional()
541
+ .describe("Specific paths to release (omit for all)"),
542
+ reservation_ids: tool.schema
543
+ .array(tool.schema.number())
544
+ .optional()
545
+ .describe("Specific reservation IDs to release"),
546
+ },
547
+ async execute(args, ctx) {
548
+ const state = requireState(ctx.sessionID);
549
+
550
+ const result = await mcpCall<{ released: number; released_at: string }>(
551
+ "release_file_reservations",
552
+ {
553
+ project_key: state.projectKey,
554
+ agent_name: state.agentName,
555
+ paths: args.paths,
556
+ file_reservation_ids: args.reservation_ids,
557
+ },
558
+ );
559
+
560
+ // Clear stored reservation IDs
561
+ state.reservations = [];
562
+ setState(ctx.sessionID, state);
563
+
564
+ return `Released ${result.released} reservation(s)`;
565
+ },
566
+ });
567
+
568
+ /**
569
+ * Acknowledge a message
570
+ */
571
+ export const agentmail_ack = tool({
572
+ description: "Acknowledge a message (for ack_required messages)",
573
+ args: {
574
+ message_id: tool.schema.number().describe("Message ID to acknowledge"),
575
+ },
576
+ async execute(args, ctx) {
577
+ const state = requireState(ctx.sessionID);
578
+
579
+ await mcpCall("acknowledge_message", {
580
+ project_key: state.projectKey,
581
+ agent_name: state.agentName,
582
+ message_id: args.message_id,
583
+ });
584
+
585
+ return `Acknowledged message ${args.message_id}`;
586
+ },
587
+ });
588
+
589
+ /**
590
+ * Search messages
591
+ */
592
+ export const agentmail_search = tool({
593
+ description: "Search messages by keyword (FTS5 syntax supported)",
594
+ args: {
595
+ query: tool.schema
596
+ .string()
597
+ .describe('Search query (e.g., "build plan", plan AND users)'),
598
+ limit: tool.schema
599
+ .number()
600
+ .optional()
601
+ .describe("Max results (default: 20)"),
602
+ },
603
+ async execute(args, ctx) {
604
+ const state = requireState(ctx.sessionID);
605
+
606
+ const results = await mcpCall<MessageHeader[]>("search_messages", {
607
+ project_key: state.projectKey,
608
+ query: args.query,
609
+ limit: args.limit || 20,
610
+ });
611
+
612
+ return JSON.stringify(results, null, 2);
613
+ },
614
+ });
615
+
616
+ /**
617
+ * Check Agent Mail health
618
+ */
619
+ export const agentmail_health = tool({
620
+ description: "Check if Agent Mail server is running",
621
+ args: {},
622
+ async execute(args, ctx) {
623
+ try {
624
+ const response = await fetch(`${AGENT_MAIL_URL}/health/liveness`);
625
+ if (response.ok) {
626
+ return "Agent Mail is running";
627
+ }
628
+ return `Agent Mail returned status ${response.status}`;
629
+ } catch (error) {
630
+ return `Agent Mail not reachable: ${error instanceof Error ? error.message : String(error)}`;
631
+ }
632
+ },
633
+ });
634
+
635
+ // ============================================================================
636
+ // Export all tools
637
+ // ============================================================================
638
+
639
+ export const agentMailTools = {
640
+ agentmail_init: agentmail_init,
641
+ agentmail_send: agentmail_send,
642
+ agentmail_inbox: agentmail_inbox,
643
+ agentmail_read_message: agentmail_read_message,
644
+ agentmail_summarize_thread: agentmail_summarize_thread,
645
+ agentmail_reserve: agentmail_reserve,
646
+ agentmail_release: agentmail_release,
647
+ agentmail_ack: agentmail_ack,
648
+ agentmail_search: agentmail_search,
649
+ agentmail_health: agentmail_health,
650
+ };
651
+
652
+ // ============================================================================
653
+ // Utility exports for other modules
654
+ // ============================================================================
655
+
656
+ export {
657
+ mcpCall,
658
+ requireState,
659
+ setState,
660
+ getState,
661
+ clearState,
662
+ sessionStates,
663
+ AGENT_MAIL_URL,
664
+ MAX_INBOX_LIMIT,
665
+ };