claude-code-swarm 0.3.5 → 0.3.7

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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.claude-plugin/run-agent-inbox-mcp.sh +22 -3
  4. package/.gitattributes +3 -0
  5. package/.opentasks/config.json +9 -0
  6. package/.opentasks/graph.jsonl +0 -0
  7. package/e2e/helpers/opentasks-daemon.mjs +149 -0
  8. package/e2e/tier6-live-inbox-flow.test.mjs +938 -0
  9. package/e2e/tier7-hooks.test.mjs +992 -0
  10. package/e2e/tier7-minimem.test.mjs +461 -0
  11. package/e2e/tier7-opentasks.test.mjs +513 -0
  12. package/e2e/tier7-skilltree.test.mjs +506 -0
  13. package/e2e/vitest.config.e2e.mjs +1 -1
  14. package/package.json +6 -2
  15. package/references/agent-inbox/package-lock.json +2 -2
  16. package/references/agent-inbox/package.json +1 -1
  17. package/references/agent-inbox/src/index.ts +16 -2
  18. package/references/agent-inbox/src/ipc/ipc-server.ts +58 -0
  19. package/references/agent-inbox/src/mcp/mcp-proxy.ts +326 -0
  20. package/references/agent-inbox/src/types.ts +26 -0
  21. package/references/agent-inbox/test/ipc-new-commands.test.ts +200 -0
  22. package/references/agent-inbox/test/mcp-proxy.test.ts +191 -0
  23. package/references/minimem/package-lock.json +2 -2
  24. package/references/minimem/package.json +1 -1
  25. package/scripts/bootstrap.mjs +8 -1
  26. package/scripts/map-hook.mjs +6 -2
  27. package/scripts/map-sidecar.mjs +19 -0
  28. package/scripts/team-loader.mjs +15 -8
  29. package/skills/swarm/SKILL.md +16 -22
  30. package/src/__tests__/agent-generator.test.mjs +9 -10
  31. package/src/__tests__/context-output.test.mjs +13 -14
  32. package/src/__tests__/e2e-inbox-integration.test.mjs +732 -0
  33. package/src/__tests__/e2e-live-inbox.test.mjs +597 -0
  34. package/src/__tests__/inbox-integration.test.mjs +298 -0
  35. package/src/__tests__/integration.test.mjs +12 -11
  36. package/src/__tests__/skilltree-client.test.mjs +47 -1
  37. package/src/agent-generator.mjs +79 -88
  38. package/src/bootstrap.mjs +24 -3
  39. package/src/context-output.mjs +238 -64
  40. package/src/index.mjs +2 -0
  41. package/src/sidecar-server.mjs +30 -0
  42. package/src/skilltree-client.mjs +50 -5
