claude-code-swarm 0.3.25 → 0.3.26

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Launch Claude Code with swarmkit capabilities, including team orchestration, MAP observability, and session tracking.",
5
5
  "owner": {
6
6
  "name": "alexngai"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
3
  "description": "Spin up Claude Code agent teams from openteams YAML topologies with optional MAP (Multi-Agent Protocol) observability and coordination. Provides hooks for session lifecycle, agent spawn/complete tracking, and a /swarm skill to launch team configurations.",
4
- "version": "0.3.25",
4
+ "version": "0.3.26",
5
5
  "author": {
6
6
  "name": "alexngai"
7
7
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-swarm",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Claude Code plugin for launching agent teams from openteams topologies with MAP observability",
5
5
  "type": "module",
6
6
  "exports": {
@@ -53,7 +53,7 @@
53
53
  "node": ">=18.0.0"
54
54
  },
55
55
  "devDependencies": {
56
- "agent-inbox": "^0.1.9",
56
+ "agent-inbox": "^0.2.3",
57
57
  "minimem": "^0.1.1",
58
58
  "opentasks": "^0.1.2",
59
59
  "skill-tree": "^0.2.0",
@@ -77,7 +77,29 @@ async function handleInject(hookData, sessionId) {
77
77
  const sPaths = sessionPaths(sessionId);
78
78
  const config = readConfig();
79
79
 
80
- if (!config.inbox?.enabled) return;
80
+ // Check for dispatch thread nudges (advisory push from hub).
81
+ // Nudges arrive via x-dispatch/nudge MAP notifications; the sidecar
82
+ // stores them until this hook drains them. We check nudges even when
83
+ // inbox is disabled — the nudge path is independent.
84
+ // Uses sendToInbox (which waits for a response) on the sidecar socket.
85
+ let nudgeOutput = "";
86
+ try {
87
+ const nudgeResp = await sendToInbox(
88
+ { action: "check-nudge" },
89
+ sPaths.socketPath,
90
+ );
91
+ if (nudgeResp && nudgeResp.ok && nudgeResp.nudges?.length > 0) {
92
+ const ids = nudgeResp.nudges.map((n) => n.dispatch_id).join(", ");
93
+ nudgeOutput = `\n<dispatch-thread-nudge>\nYou have pending messages in dispatch coordination thread(s): ${ids}. Check your inbox for new turns.\n</dispatch-thread-nudge>\n`;
94
+ }
95
+ } catch {
96
+ // Best effort — nudge is advisory
97
+ }
98
+
99
+ if (!config.inbox?.enabled) {
100
+ if (nudgeOutput) process.stdout.write(nudgeOutput);
101
+ return;
102
+ }
81
103
 
82
104
  // Only check messages addressed to the main agent (not all scope messages).
83
105
  // Per-agent messages stay in storage for agents to pull via MCP tools.
@@ -89,10 +111,9 @@ async function handleInject(hookData, sessionId) {
89
111
  { action: "check_inbox", agentId: mainAgentId, scope, unreadOnly: true, clear: true },
90
112
  sPaths.inboxSocketPath
91
113
  );
92
- if (!resp || !resp.ok || !resp.messages?.length) return;
93
114
 
94
115
  // Forward task.* events to opentasks graph if enabled
95
- if (config.opentasks?.enabled) {
116
+ if (resp?.ok && resp.messages?.length && config.opentasks?.enabled) {
96
117
  const otSocketPath = findSocketPath();
97
118
  const taskEvents = resp.messages.filter(
98
119
  (m) => m.content?.type === "event" && m.content?.event?.startsWith("task.")
@@ -102,8 +123,12 @@ async function handleInject(hookData, sessionId) {
102
123
  }
103
124
  }
104
125
 
105
- const output = formatInboxAsMarkdown(resp.messages);
106
- if (output) process.stdout.write(output);
126
+ const inboxOutput = resp?.ok && resp.messages?.length
127
+ ? formatInboxAsMarkdown(resp.messages)
128
+ : "";
129
+
130
+ const output = (nudgeOutput + (inboxOutput || "")).trim();
131
+ if (output) process.stdout.write(output + "\n");
107
132
  }
108
133
 
109
134
  async function handleTurnCompleted(hookData, sessionId) {
@@ -425,6 +425,34 @@ function registerContentHandler(conn) {
425
425
  });
426
426
  }
427
427
 
428
+ /**
429
+ * Register the x-dispatch/nudge notification handler on a connection.
430
+ * When the hub sends a nudge (a dispatch thread received a new turn),
431
+ * the sidecar stores the nudge so the UserPromptSubmit hook can inject
432
+ * a hint about pending messages.
433
+ */
434
+ function registerNudgeHandler(conn) {
435
+ if (!conn || typeof conn.onNotification !== "function") return;
436
+
437
+ conn.onNotification("x-dispatch/nudge", (params) => {
438
+ const dispatchId = params?.dispatch_id;
439
+ const conversationId = params?.conversation_id;
440
+ if (!dispatchId) return;
441
+
442
+ log.info("dispatch nudge received", { dispatchId, conversationId });
443
+ resetInactivityTimer();
444
+
445
+ // Store the nudge via the command handler's nudge command.
446
+ // Use a fake client since we don't need the response.
447
+ if (commandHandler) {
448
+ commandHandler(
449
+ { action: "nudge", dispatch_id: dispatchId, conversation_id: conversationId },
450
+ { write: () => {}, writable: true },
451
+ );
452
+ }
453
+ });
454
+ }
455
+
428
456
  async function startWebSocketTransport() {
429
457
  connection = await connectToMAP({
430
458
  server: MAP_SERVER,
@@ -557,6 +585,10 @@ async function main() {
557
585
  return commandHandler(command, client);
558
586
  });
559
587
 
588
+ // Register dispatch nudge handler — must come after commandHandler is created
589
+ // so the notification can store nudge state via the command handler.
590
+ registerNudgeHandler(connection);
591
+
560
592
  // Start memory file watcher if minimem is enabled
561
593
  const sidecarConfig = readConfig();
562
594
  if (sidecarConfig.minimem?.enabled) {
@@ -4,8 +4,7 @@
4
4
  * Asserts that the openteams `loadout.skills` schema (SkillsConfig) and
5
5
  * skilltree-client's bridge mapping stay in sync. skill-tree is the
6
6
  * mechanism; openteams' `loadout.skills` is the declaration that
7
- * dispatches into it. See openhive's docs/LOADOUT_INTEGRATION.md for
8
- * the model.
7
+ * dispatches into it.
9
8
  *
10
9
  * What this test catches:
11
10
  * - openteams adds a new field to SkillsConfig and the bridge doesn't
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests for dispatch thread nudge commands on the sidecar command handler.
3
+ *
4
+ * Covers Phase 7 of dispatch-inbox-threads:
5
+ * - nudge command stores nudge state
6
+ * - check-nudge returns and clears pending nudges
7
+ * - Multiple nudges accumulate independently
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "vitest";
11
+ import { createCommandHandler, respond } from "../sidecar-server.mjs";
12
+
13
+ function createTestHandler() {
14
+ const registeredAgents = new Map();
15
+ return createCommandHandler(null, "swarm:test", registeredAgents, {
16
+ transportMode: "websocket",
17
+ });
18
+ }
19
+
20
+ function createFakeClient() {
21
+ let lastResponse = null;
22
+ return {
23
+ write(data) {
24
+ try {
25
+ lastResponse = JSON.parse(data.replace(/\n$/, ""));
26
+ } catch {
27
+ lastResponse = data;
28
+ }
29
+ },
30
+ writable: true,
31
+ getResponse() {
32
+ return lastResponse;
33
+ },
34
+ };
35
+ }
36
+
37
+ describe("sidecar nudge commands", () => {
38
+ let handler;
39
+
40
+ beforeEach(() => {
41
+ handler = createTestHandler();
42
+ });
43
+
44
+ it("check-nudge returns empty array when no nudges pending", async () => {
45
+ const client = createFakeClient();
46
+ await handler({ action: "check-nudge" }, client);
47
+
48
+ const resp = client.getResponse();
49
+ expect(resp.ok).toBe(true);
50
+ expect(resp.nudges).toEqual([]);
51
+ });
52
+
53
+ it("nudge stores state, check-nudge returns and clears it", async () => {
54
+ const fakeClient = { write: () => {}, writable: true };
55
+
56
+ // Store a nudge
57
+ await handler(
58
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
59
+ fakeClient,
60
+ );
61
+
62
+ // Check nudge should return it
63
+ const client = createFakeClient();
64
+ await handler({ action: "check-nudge" }, client);
65
+
66
+ const resp = client.getResponse();
67
+ expect(resp.ok).toBe(true);
68
+ expect(resp.nudges).toHaveLength(1);
69
+ expect(resp.nudges[0]).toEqual({
70
+ dispatch_id: "d1",
71
+ conversation_id: "conv-d1",
72
+ });
73
+
74
+ // Second check should be empty (cleared)
75
+ const client2 = createFakeClient();
76
+ await handler({ action: "check-nudge" }, client2);
77
+
78
+ const resp2 = client2.getResponse();
79
+ expect(resp2.nudges).toEqual([]);
80
+ });
81
+
82
+ it("accumulates multiple nudges for different dispatches", async () => {
83
+ const fakeClient = { write: () => {}, writable: true };
84
+
85
+ await handler(
86
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-d1" },
87
+ fakeClient,
88
+ );
89
+ await handler(
90
+ { action: "nudge", dispatch_id: "d2", conversation_id: "conv-d2" },
91
+ fakeClient,
92
+ );
93
+
94
+ const client = createFakeClient();
95
+ await handler({ action: "check-nudge" }, client);
96
+
97
+ const resp = client.getResponse();
98
+ expect(resp.nudges).toHaveLength(2);
99
+ const ids = resp.nudges.map((n) => n.dispatch_id).sort();
100
+ expect(ids).toEqual(["d1", "d2"]);
101
+ });
102
+
103
+ it("overwrites nudge for same dispatch_id (latest wins)", async () => {
104
+ const fakeClient = { write: () => {}, writable: true };
105
+
106
+ await handler(
107
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-old" },
108
+ fakeClient,
109
+ );
110
+ await handler(
111
+ { action: "nudge", dispatch_id: "d1", conversation_id: "conv-new" },
112
+ fakeClient,
113
+ );
114
+
115
+ const client = createFakeClient();
116
+ await handler({ action: "check-nudge" }, client);
117
+
118
+ const resp = client.getResponse();
119
+ expect(resp.nudges).toHaveLength(1);
120
+ expect(resp.nudges[0].conversation_id).toBe("conv-new");
121
+ });
122
+
123
+ it("ignores nudge with no dispatch_id", async () => {
124
+ const fakeClient = { write: () => {}, writable: true };
125
+
126
+ await handler(
127
+ { action: "nudge", conversation_id: "conv-x" },
128
+ fakeClient,
129
+ );
130
+
131
+ const client = createFakeClient();
132
+ await handler({ action: "check-nudge" }, client);
133
+
134
+ const resp = client.getResponse();
135
+ expect(resp.nudges).toEqual([]);
136
+ });
137
+ });
package/src/bootstrap.mjs CHANGED
@@ -244,16 +244,22 @@ async function startSessionSidecar(config, scope, dir, sessionId) {
244
244
 
245
245
  const ok = await startSidecar(config, dir, sessionId);
246
246
  if (ok) {
247
- // Register the main Claude Code session agent with the MAP server
247
+ // Register the main Claude Code session agent with the MAP server.
248
+ // Use the inbox-derived ID (`${teamName}-main`) as the canonical agentId
249
+ // so MAP and inbox identities are unified — the hub can correlate a MAP
250
+ // agent with its inbox participant without a separate lookup table.
251
+ // The ephemeral sessionId is preserved in metadata for trajectory
252
+ // correlation and session storage.
248
253
  const teamName = resolveTeamName(config);
254
+ const inboxAgentId = `${teamName}-main`;
249
255
  sendCommand(config, {
250
256
  action: "spawn",
251
257
  agent: {
252
- agentId: sessionId,
253
- name: `${teamName}-main`,
258
+ agentId: inboxAgentId,
259
+ name: inboxAgentId,
254
260
  role: "orchestrator",
255
261
  scopes: [scope],
256
- metadata: { isMain: true, sessionId },
262
+ metadata: { isMain: true, sessionId, inboxAgentId },
257
263
  },
258
264
  }, sessionId).catch(() => {});
259
265
 
@@ -307,9 +313,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
307
313
  }
308
314
  }
309
315
 
310
- // Inbox registration
316
+ // Inbox registration — uses the same stable ID as the MAP registration
317
+ // above so both systems share a single canonical agent identity.
311
318
  if (config.map.enabled && config.inbox?.enabled) {
312
- const teamName = resolveTeamName(config);
319
+ const inboxTeamName = resolveTeamName(config);
320
+ const inboxId = `${inboxTeamName}-main`;
313
321
  const sPaths = sessionId
314
322
  ? (await import("./paths.mjs")).sessionPaths(sessionId)
315
323
  : { inboxSocketPath: (await import("./paths.mjs")).INBOX_SOCKET_PATH };
@@ -319,11 +327,11 @@ export async function backgroundInit(config, scope, dir, sessionId) {
319
327
  event: {
320
328
  type: "agent.spawn",
321
329
  agent: {
322
- agentId: `${teamName}-main`,
323
- name: `${teamName}-main`,
330
+ agentId: inboxId,
331
+ name: inboxId,
324
332
  role: "orchestrator",
325
333
  scopes: [scope],
326
- metadata: { isMain: true, sessionId },
334
+ metadata: { isMain: true, sessionId, inboxAgentId: inboxId },
327
335
  },
328
336
  },
329
337
  }, sPaths.inboxSocketPath).catch(() => {})
@@ -63,12 +63,18 @@ export async function emitPayload(config, payload, meta, sessionId) {
63
63
 
64
64
  /**
65
65
  * Build a "spawn" sidecar command for a subagent.
66
+ *
67
+ * The agentId is derived from hookData.agent_id when available (stable,
68
+ * set by the spawning agent) or falls back to a timestamp-based ID.
69
+ * `inboxAgentId` is included in metadata so the hub can correlate MAP
70
+ * and inbox identities.
66
71
  */
67
72
  export function buildSubagentSpawnCommand(hookData, teamName) {
73
+ const agentId = hookData.agent_id || `${teamName}-subagent-${Date.now()}`;
68
74
  return {
69
75
  action: "spawn",
70
76
  agent: {
71
- agentId: hookData.agent_id || `${teamName}-subagent-${Date.now()}`,
77
+ agentId,
72
78
  name: hookData.agent_type || "subagent",
73
79
  role: "subagent",
74
80
  scopes: [`swarm:${teamName}`],
@@ -76,6 +82,7 @@ export function buildSubagentSpawnCommand(hookData, teamName) {
76
82
  agentType: hookData.agent_type || "",
77
83
  sessionId: hookData.session_id || "",
78
84
  isTeamRole: false,
85
+ inboxAgentId: agentId,
79
86
  },
80
87
  },
81
88
  };
@@ -99,6 +99,11 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
99
99
  const { inboxInstance, meshPeer, transportMode = "websocket" } = opts;
100
100
  const useMeshRegistry = transportMode === "mesh" && inboxInstance;
101
101
 
102
+ // Dispatch thread nudge state — set by x-dispatch/nudge notifications,
103
+ // consumed by the UserPromptSubmit hook via the check-nudge command.
104
+ // Keyed by dispatch_id → { conversation_id, received_at }.
105
+ const _pendingNudges = new Map();
106
+
102
107
  // Connection-ready gate: commands that need `conn` await this promise.
103
108
  // If connection is already available, resolves immediately.
104
109
  // When connection arrives later (via setConnection), resolves the pending promise.
@@ -474,6 +479,37 @@ export function createCommandHandler(connection, scope, registeredAgents, opts =
474
479
  break;
475
480
  }
476
481
 
482
+ // --- Dispatch thread nudge ---
483
+ // Set by x-dispatch/nudge MAP notifications, consumed by hooks.
484
+
485
+ case "nudge": {
486
+ // Called internally when the notification handler fires.
487
+ const { dispatch_id, conversation_id } = command;
488
+ if (dispatch_id) {
489
+ _pendingNudges.set(dispatch_id, {
490
+ conversation_id,
491
+ received_at: Date.now(),
492
+ });
493
+ }
494
+ respond(client, { ok: true });
495
+ break;
496
+ }
497
+
498
+ case "check-nudge": {
499
+ // Called by UserPromptSubmit hook. Returns and clears all
500
+ // pending nudges so the hook can inject a hint.
501
+ const nudges = [];
502
+ for (const [dispatchId, info] of _pendingNudges) {
503
+ nudges.push({
504
+ dispatch_id: dispatchId,
505
+ conversation_id: info.conversation_id,
506
+ });
507
+ }
508
+ _pendingNudges.clear();
509
+ respond(client, { ok: true, nudges });
510
+ break;
511
+ }
512
+
477
513
  default:
478
514
  respond(client, { ok: false, error: `Unknown action: ${action}` });
479
515
  }
@@ -155,7 +155,7 @@ export function inferProfileFromRole(roleName) {
155
155
  // The bridge between openteams `loadout.skills` (SkillsConfig in the
156
156
  // schema) and skill-tree's LoadoutCriteria. skill-tree is the
157
157
  // *mechanism*; openteams is the *declaration layer* that dispatches
158
- // into it. See openhive's docs/LOADOUT_INTEGRATION.md for the model.
158
+ // into it.
159
159
  //
160
160
  // Bridged fields are locked in by src/__tests__/loadout-schema-bridge.test.mjs
161
161
  // which cross-references this list against openteams' SkillsConfig schema.