agent-inbox 0.1.8 → 0.1.9

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 (70) hide show
  1. package/CLAUDE.md +44 -7
  2. package/README.md +67 -24
  3. package/dist/federation/connection-manager.d.ts +13 -2
  4. package/dist/federation/connection-manager.d.ts.map +1 -1
  5. package/dist/federation/connection-manager.js +109 -10
  6. package/dist/federation/connection-manager.js.map +1 -1
  7. package/dist/index.d.mts +2 -0
  8. package/dist/index.d.ts +25 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +58 -5
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +1 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/ipc/ipc-server.d.ts +2 -0
  15. package/dist/ipc/ipc-server.d.ts.map +1 -1
  16. package/dist/ipc/ipc-server.js +48 -0
  17. package/dist/ipc/ipc-server.js.map +1 -1
  18. package/dist/map/map-client.d.ts +100 -0
  19. package/dist/map/map-client.d.ts.map +1 -1
  20. package/dist/map/map-client.js +61 -0
  21. package/dist/map/map-client.js.map +1 -1
  22. package/dist/mcp/mcp-proxy.d.ts +28 -0
  23. package/dist/mcp/mcp-proxy.d.ts.map +1 -0
  24. package/dist/mcp/mcp-proxy.js +280 -0
  25. package/dist/mcp/mcp-proxy.js.map +1 -0
  26. package/dist/mesh/delivery-bridge.d.ts +47 -0
  27. package/dist/mesh/delivery-bridge.d.ts.map +1 -0
  28. package/dist/mesh/delivery-bridge.js +73 -0
  29. package/dist/mesh/delivery-bridge.js.map +1 -0
  30. package/dist/mesh/mesh-connector.d.ts +29 -0
  31. package/dist/mesh/mesh-connector.d.ts.map +1 -0
  32. package/dist/mesh/mesh-connector.js +36 -0
  33. package/dist/mesh/mesh-connector.js.map +1 -0
  34. package/dist/mesh/mesh-transport.d.ts +70 -0
  35. package/dist/mesh/mesh-transport.d.ts.map +1 -0
  36. package/dist/mesh/mesh-transport.js +92 -0
  37. package/dist/mesh/mesh-transport.js.map +1 -0
  38. package/dist/mesh/type-mapper.d.ts +67 -0
  39. package/dist/mesh/type-mapper.d.ts.map +1 -0
  40. package/dist/mesh/type-mapper.js +165 -0
  41. package/dist/mesh/type-mapper.js.map +1 -0
  42. package/dist/types.d.ts +29 -2
  43. package/dist/types.d.ts.map +1 -1
  44. package/docs/CLAUDE-CODE-SWARM-PROPOSAL.md +137 -0
  45. package/package.json +7 -2
  46. package/src/federation/connection-manager.ts +125 -10
  47. package/src/index.ts +96 -5
  48. package/src/ipc/ipc-server.ts +58 -0
  49. package/src/map/map-client.ts +152 -0
  50. package/src/mcp/mcp-proxy.ts +326 -0
  51. package/src/mesh/delivery-bridge.ts +110 -0
  52. package/src/mesh/mesh-connector.ts +41 -0
  53. package/src/mesh/mesh-transport.ts +157 -0
  54. package/src/mesh/type-mapper.ts +239 -0
  55. package/src/types.ts +33 -1
  56. package/test/federation/integration.test.ts +37 -3
  57. package/test/federation/sdk-integration.test.ts +4 -8
  58. package/test/ipc-new-commands.test.ts +200 -0
  59. package/test/mcp-proxy.test.ts +191 -0
  60. package/test/mesh/delivery-bridge.test.ts +178 -0
  61. package/test/mesh/e2e-mesh.test.ts +527 -0
  62. package/test/mesh/e2e-real-meshpeer.test.ts +629 -0
  63. package/test/mesh/federation-mesh.test.ts +269 -0
  64. package/test/mesh/mesh-connector.test.ts +66 -0
  65. package/test/mesh/mesh-transport.test.ts +191 -0
  66. package/test/mesh/meshpeer-integration.test.ts +442 -0
  67. package/test/mesh/mock-mesh.ts +125 -0
  68. package/test/mesh/mock-meshpeer.ts +266 -0
  69. package/test/mesh/type-mapper.test.ts +226 -0
  70. package/docs/PLAN.md +0 -545
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Type mapping between agentic-mesh MAP messages and agent-inbox messages.
3
+ *
4
+ * Translates MAP Message (from agentic-mesh's MapServer/FederationGateway)
5
+ * into agent-inbox's internal Message format and vice versa.
6
+ *
7
+ * Inbox-specific fields (subject, thread_tag, importance, recipientKind)
8
+ * are encoded in the MAP message's `_meta` vendor extension field.
9
+ */
10
+
11
+ import { ulid } from "ulid";
12
+ import { normalizeContent } from "../router/message-router.js";
13
+ import type {
14
+ Message,
15
+ Recipient,
16
+ RecipientKind,
17
+ Importance,
18
+ } from "../types.js";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Minimal MAP message type stubs (matches agentic-mesh v0.2.0)
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** MAP Message as delivered by agentic-mesh's MapServer. */
25
+ export interface MapMessage {
26
+ id: string;
27
+ from: string;
28
+ to: MapAddress;
29
+ timestamp: number;
30
+ payload?: unknown;
31
+ meta?: MapMessageMeta;
32
+ _meta?: Record<string, unknown>;
33
+ }
34
+
35
+ export type MapAddress =
36
+ | string
37
+ | { agent: string }
38
+ | { agents: string[] }
39
+ | { scope: string }
40
+ | { role: string; within?: string }
41
+ | { broadcast: true }
42
+ | { system: string; agent: string }
43
+ | Record<string, unknown>;
44
+
45
+ export interface MapMessageMeta {
46
+ priority?: "urgent" | "high" | "normal" | "low";
47
+ correlationId?: string;
48
+ delivery?: "fire-and-forget" | "acknowledged" | "guaranteed";
49
+ _meta?: Record<string, unknown>;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // MAP → Inbox
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Convert a MAP Message from agentic-mesh into an agent-inbox Message.
58
+ *
59
+ * Inbox-specific fields are read from `_meta` (or `meta._meta`):
60
+ * - `_meta.subject` → `subject`
61
+ * - `_meta.threadTag` / `meta.correlationId` → `thread_tag`
62
+ * - `_meta.inReplyTo` → `in_reply_to`
63
+ * - `_meta.recipientKind` → per-recipient `kind`
64
+ * - `_meta.inboxMessageId` → used as `id` if present
65
+ * - `meta.priority` → `importance`
66
+ */
67
+ export function mapMessageToInbox(
68
+ mapMsg: MapMessage,
69
+ scope: string = "default"
70
+ ): Message {
71
+ const vendorMeta = mapMsg._meta ?? mapMsg.meta?._meta ?? {};
72
+ const recipients = resolveMapAddress(mapMsg.to, vendorMeta);
73
+
74
+ const priority = mapMsg.meta?.priority;
75
+ const importance: Importance =
76
+ priority === "urgent"
77
+ ? "urgent"
78
+ : priority === "high"
79
+ ? "high"
80
+ : priority === "low"
81
+ ? "low"
82
+ : "normal";
83
+
84
+ return {
85
+ id: (vendorMeta.inboxMessageId as string) ?? ulid(),
86
+ scope,
87
+ sender_id: mapMsg.from,
88
+ recipients,
89
+ content: normalizeContent(mapMsg.payload),
90
+ importance,
91
+ subject: vendorMeta.subject as string | undefined,
92
+ thread_tag:
93
+ (vendorMeta.threadTag as string) ?? mapMsg.meta?.correlationId,
94
+ in_reply_to: vendorMeta.inReplyTo as string | undefined,
95
+ conversation_id: vendorMeta.conversationId as string | undefined,
96
+ metadata: stripInboxKeys(vendorMeta),
97
+ created_at: new Date(mapMsg.timestamp).toISOString(),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Resolve a MAP Address into agent-inbox Recipients.
103
+ */
104
+ function resolveMapAddress(
105
+ addr: MapAddress,
106
+ vendorMeta: Record<string, unknown>
107
+ ): Recipient[] {
108
+ const kind = (vendorMeta.recipientKind as RecipientKind) ?? "to";
109
+ const now = new Date().toISOString();
110
+
111
+ if (typeof addr === "string") {
112
+ return [{ agent_id: addr, kind, delivered_at: now }];
113
+ }
114
+
115
+ // Federated address must be checked before simple agent address
116
+ // since { system: "x", agent: "y" } also matches "agent" in addr
117
+ if (
118
+ "system" in addr &&
119
+ "agent" in addr &&
120
+ typeof addr.system === "string" &&
121
+ typeof addr.agent === "string"
122
+ ) {
123
+ return [
124
+ { agent_id: `${addr.agent}@${addr.system}`, kind, delivered_at: now },
125
+ ];
126
+ }
127
+
128
+ if ("agent" in addr && typeof addr.agent === "string") {
129
+ return [{ agent_id: addr.agent, kind, delivered_at: now }];
130
+ }
131
+
132
+ if ("agents" in addr && Array.isArray(addr.agents)) {
133
+ return addr.agents.map((agentId: string) => ({
134
+ agent_id: agentId,
135
+ kind,
136
+ delivered_at: now,
137
+ }));
138
+ }
139
+
140
+ if ("scope" in addr && typeof addr.scope === "string") {
141
+ return [{ agent_id: addr.scope, kind, delivered_at: now }];
142
+ }
143
+
144
+ if ("broadcast" in addr) {
145
+ return [{ agent_id: "*", kind, delivered_at: now }];
146
+ }
147
+
148
+ return [];
149
+ }
150
+
151
+ // Keys that belong to the inbox layer, not stored in metadata
152
+ const INBOX_META_KEYS = new Set([
153
+ "subject",
154
+ "threadTag",
155
+ "inReplyTo",
156
+ "recipientKind",
157
+ "inboxMessageId",
158
+ "conversationId",
159
+ "importance",
160
+ ]);
161
+
162
+ function stripInboxKeys(
163
+ meta: Record<string, unknown>
164
+ ): Record<string, unknown> {
165
+ const result: Record<string, unknown> = {};
166
+ for (const [k, v] of Object.entries(meta)) {
167
+ if (!INBOX_META_KEYS.has(k)) {
168
+ result[k] = v;
169
+ }
170
+ }
171
+ return result;
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Inbox → MAP
176
+ // ---------------------------------------------------------------------------
177
+
178
+ /**
179
+ * Convert an agent-inbox Message into partial MAP message fields
180
+ * suitable for sending via `AgentConnection.send()` or `FederationGateway.route()`.
181
+ *
182
+ * Returns `to`, `payload`, and `meta` (including `_meta` for inbox fields).
183
+ */
184
+ export function inboxMessageToMap(msg: Message): {
185
+ to: MapAddress;
186
+ payload: unknown;
187
+ meta: MapMessageMeta & { _meta: Record<string, unknown> };
188
+ } {
189
+ // Build the MAP address from recipients
190
+ const primaryRecipients = msg.recipients.filter((r) => r.kind === "to");
191
+ const to = resolveRecipientsToAddress(primaryRecipients);
192
+
193
+ const priority: MapMessageMeta["priority"] =
194
+ msg.importance === "urgent"
195
+ ? "urgent"
196
+ : msg.importance === "high"
197
+ ? "high"
198
+ : msg.importance === "low"
199
+ ? "low"
200
+ : "normal";
201
+
202
+ return {
203
+ to,
204
+ payload: msg.content,
205
+ meta: {
206
+ priority,
207
+ correlationId: msg.thread_tag,
208
+ _meta: {
209
+ inboxMessageId: msg.id,
210
+ ...(msg.subject ? { subject: msg.subject } : {}),
211
+ ...(msg.thread_tag ? { threadTag: msg.thread_tag } : {}),
212
+ ...(msg.in_reply_to ? { inReplyTo: msg.in_reply_to } : {}),
213
+ ...(msg.conversation_id
214
+ ? { conversationId: msg.conversation_id }
215
+ : {}),
216
+ ...msg.metadata,
217
+ },
218
+ },
219
+ };
220
+ }
221
+
222
+ function resolveRecipientsToAddress(recipients: Recipient[]): MapAddress {
223
+ if (recipients.length === 0) {
224
+ return { broadcast: true };
225
+ }
226
+ if (recipients.length === 1) {
227
+ const agentId = recipients[0].agent_id;
228
+ // Check for federated address (agent@system)
229
+ const atIdx = agentId.indexOf("@");
230
+ if (atIdx > 0) {
231
+ return {
232
+ system: agentId.slice(atIdx + 1),
233
+ agent: agentId.slice(0, atIdx),
234
+ };
235
+ }
236
+ return { agent: agentId };
237
+ }
238
+ return { agents: recipients.map((r) => r.agent_id) };
239
+ }
package/src/types.ts CHANGED
@@ -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
  }
@@ -180,7 +206,10 @@ export interface FederatedAddress {
180
206
 
181
207
  export interface FederationPeerConfig {
182
208
  systemId: string;
183
- url: string;
209
+ /** WebSocket URL for MAP SDK connections. */
210
+ url?: string;
211
+ /** Mesh peer ID for agentic-mesh transport connections. */
212
+ meshPeerId?: string;
184
213
  auth?: FederationAuth;
185
214
  exposure?: ExposurePolicy;
186
215
  }
@@ -199,12 +228,15 @@ export interface ExposurePolicy {
199
228
  events?: ExposureLevel;
200
229
  }
201
230
 
231
+ export type FederationTransport = "websocket" | "mesh";
232
+
202
233
  export interface FederationLink {
203
234
  peerId: string;
204
235
  sessionId: string;
205
236
  status: "connected" | "disconnected" | "authenticating";
206
237
  exposure: ExposurePolicy;
207
238
  url: string;
239
+ transport: FederationTransport;
208
240
  connectedAt?: string;
209
241
  }
210
242
 
@@ -185,7 +185,7 @@ describe("Federation Integration", () => {
185
185
  await hubFederation.destroy();
186
186
  });
187
187
 
188
- it("should queue when system-qualified address has no peer link", async () => {
188
+ it("should relay via upstream hub when system-qualified address has no direct peer link", async () => {
189
189
  const hubFederation = new ConnectionManager(events, {
190
190
  systemId: "system-1",
191
191
  trust: {
@@ -204,7 +204,8 @@ describe("Federation Integration", () => {
204
204
  url: "ws://hub:3000",
205
205
  });
206
206
 
207
- // Route to far-system which has no peer link — gets queued for far-system
207
+ // Route to far-system which has no direct peer link.
208
+ // With hierarchical strategy, falls through to upstream hub relay.
208
209
  const msg: Message = {
209
210
  id: "msg-hub-2",
210
211
  scope: "default",
@@ -217,11 +218,44 @@ describe("Federation Integration", () => {
217
218
  };
218
219
 
219
220
  const result = await hubFederation.route(msg);
221
+ // Hierarchical strategy delegates to upstream hub instead of queuing
222
+ expect(result.delivered).toBe(true);
223
+ expect(result.peerId).toBe("hub-system");
224
+
225
+ await hubFederation.destroy();
226
+ });
227
+
228
+ it("should queue when system-qualified address has no peer link (table strategy)", async () => {
229
+ const tableFederation = new ConnectionManager(events, {
230
+ systemId: "system-1",
231
+ trust: {
232
+ allowedServers: [],
233
+ scopePermissions: {},
234
+ requireAuth: false,
235
+ },
236
+ routing: {
237
+ strategy: "table",
238
+ },
239
+ });
240
+
241
+ // No peers connected — route to far-system queues immediately
242
+ const msg: Message = {
243
+ id: "msg-table-1",
244
+ scope: "default",
245
+ sender_id: "agent-a",
246
+ recipients: [{ agent_id: "agent-x@far-system", kind: "to" }],
247
+ content: { type: "text", text: "no direct link" },
248
+ importance: "normal",
249
+ metadata: {},
250
+ created_at: new Date().toISOString(),
251
+ };
252
+
253
+ const result = await tableFederation.route(msg);
220
254
  expect(result.delivered).toBe(false);
221
255
  expect(result.queued).toBe(true);
222
256
  expect(result.peerId).toBe("far-system");
223
257
 
224
- await hubFederation.destroy();
258
+ await tableFederation.destroy();
225
259
  });
226
260
  });
227
261
 
@@ -477,14 +477,10 @@ describe("Federation SDK Integration (two-system)", () => {
477
477
  });
478
478
 
479
479
  describe("broadcast strategy with real transport", () => {
480
- // NOTE: In the current routing implementation, broadcast/hierarchical strategies
481
- // are only reachable via handleUnknownRoute(), which requires resolveRoute()
482
- // to return null. This only happens for agent-only addresses (no @system),
483
- // but those are filtered out by isRemoteAddress() in route().
484
- //
485
- // System-qualified addresses like "bob@system-2" always resolve via addr.system,
486
- // bypassing the strategy entirely. This is a known design gap documented in
487
- // integration.test.ts.
480
+ // For system-qualified addresses (bob@system-2) where the target system IS a
481
+ // connected peer, delivery goes directly via transport (no broadcast needed).
482
+ // Broadcast/hierarchical strategies activate when the resolved peer has no
483
+ // active link e.g., targeting a system we're not directly connected to.
488
484
  //
489
485
  // These tests verify broadcast transport by:
490
486
  // 1. Testing system-qualified delivery to multiple peers (direct transport)
@@ -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
+ });