@@ -0,0 +1,326 @@
1
+ /**
2
+ * mcp-proxy.ts — MCP server that proxies all tools to an existing inbox IPC socket.
3
+ *
4
+ * Instead of creating its own storage/router, this connects to a running
5
+ * agent-inbox IPC server (e.g., the sidecar's inbox instance) and translates
6
+ * MCP tool calls into IPC commands.
7
+ *
8
+ * This ensures a single source of truth for messages, agents, and routing.
9
+ */
10
+
11
+ import * as net from "node:net";
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import { z } from "zod";
15
+ import type { IpcResponse } from "../types.js";
16
+
17
+ const IPC_TIMEOUT_MS = 5000;
18
+ const CONNECT_RETRY_MS = 500;
19
+ const CONNECT_MAX_RETRIES = 10;
20
+
21
+ export class InboxMcpProxy {
22
+ private mcp: McpServer;
23
+
24
+ constructor(
25
+ private socketPath: string,
26
+ private defaultAgentId: string = "anonymous",
27
+ private defaultScope: string = "default"
28
+ ) {
29
+ this.mcp = new McpServer({
30
+ name: "agent-inbox",
31
+ version: "0.1.0",
32
+ });
33
+
34
+ this.registerTools();
35
+ }
36
+
37
+ /**
38
+ * Send an IPC command to the inbox socket and return the response.
39
+ * Retries connection if socket is not yet available.
40
+ */
41
+ private async sendIpc(command: Record<string, unknown>): Promise<IpcResponse> {
42
+ let lastError: Error | null = null;
43
+
44
+ for (let attempt = 0; attempt < CONNECT_MAX_RETRIES; attempt++) {
45
+ try {
46
+ return await this.sendIpcOnce(command);
47
+ } catch (err) {
48
+ lastError = err instanceof Error ? err : new Error(String(err));
49
+ // Only retry on connection errors (socket not ready yet)
50
+ if (lastError.message.includes("ENOENT") || lastError.message.includes("ECONNREFUSED")) {
51
+ if (attempt < CONNECT_MAX_RETRIES - 1) {
52
+ await new Promise((r) => setTimeout(r, CONNECT_RETRY_MS));
53
+ continue;
54
+ }
55
+ }
56
+ break;
57
+ }
58
+ }
59
+
60
+ return { ok: false, error: `Inbox unavailable: ${lastError?.message ?? "unknown error"}` };
61
+ }
62
+
63
+ private sendIpcOnce(command: Record<string, unknown>): Promise<IpcResponse> {
64
+ return new Promise((resolve, reject) => {
65
+ const client = net.createConnection(this.socketPath);
66
+ let buffer = "";
67
+ let settled = false;
68
+
69
+ const timer = setTimeout(() => {
70
+ if (!settled) {
71
+ settled = true;
72
+ client.destroy();
73
+ reject(new Error("IPC timeout"));
74
+ }
75
+ }, IPC_TIMEOUT_MS);
76
+
77
+ client.on("connect", () => {
78
+ client.write(JSON.stringify(command) + "\n");
79
+ });
80
+
81
+ client.on("data", (data) => {
82
+ buffer += data.toString();
83
+ const newlineIdx = buffer.indexOf("\n");
84
+ if (newlineIdx !== -1) {
85
+ clearTimeout(timer);
86
+ settled = true;
87
+ const line = buffer.slice(0, newlineIdx).trim();
88
+ client.destroy();
89
+ try {
90
+ resolve(JSON.parse(line) as IpcResponse);
91
+ } catch {
92
+ reject(new Error("Invalid IPC response"));
93
+ }
94
+ }
95
+ });
96
+
97
+ client.on("error", (err) => {
98
+ if (!settled) {
99
+ clearTimeout(timer);
100
+ settled = true;
101
+ reject(err);
102
+ }
103
+ });
104
+ });
105
+ }
106
+
107
+ private registerTools(): void {
108
+ this.mcp.tool(
109
+ "send_message",
110
+ "Send a message to one or more agents. Supports replies (inReplyTo), threading (threadTag), and federated addressing (agent@system).",
111
+ {
112
+ to: z
113
+ .union([z.string(), z.array(z.string())])
114
+ .describe(
115
+ "Recipient agent ID(s). Use 'agent@system' for federated addressing."
116
+ ),
117
+ body: z
118
+ .string()
119
+ .optional()
120
+ .describe("Plain text message body (shorthand for content)"),
121
+ content: z
122
+ .object({ type: z.string() })
123
+ .passthrough()
124
+ .optional()
125
+ .describe("Structured message content"),
126
+ from: z
127
+ .string()
128
+ .optional()
129
+ .describe("Sender agent ID (defaults to caller)"),
130
+ threadTag: z
131
+ .string()
132
+ .optional()
133
+ .describe("Thread tag for grouping related messages"),
134
+ inReplyTo: z
135
+ .string()
136
+ .optional()
137
+ .describe("Message ID this is a reply to"),
138
+ importance: z
139
+ .enum(["low", "normal", "high", "urgent"])
140
+ .optional()
141
+ .describe("Message importance level"),
142
+ subject: z.string().optional().describe("Message subject"),
143
+ },
144
+ async ({ to, body, content, from, threadTag, inReplyTo, importance, subject }) => {
145
+ const payload = content ?? body ?? "";
146
+ const senderId = from ?? this.defaultAgentId;
147
+ const resp = await this.sendIpc({
148
+ action: "send",
149
+ from: senderId,
150
+ to,
151
+ payload,
152
+ threadTag,
153
+ inReplyTo,
154
+ importance,
155
+ subject,
156
+ });
157
+ return {
158
+ content: [
159
+ {
160
+ type: "text" as const,
161
+ text: JSON.stringify(
162
+ resp.ok
163
+ ? { ok: true, messageId: resp.messageId }
164
+ : { ok: false, error: resp.error }
165
+ ),
166
+ },
167
+ ],
168
+ };
169
+ }
170
+ );
171
+
172
+ this.mcp.tool(
173
+ "check_inbox",
174
+ "Check inbox for messages addressed to an agent. Auto-registers the agent if not already registered.",
175
+ {
176
+ agentId: z.string().describe("Agent ID to check inbox for"),
177
+ unreadOnly: z
178
+ .boolean()
179
+ .optional()
180
+ .describe("Only return unread messages (default true)"),
181
+ limit: z
182
+ .number()
183
+ .optional()
184
+ .describe("Max messages to return"),
185
+ },
186
+ async ({ agentId, unreadOnly, limit }) => {
187
+ const resp = await this.sendIpc({
188
+ action: "check_inbox",
189
+ agentId,
190
+ unreadOnly: unreadOnly ?? true,
191
+ clear: true, // Mark as read after retrieval
192
+ });
193
+
194
+ if (!resp.ok) {
195
+ return {
196
+ content: [
197
+ { type: "text" as const, text: JSON.stringify({ ok: false, error: resp.error }) },
198
+ ],
199
+ };
200
+ }
201
+
202
+ let messages = resp.messages ?? [];
203
+ if (limit && messages.length > limit) {
204
+ messages = messages.slice(0, limit);
205
+ }
206
+
207
+ return {
208
+ content: [
209
+ {
210
+ type: "text" as const,
211
+ text: JSON.stringify({
212
+ count: messages.length,
213
+ messages: messages.map((m) => ({
214
+ id: m.id,
215
+ from: m.sender_id,
216
+ subject: m.subject,
217
+ content: m.content,
218
+ threadTag: m.thread_tag,
219
+ importance: m.importance,
220
+ createdAt: m.created_at,
221
+ inReplyTo: m.in_reply_to,
222
+ })),
223
+ }),
224
+ },
225
+ ],
226
+ };
227
+ }
228
+ );
229
+
230
+ this.mcp.tool(
231
+ "read_thread",
232
+ "Read all messages in a thread (by thread_tag)",
233
+ {
234
+ threadTag: z.string().describe("Thread tag to read"),
235
+ scope: z
236
+ .string()
237
+ .optional()
238
+ .describe("Scope (defaults to 'default')"),
239
+ },
240
+ async ({ threadTag, scope }) => {
241
+ const resp = await this.sendIpc({
242
+ action: "read_thread",
243
+ threadTag,
244
+ scope: scope ?? this.defaultScope,
245
+ });
246
+
247
+ if (!resp.ok) {
248
+ return {
249
+ content: [
250
+ { type: "text" as const, text: JSON.stringify({ ok: false, error: resp.error }) },
251
+ ],
252
+ };
253
+ }
254
+
255
+ const messages = resp.messages ?? [];
256
+ return {
257
+ content: [
258
+ {
259
+ type: "text" as const,
260
+ text: JSON.stringify({
261
+ threadTag,
262
+ count: messages.length,
263
+ messages: messages.map((m) => ({
264
+ id: m.id,
265
+ from: m.sender_id,
266
+ content: m.content,
267
+ createdAt: m.created_at,
268
+ inReplyTo: m.in_reply_to,
269
+ })),
270
+ }),
271
+ },
272
+ ],
273
+ };
274
+ }
275
+ );
276
+
277
+ this.mcp.tool(
278
+ "list_agents",
279
+ "List agents registered in the inbox (local and optionally federated)",
280
+ {
281
+ scope: z.string().optional().describe("Filter by scope"),
282
+ includeFederated: z
283
+ .boolean()
284
+ .optional()
285
+ .describe("Include agents known from federation routing table"),
286
+ },
287
+ async ({ scope, includeFederated }) => {
288
+ const resp = await this.sendIpc({
289
+ action: "list_agents",
290
+ scope,
291
+ includeFederated,
292
+ });
293
+
294
+ if (!resp.ok) {
295
+ return {
296
+ content: [
297
+ { type: "text" as const, text: JSON.stringify({ ok: false, error: resp.error }) },
298
+ ],
299
+ };
300
+ }
301
+
302
+ return {
303
+ content: [
304
+ {
305
+ type: "text" as const,
306
+ text: JSON.stringify({
307
+ count: resp.count ?? resp.agents?.length ?? 0,
308
+ agents: resp.agents ?? [],
309
+ }),
310
+ },
311
+ ],
312
+ };
313
+ }
314
+ );
315
+ }
316
+
317
+ async start(): Promise<void> {
318
+ const transport = new StdioServerTransport();
319
+ await this.mcp.connect(transport);
320
+ }
321
+
322
+ /** Expose for testing */
323
+ get server(): McpServer {
324
+ return this.mcp;
325
+ }
326
+ }
@@ -140,6 +140,18 @@ export interface IpcCheckInboxCommand {
140
140
  clear?: boolean;
141
141
  }
