claude-code-swarm 0.3.24 → 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/docs/loadout-consumer-design.md +469 -0
- package/e2e/tier7-loadout-live.test.mjs +221 -0
- package/package.json +3 -3
- package/scripts/map-hook.mjs +30 -5
- package/scripts/map-sidecar.mjs +32 -0
- package/scripts/scope-check.mjs +132 -0
- package/skills/swarm-mcp/SKILL.md +116 -0
- package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
- package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
- package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
- package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
- package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
- package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
- package/src/__tests__/loadout-materializer.test.mjs +578 -0
- package/src/__tests__/loadout-schema-bridge.test.mjs +176 -0
- package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
- package/src/__tests__/loadout-template-shape.test.mjs +102 -0
- package/src/__tests__/mcp-health-checker.test.mjs +327 -0
- package/src/__tests__/scope-check.test.mjs +210 -0
- package/src/__tests__/sidecar-nudge.test.mjs +137 -0
- package/src/__tests__/skilltree-client.test.mjs +185 -1
- package/src/agent-generator.mjs +135 -8
- package/src/bootstrap.mjs +17 -9
- package/src/context-output.mjs +32 -0
- package/src/loadout-materializer.mjs +315 -0
- package/src/map-events.mjs +8 -1
- package/src/mcp-health-checker.mjs +237 -0
- package/src/sidecar-server.mjs +36 -0
- package/src/skilltree-client.mjs +135 -24
- package/src/template.mjs +158 -2
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loadout-materializer.mjs — pure functions that convert openteams
|
|
3
|
+
* loadouts into Claude Code sub-agent frontmatter + sibling scope files.
|
|
4
|
+
*
|
|
5
|
+
* No filesystem writes in this module. All I/O is the caller's job.
|
|
6
|
+
* See docs/loadout-consumer-design.md for the full design.
|
|
7
|
+
*
|
|
8
|
+
* Inputs:
|
|
9
|
+
* - role — role object (at minimum: { name, description?, displayName? })
|
|
10
|
+
* - loadout — openteams ResolvedLoadout (may be undefined for loadout-less roles)
|
|
11
|
+
* - template — openteams ResolvedTemplate shape (mcpProviders as Map or plain object)
|
|
12
|
+
* - options — { teamName, projectPath, hookCommand, scopeFilePath, nativeTools, position }
|
|
13
|
+
*
|
|
14
|
+
* Output from materializeLoadout():
|
|
15
|
+
* {
|
|
16
|
+
* frontmatter: {...}, // YAML-serializable object for AGENT.md
|
|
17
|
+
* scopeFile: {...}, // JSON-serializable object for the scope-check hook
|
|
18
|
+
* warnings: [string], // non-fatal issues to surface to the user
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MCP_TOOL_PREFIX = "mcp__";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Build the full materialization result for a role.
|
|
26
|
+
*/
|
|
27
|
+
export function materializeLoadout({
|
|
28
|
+
role,
|
|
29
|
+
loadout,
|
|
30
|
+
template,
|
|
31
|
+
options = {},
|
|
32
|
+
} = {}) {
|
|
33
|
+
if (!role || typeof role !== "object" || !role.name) {
|
|
34
|
+
throw new Error("materializeLoadout: role.name is required");
|
|
35
|
+
}
|
|
36
|
+
const warnings = [];
|
|
37
|
+
const teamName = options.teamName ?? template?.manifest?.name ?? "team";
|
|
38
|
+
|
|
39
|
+
const mcp = resolveMcpScope({
|
|
40
|
+
mcpScope: loadout?.mcpScope ?? [],
|
|
41
|
+
mcpInstalls: loadout?.mcpServers ?? [],
|
|
42
|
+
providers: toProviderMap(template?.mcpProviders),
|
|
43
|
+
warnings,
|
|
44
|
+
roleName: role.name,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const scopeFile = buildScopeFile({
|
|
48
|
+
role,
|
|
49
|
+
loadout,
|
|
50
|
+
mcp,
|
|
51
|
+
teamName,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const frontmatter = buildFrontmatter({
|
|
55
|
+
role,
|
|
56
|
+
loadout,
|
|
57
|
+
teamName,
|
|
58
|
+
mcp,
|
|
59
|
+
scopeFilePath: options.scopeFilePath,
|
|
60
|
+
hookCommand: options.hookCommand,
|
|
61
|
+
projectPath: options.projectPath,
|
|
62
|
+
nativeTools: options.nativeTools ?? defaultNativeTools(role, options),
|
|
63
|
+
position: options.position,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return { frontmatter, scopeFile, warnings };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ────────────────────────────────────────────────────────────────
|
|
70
|
+
// MCP scope resolution
|
|
71
|
+
// ────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normalize mcpScope (from openteams) + install-bearing mcpServers
|
|
75
|
+
* into the shapes we need:
|
|
76
|
+
*
|
|
77
|
+
* mcpServers — list of `string | inline` for frontmatter
|
|
78
|
+
* disallowedTools — list of `mcp__<server>__<tool>` for frontmatter
|
|
79
|
+
* scope — canonical scope list for the scope file
|
|
80
|
+
* (normalized server → tools?/exclude?)
|
|
81
|
+
* refs — symbolic refs the consumer should resolve later
|
|
82
|
+
*/
|
|
83
|
+
export function resolveMcpScope({
|
|
84
|
+
mcpScope = [],
|
|
85
|
+
mcpInstalls = [],
|
|
86
|
+
providers = new Map(),
|
|
87
|
+
warnings = [],
|
|
88
|
+
roleName = "",
|
|
89
|
+
} = {}) {
|
|
90
|
+
const mcpServers = [];
|
|
91
|
+
const seenServers = new Set();
|
|
92
|
+
const refs = [];
|
|
93
|
+
|
|
94
|
+
// Install-bearing entries authored by the loadout itself.
|
|
95
|
+
// Inline install specs spawn a subprocess per agent invocation —
|
|
96
|
+
// we emit them verbatim but warn users about the cost.
|
|
97
|
+
for (const install of mcpInstalls) {
|
|
98
|
+
if (install && typeof install === "object" && "ref" in install) {
|
|
99
|
+
refs.push({ ref: install.ref, config: install.config });
|
|
100
|
+
// Refs resolve lazily — don't add to mcpServers until resolved.
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (install && typeof install === "object" && "name" in install) {
|
|
104
|
+
if (seenServers.has(install.name)) continue;
|
|
105
|
+
seenServers.add(install.name);
|
|
106
|
+
const inline = { [install.name]: toInlineSpec(install) };
|
|
107
|
+
mcpServers.push(inline);
|
|
108
|
+
warnings.push(
|
|
109
|
+
`Role "${roleName}": loadout inlines MCP server "${install.name}" — ` +
|
|
110
|
+
`each subagent spawn starts its own subprocess. Consider moving to ` +
|
|
111
|
+
`team.mcp_providers instead.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Pure-scope references. A string-ref entry gives the agent access to
|
|
117
|
+
// the named server from the session-level base set.
|
|
118
|
+
for (const entry of mcpScope) {
|
|
119
|
+
if (!entry || !entry.server) continue;
|
|
120
|
+
const name = entry.server;
|
|
121
|
+
if (!seenServers.has(name)) {
|
|
122
|
+
seenServers.add(name);
|
|
123
|
+
mcpServers.push(name);
|
|
124
|
+
}
|
|
125
|
+
// Advisory: flag if neither providers declare it nor it's inline.
|
|
126
|
+
if (!providers.has(name)) {
|
|
127
|
+
// We don't definitively know whether the consumer has this server
|
|
128
|
+
// installed externally — that's handled by the health-check step.
|
|
129
|
+
// We only warn when providers expressly omit it; the caller can
|
|
130
|
+
// downgrade or clear the warning after checking against the active set.
|
|
131
|
+
// For now, don't push — health-checker owns cross-referencing.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Translate exclude lists into literal `mcp__<server>__<tool>` denies.
|
|
136
|
+
const disallowedTools = [];
|
|
137
|
+
for (const entry of mcpScope) {
|
|
138
|
+
if (!entry?.exclude?.length) continue;
|
|
139
|
+
for (const tool of entry.exclude) {
|
|
140
|
+
disallowedTools.push(`${MCP_TOOL_PREFIX}${entry.server}__${tool}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build the canonical scope list for the scope file (hook input).
|
|
145
|
+
const scope = mcpScope.map((entry) => {
|
|
146
|
+
const out = { server: entry.server };
|
|
147
|
+
if (entry.tools?.length) out.tools = [...entry.tools];
|
|
148
|
+
if (entry.exclude?.length) out.exclude = [...entry.exclude];
|
|
149
|
+
return out;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { mcpServers, disallowedTools, scope, refs };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toInlineSpec(install) {
|
|
156
|
+
const spec = {};
|
|
157
|
+
if (install.command) spec.command = install.command;
|
|
158
|
+
if (install.args) spec.args = [...install.args];
|
|
159
|
+
if (install.env) spec.env = { ...install.env };
|
|
160
|
+
if (install.type) spec.type = install.type;
|
|
161
|
+
return spec;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Accept either a Map (openteams native) or a plain object (cached JSON)
|
|
166
|
+
* and return a Map for consistent lookup.
|
|
167
|
+
*/
|
|
168
|
+
function toProviderMap(providers) {
|
|
169
|
+
if (!providers) return new Map();
|
|
170
|
+
if (providers instanceof Map) return providers;
|
|
171
|
+
return new Map(Object.entries(providers));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ────────────────────────────────────────────────────────────────
|
|
175
|
+
// Scope file (hook input)
|
|
176
|
+
// ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build the JSON payload the scope-check hook reads at runtime.
|
|
180
|
+
* Includes scope declarations + loadout permissions for enforcement.
|
|
181
|
+
*/
|
|
182
|
+
export function buildScopeFile({ role, loadout, mcp, teamName }) {
|
|
183
|
+
const scopeFile = {
|
|
184
|
+
role: role.name,
|
|
185
|
+
team: teamName,
|
|
186
|
+
scope: mcp?.scope ?? [],
|
|
187
|
+
permissions: {
|
|
188
|
+
allow: loadout?.permissions?.allow ? [...loadout.permissions.allow] : [],
|
|
189
|
+
deny: loadout?.permissions?.deny ? [...loadout.permissions.deny] : [],
|
|
190
|
+
ask: loadout?.permissions?.ask ? [...loadout.permissions.ask] : [],
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
return scopeFile;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ────────────────────────────────────────────────────────────────
|
|
197
|
+
// Frontmatter assembly
|
|
198
|
+
// ────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Assemble the frontmatter object for the AGENT.md file.
|
|
202
|
+
* Order of keys is deliberate for human readability when serialized.
|
|
203
|
+
*/
|
|
204
|
+
export function buildFrontmatter({
|
|
205
|
+
role,
|
|
206
|
+
loadout,
|
|
207
|
+
teamName,
|
|
208
|
+
mcp,
|
|
209
|
+
scopeFilePath,
|
|
210
|
+
hookCommand,
|
|
211
|
+
projectPath,
|
|
212
|
+
nativeTools,
|
|
213
|
+
position,
|
|
214
|
+
}) {
|
|
215
|
+
const name = `${teamName}-${role.name}`;
|
|
216
|
+
const description =
|
|
217
|
+
role.description ||
|
|
218
|
+
loadout?.description ||
|
|
219
|
+
`${role.name} role for the ${teamName} team`;
|
|
220
|
+
|
|
221
|
+
const fm = {
|
|
222
|
+
name,
|
|
223
|
+
description,
|
|
224
|
+
team_name: teamName,
|
|
225
|
+
role: role.name,
|
|
226
|
+
generated_by: "claude-code-swarm",
|
|
227
|
+
generated_at: new Date().toISOString(),
|
|
228
|
+
};
|
|
229
|
+
if (projectPath) fm.project_path = projectPath;
|
|
230
|
+
if (position) fm.position = position;
|
|
231
|
+
|
|
232
|
+
if (nativeTools?.length) fm.tools = [...nativeTools];
|
|
233
|
+
|
|
234
|
+
if (mcp?.mcpServers?.length) fm.mcpServers = mcp.mcpServers;
|
|
235
|
+
|
|
236
|
+
if (mcp?.disallowedTools?.length) {
|
|
237
|
+
fm.disallowedTools = mcp.disallowedTools;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const needsHook = scopeNeedsHook(mcp, loadout);
|
|
241
|
+
if (needsHook && hookCommand && scopeFilePath) {
|
|
242
|
+
fm.hooks = buildHookDeclaration({
|
|
243
|
+
hookCommand,
|
|
244
|
+
scopeFilePath,
|
|
245
|
+
roleName: role.name,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (loadout?.capabilities?.length) {
|
|
250
|
+
fm.capabilities = [...loadout.capabilities];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return fm;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* We only emit a hook when there's something the hook can enforce that
|
|
258
|
+
* the static frontmatter can't — namely tool-level allowlists (no
|
|
259
|
+
* wildcard support in `tools:`) or non-empty permissions lists that
|
|
260
|
+
* aren't already materialized elsewhere.
|
|
261
|
+
*/
|
|
262
|
+
export function scopeNeedsHook(mcp, loadout) {
|
|
263
|
+
if (!mcp && !loadout) return false;
|
|
264
|
+
if (mcp?.scope?.some((s) => s.tools?.length)) return true;
|
|
265
|
+
if (
|
|
266
|
+
(loadout?.permissions?.allow?.length ?? 0) > 0 ||
|
|
267
|
+
(loadout?.permissions?.deny?.length ?? 0) > 0 ||
|
|
268
|
+
(loadout?.permissions?.ask?.length ?? 0) > 0
|
|
269
|
+
) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function buildHookDeclaration({ hookCommand, scopeFilePath, roleName }) {
|
|
276
|
+
return {
|
|
277
|
+
PreToolUse: [
|
|
278
|
+
{
|
|
279
|
+
matcher: `${MCP_TOOL_PREFIX}.*`,
|
|
280
|
+
hooks: [
|
|
281
|
+
{
|
|
282
|
+
type: "command",
|
|
283
|
+
command: hookCommand,
|
|
284
|
+
env: {
|
|
285
|
+
SCOPE_FILE: scopeFilePath,
|
|
286
|
+
ROLE_NAME: roleName,
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ────────────────────────────────────────────────────────────────
|
|
296
|
+
// Native tools defaulting
|
|
297
|
+
// ────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Mirror the existing agent-generator.mjs behavior for default tools.
|
|
301
|
+
* Callers can override via options.nativeTools if they've already
|
|
302
|
+
* computed tools through another code path.
|
|
303
|
+
*/
|
|
304
|
+
function defaultNativeTools(role, options = {}) {
|
|
305
|
+
const tools = ["Read", "Glob", "Grep", "Bash"];
|
|
306
|
+
if (options.opentasksEnabled) {
|
|
307
|
+
tools.push("SendMessage");
|
|
308
|
+
} else {
|
|
309
|
+
tools.push("TaskList", "TaskUpdate", "SendMessage");
|
|
310
|
+
if (options.position === "root" || options.position === "companion") {
|
|
311
|
+
tools.push("TaskCreate");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return tools;
|
|
315
|
+
}
|
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
|
};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-health-checker.mjs — compare declared MCP providers against the
|
|
3
|
+
* set of servers known to be installed, produce a report, and format it
|
|
4
|
+
* for human consumption.
|
|
5
|
+
*
|
|
6
|
+
* Two entry points:
|
|
7
|
+
* checkMcpHealth({ providers, activeSet, scopeReferences }) — pure
|
|
8
|
+
* discoverActiveSet({ projectPath, pluginPath, userPath }) — reads fs
|
|
9
|
+
*
|
|
10
|
+
* See docs/loadout-consumer-design.md for the non-invasive design.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "fs";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import os from "os";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compare declared providers against the active set and return a report.
|
|
19
|
+
* Pure function — no I/O. Consumers supply the active-set via discoverActiveSet
|
|
20
|
+
* or their own logic (e.g. hive DB lookup).
|
|
21
|
+
*
|
|
22
|
+
* @param providers Map<string, McpProviderSpec> or plain object from team.yaml
|
|
23
|
+
* @param activeSet Map<string, { source, spec? }> of installed servers
|
|
24
|
+
* @param scopeReferences [{ loadout, server }] — which loadouts reference which servers
|
|
25
|
+
* @returns {MCPHealthReport}
|
|
26
|
+
*/
|
|
27
|
+
export function checkMcpHealth({
|
|
28
|
+
providers,
|
|
29
|
+
activeSet = new Map(),
|
|
30
|
+
scopeReferences = [],
|
|
31
|
+
} = {}) {
|
|
32
|
+
const providerMap = toMap(providers);
|
|
33
|
+
const activeMap = toMap(activeSet);
|
|
34
|
+
|
|
35
|
+
const ok = [];
|
|
36
|
+
const missing = [];
|
|
37
|
+
const disabled = [];
|
|
38
|
+
const refs = [];
|
|
39
|
+
|
|
40
|
+
for (const [name, spec] of providerMap) {
|
|
41
|
+
if (spec?.disabled) {
|
|
42
|
+
disabled.push({ name, spec });
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (spec?.ref) {
|
|
46
|
+
refs.push({ name, ref: spec.ref, spec });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const active = activeMap.get(name);
|
|
50
|
+
if (active) {
|
|
51
|
+
ok.push({ name, source: active.source, spec });
|
|
52
|
+
} else {
|
|
53
|
+
missing.push({ name, spec });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Active servers that are NOT declared in providers — informational only.
|
|
58
|
+
// Useful for detecting plugin MCPs or user-installed servers the team
|
|
59
|
+
// template wasn't aware of but can still reference in scope.
|
|
60
|
+
const activeOnly = [];
|
|
61
|
+
for (const [name, active] of activeMap) {
|
|
62
|
+
if (!providerMap.has(name)) {
|
|
63
|
+
activeOnly.push({ name, source: active.source });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Scope references that aren't backed by providers OR active servers.
|
|
68
|
+
// These are the "loadout X wants server Y but it's not available" cases.
|
|
69
|
+
const orphanedReferences = [];
|
|
70
|
+
const availableSet = new Set([...providerMap.keys(), ...activeMap.keys()]);
|
|
71
|
+
for (const ref of scopeReferences) {
|
|
72
|
+
if (!ref?.server) continue;
|
|
73
|
+
if (!availableSet.has(ref.server)) {
|
|
74
|
+
orphanedReferences.push({ loadout: ref.loadout, server: ref.server });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok,
|
|
80
|
+
missing,
|
|
81
|
+
disabled,
|
|
82
|
+
refs,
|
|
83
|
+
activeOnly,
|
|
84
|
+
orphanedReferences,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Collect the set of MCP servers known to be installed by reading the
|
|
90
|
+
* well-known Claude Code configuration paths.
|
|
91
|
+
*
|
|
92
|
+
* Priority order (later wins on conflict):
|
|
93
|
+
* 1. Plugin MCPs (.claude-plugin/plugin.json:mcpServers)
|
|
94
|
+
* 2. User MCPs (~/.claude/mcp.json:mcpServers or ~/.claude.json:mcpServers)
|
|
95
|
+
* 3. Project MCPs (.mcp.json:mcpServers)
|
|
96
|
+
*
|
|
97
|
+
* Returns a Map<string, { source, spec }> describing which server came
|
|
98
|
+
* from where. Silently skips files that don't exist or fail to parse —
|
|
99
|
+
* this is read-only observation, not config validation.
|
|
100
|
+
*/
|
|
101
|
+
export function discoverActiveSet({
|
|
102
|
+
projectPath = process.cwd(),
|
|
103
|
+
pluginPath,
|
|
104
|
+
userHome = os.homedir(),
|
|
105
|
+
} = {}) {
|
|
106
|
+
const active = new Map();
|
|
107
|
+
|
|
108
|
+
// 1. Plugin MCPs — lowest priority
|
|
109
|
+
if (pluginPath) {
|
|
110
|
+
const pluginManifest = path.join(pluginPath, ".claude-plugin", "plugin.json");
|
|
111
|
+
mergeMcpServers(active, pluginManifest, "plugin");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. User MCPs — middle priority
|
|
115
|
+
const userMcp = path.join(userHome, ".claude", "mcp.json");
|
|
116
|
+
mergeMcpServers(active, userMcp, "user");
|
|
117
|
+
|
|
118
|
+
// 3. Project MCPs — highest priority
|
|
119
|
+
const projectMcp = path.join(projectPath, ".mcp.json");
|
|
120
|
+
mergeMcpServers(active, projectMcp, "project");
|
|
121
|
+
|
|
122
|
+
return active;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Scan a loadouts map for every scope reference — useful input for
|
|
127
|
+
* checkMcpHealth's scopeReferences arg. Accepts either openteams' native
|
|
128
|
+
* Map<name, ResolvedLoadout> or a plain object of the same shape.
|
|
129
|
+
*/
|
|
130
|
+
export function collectScopeReferences(loadouts) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const it = toMap(loadouts);
|
|
133
|
+
for (const [name, lo] of it) {
|
|
134
|
+
const scope = lo?.mcpScope ?? [];
|
|
135
|
+
for (const entry of scope) {
|
|
136
|
+
if (entry?.server) {
|
|
137
|
+
out.push({ loadout: name, server: entry.server });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ────────────────────────────────────────────────────────────────
|
|
145
|
+
// Formatting — human-readable report
|
|
146
|
+
// ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const STATUS = {
|
|
149
|
+
ok: "✓",
|
|
150
|
+
missing: "⚠",
|
|
151
|
+
refs: "◎",
|
|
152
|
+
disabled: "○",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Render a report as a text block suitable for terminal output.
|
|
157
|
+
* ASCII-only — no terminal-color escapes (callers can colorize).
|
|
158
|
+
*/
|
|
159
|
+
export function formatHealthReport(report, { teamName = "team" } = {}) {
|
|
160
|
+
const lines = [];
|
|
161
|
+
lines.push(`Team "${teamName}" MCP status`);
|
|
162
|
+
lines.push("─".repeat(40));
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
report.ok.length === 0 &&
|
|
166
|
+
report.missing.length === 0 &&
|
|
167
|
+
report.refs.length === 0 &&
|
|
168
|
+
report.disabled.length === 0
|
|
169
|
+
) {
|
|
170
|
+
lines.push("(no MCP providers declared; team may use installed servers)");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const e of report.ok) {
|
|
174
|
+
lines.push(` ${STATUS.ok} ${e.name} (${e.source})`);
|
|
175
|
+
}
|
|
176
|
+
for (const e of report.missing) {
|
|
177
|
+
lines.push(` ${STATUS.missing} ${e.name} (declared, not active)`);
|
|
178
|
+
lines.push(` → /swarm mcp install ${e.name}`);
|
|
179
|
+
}
|
|
180
|
+
for (const e of report.refs) {
|
|
181
|
+
lines.push(` ${STATUS.refs} ${e.name} (ref: ${e.ref})`);
|
|
182
|
+
lines.push(` → ref resolution deferred`);
|
|
183
|
+
}
|
|
184
|
+
for (const e of report.disabled) {
|
|
185
|
+
lines.push(` ${STATUS.disabled} ${e.name} (disabled)`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (report.orphanedReferences.length > 0) {
|
|
189
|
+
lines.push("");
|
|
190
|
+
lines.push("Loadout scope references not backed by any provider:");
|
|
191
|
+
for (const r of report.orphanedReferences) {
|
|
192
|
+
lines.push(` - loadout "${r.loadout}" uses "${r.server}"`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (report.activeOnly.length > 0) {
|
|
197
|
+
lines.push("");
|
|
198
|
+
lines.push(`Active servers not declared: ${report.activeOnly.map((a) => a.name).join(", ")}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return lines.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ────────────────────────────────────────────────────────────────
|
|
205
|
+
// Internals
|
|
206
|
+
// ────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
function toMap(input) {
|
|
209
|
+
if (!input) return new Map();
|
|
210
|
+
if (input instanceof Map) return input;
|
|
211
|
+
return new Map(Object.entries(input));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function mergeMcpServers(active, jsonPath, source) {
|
|
215
|
+
if (!fs.existsSync(jsonPath)) return;
|
|
216
|
+
let doc;
|
|
217
|
+
try {
|
|
218
|
+
doc = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
|
219
|
+
} catch {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const mcp = doc?.mcpServers;
|
|
223
|
+
if (!mcp || typeof mcp !== "object") return;
|
|
224
|
+
for (const [name, spec] of Object.entries(mcp)) {
|
|
225
|
+
active.set(name, { source, spec });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {Object} MCPHealthReport
|
|
231
|
+
* @property {Array<{name: string, source: string, spec: object}>} ok
|
|
232
|
+
* @property {Array<{name: string, spec: object}>} missing
|
|
233
|
+
* @property {Array<{name: string, spec: object}>} disabled
|
|
234
|
+
* @property {Array<{name: string, ref: string, spec: object}>} refs
|
|
235
|
+
* @property {Array<{name: string, source: string}>} activeOnly
|
|
236
|
+
* @property {Array<{loadout: string, server: string}>} orphanedReferences
|
|
237
|
+
*/
|
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
|
}
|