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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +2 -2
- package/scripts/map-hook.mjs +30 -5
- package/scripts/map-sidecar.mjs +32 -0
- package/src/__tests__/loadout-schema-bridge.test.mjs +1 -2
- package/src/__tests__/sidecar-nudge.test.mjs +137 -0
- package/src/bootstrap.mjs +17 -9
- package/src/map-events.mjs +8 -1
- package/src/sidecar-server.mjs +36 -0
- package/src/skilltree-client.mjs +1 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/map-hook.mjs
CHANGED
|
@@ -77,7 +77,29 @@ async function handleInject(hookData, sessionId) {
|
|
|
77
77
|
const sPaths = sessionPaths(sessionId);
|
|
78
78
|
const config = readConfig();
|
|
79
79
|
|
|
80
|
-
|
|
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
|
|
106
|
-
|
|
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) {
|
package/scripts/map-sidecar.mjs
CHANGED
|
@@ -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.
|
|
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:
|
|
253
|
-
name:
|
|
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
|
|
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:
|
|
323
|
-
name:
|
|
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(() => {})
|
package/src/map-events.mjs
CHANGED
|
@@ -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
|
|
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
|
};
|
package/src/sidecar-server.mjs
CHANGED
|
@@ -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
|
}
|
package/src/skilltree-client.mjs
CHANGED
|
@@ -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.
|
|
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.
|