142
142
 
143
+ export interface IpcReadThreadCommand {
144
+ action: "read_thread";
145
+ threadTag: string;
146
+ scope?: string;
147
+ }
148
+
149
+ export interface IpcListAgentsCommand {
150
+ action: "list_agents";
151
+ scope?: string;
152
+ includeFederated?: boolean;
153
+ }
154
+
143
155
  export interface IpcPingCommand {
144
156
  action: "ping";
145
157
  }
@@ -149,12 +161,26 @@ export type IpcCommand =
149
161
  | IpcEmitCommand
150
162
  | IpcNotifyCommand
151
163
  | IpcCheckInboxCommand
164
+ | IpcReadThreadCommand
165
+ | IpcListAgentsCommand
152
166
  | IpcPingCommand;
153
167
 
154
168
  export interface IpcResponse {
155
169
  ok: boolean;
156
170
  messageId?: string;
157
171
  messages?: Message[];
172
+ agents?: Array<{
173
+ agentId: string;
174
+ name?: string;
175
+ scope: string;
176
+ status: string;
177
+ program?: string;
178
+ lastActive?: string;
179
+ location: "local" | "federated";
180
+ peerId?: string;
181
+ }>;
182
+ count?: number;
183
+ threadTag?: string;
158
184
  error?: string;
159
185
  pid?: number;
160
186
  }
@@ -0,0 +1,200 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { EventEmitter } from "node:events";
6
+ import { InMemoryStorage } from "../src/storage/memory.js";
7
+ import { MessageRouter } from "../src/router/message-router.js";
8
+ import { IpcServer } from "../src/ipc/ipc-server.js";
9
+
10
+ function tmpSocketPath(): string {
11
+ return path.join(os.tmpdir(), `inbox-test-${Date.now()}-${Math.random().toString(36).slice(2)}.sock`);
12
+ }
13
+
14
+ function sendCommand(socketPath: string, command: object): Promise<Record<string, unknown>> {
15
+ return new Promise((resolve, reject) => {
16
+ const client = net.createConnection(socketPath, () => {
17
+ client.write(JSON.stringify(command) + "\n");
18
+ });
19
+ let buffer = "";
20
+ client.on("data", (data) => {
21
+ buffer += data.toString();
22
+ const idx = buffer.indexOf("\n");
23
+ if (idx !== -1) {
24
+ const line = buffer.slice(0, idx);
25
+ client.end();
26
+ resolve(JSON.parse(line));
27
+ }
28
+ });
29
+ client.on("error", reject);
30
+ });
31
+ }
32
+
33
+ describe("IPC read_thread command", () => {
34
+ let storage: InMemoryStorage;
35
+ let events: EventEmitter;
36
+ let router: MessageRouter;
37
+ let server: IpcServer;
38
+ let socketPath: string;
39
+
40
+ beforeEach(async () => {
41
+ storage = new InMemoryStorage();
42
+ events = new EventEmitter();
43
+ router = new MessageRouter(storage, events, "default");
44
+ socketPath = tmpSocketPath();
45
+ server = new IpcServer(socketPath, router, storage);
46
+ await server.start();
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await server.stop();
51
+ });
52
+
53
+ it("should return empty thread for unknown tag", async () => {
54
+ const resp = await sendCommand(socketPath, {
55
+ action: "read_thread",
56
+ threadTag: "nonexistent",
57
+ });
58
+ expect(resp.ok).toBe(true);
59
+ expect(resp.threadTag).toBe("nonexistent");
60
+ expect(resp.count).toBe(0);
61
+ expect(resp.messages).toEqual([]);
62
+ });
63
+
64
+ it("should return messages in a thread", async () => {
65
+ // Send messages with same threadTag
66
+ await sendCommand(socketPath, {
67
+ action: "send",
68
+ from: "alice",
69
+ to: "bob",
70
+ payload: "first message",
71
+ threadTag: "task-42",
72
+ });
73
+ await sendCommand(socketPath, {
74
+ action: "send",
75
+ from: "bob",
76
+ to: "alice",
77
+ payload: "reply to first",
78
+ threadTag: "task-42",
79
+ });
80
+ // Different thread
81
+ await sendCommand(socketPath, {
82
+ action: "send",
83
+ from: "alice",
84
+ to: "charlie",
85
+ payload: "unrelated",
86
+ threadTag: "task-99",
87
+ });
88
+
89
+ const resp = await sendCommand(socketPath, {
90
+ action: "read_thread",
91
+ threadTag: "task-42",
92
+ scope: "default",
93
+ });
94
+
95
+ expect(resp.ok).toBe(true);
96
+ expect(resp.count).toBe(2);
97
+ const messages = resp.messages as Array<{ sender_id: string }>;
98
+ expect(messages).toHaveLength(2);
99
+ const senders = messages.map((m) => m.sender_id);
100
+ expect(senders).toContain("alice");
101
+ expect(senders).toContain("bob");
102
+ });
103
+ });
104
+
105
+ describe("IPC list_agents command", () => {
106
+ let storage: InMemoryStorage;
107
+ let events: EventEmitter;
108
+ let router: MessageRouter;
109
+ let server: IpcServer;
110
+ let socketPath: string;
111
+
112
+ beforeEach(async () => {
113
+ storage = new InMemoryStorage();
114
+ events = new EventEmitter();
115
+ router = new MessageRouter(storage, events, "default");
116
+ socketPath = tmpSocketPath();
117
+ server = new IpcServer(socketPath, router, storage);
118
+ await server.start();
119
+ });
120
+
121
+ afterEach(async () => {
122
+ await server.stop();
123
+ });
124
+
125
+ it("should return empty list with no agents", async () => {
126
+ const resp = await sendCommand(socketPath, {
127
+ action: "list_agents",
128
+ });
129
+ expect(resp.ok).toBe(true);
130
+ expect(resp.count).toBe(0);
131
+ expect(resp.agents).toEqual([]);
132
+ });
133
+
134
+ it("should list registered agents", async () => {
135
+ // Register agents via notify
136
+ await sendCommand(socketPath, {
137
+ action: "notify",
138
+ event: {
139
+ type: "agent.spawn",
140
+ agent: {
141
+ agentId: "gsd-executor",
142
+ name: "executor",
143
+ scopes: ["swarm:gsd"],
144
+ metadata: { role: "executor" },
145
+ },
146
+ },
147
+ });
148
+ await sendCommand(socketPath, {
149
+ action: "notify",
150
+ event: {
151
+ type: "agent.spawn",
152
+ agent: {
153
+ agentId: "gsd-verifier",
154
+ name: "verifier",
155
+ scopes: ["swarm:gsd"],
156
+ },
157
+ },
158
+ });
159
+
160
+ const resp = await sendCommand(socketPath, {
161
+ action: "list_agents",
162
+ });
163
+
164
+ expect(resp.ok).toBe(true);
165
+ expect(resp.count).toBe(2);
166
+ const agents = resp.agents as Array<{ agentId: string; location: string }>;
167
+ expect(agents).toHaveLength(2);
168
+ const ids = agents.map((a) => a.agentId);
169
+ expect(ids).toContain("gsd-executor");
170
+ expect(ids).toContain("gsd-verifier");
171
+ expect(agents[0].location).toBe("local");
172
+ });
173
+
174
+ it("should filter agents by scope", async () => {
175
+ await sendCommand(socketPath, {
176
+ action: "notify",
177
+ event: {
178
+ type: "agent.spawn",
179
+ agent: { agentId: "team1-a", name: "a", scopes: ["team1"] },
180
+ },
181
+ });
182
+ await sendCommand(socketPath, {
183
+ action: "notify",
184
+ event: {
185
+ type: "agent.spawn",
186
+ agent: { agentId: "team2-b", name: "b", scopes: ["team2"] },
187
+ },
188
+ });
189
+
190
+ const resp = await sendCommand(socketPath, {
191
+ action: "list_agents",
192
+ scope: "team1",
193
+ });
194
+
195
+ expect(resp.ok).toBe(true);
196
+ expect(resp.count).toBe(1);
197
+ const agents = resp.agents as Array<{ agentId: string }>;
198
+ expect(agents[0].agentId).toBe("team1-a");
199
+ });
200
+ });