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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/docs/loadout-consumer-design.md +469 -0
  4. package/e2e/tier7-loadout-live.test.mjs +221 -0
  5. package/package.json +3 -3
  6. package/scripts/map-hook.mjs +30 -5
  7. package/scripts/map-sidecar.mjs +32 -0
  8. package/scripts/scope-check.mjs +132 -0
  9. package/skills/swarm-mcp/SKILL.md +116 -0
  10. package/src/__tests__/cognitive-core-loadout-e2e.test.mjs +260 -0
  11. package/src/__tests__/e2e-loadout-demo.test.mjs +150 -0
  12. package/src/__tests__/fixtures/loadout-compile-team/loadouts/base-reviewer.yaml +16 -0
  13. package/src/__tests__/fixtures/loadout-compile-team/loadouts/extended-security.yaml +10 -0
  14. package/src/__tests__/fixtures/loadout-compile-team/roles/auditor.yaml +4 -0
  15. package/src/__tests__/fixtures/loadout-compile-team/roles/inline-extender.yaml +10 -0
  16. package/src/__tests__/fixtures/loadout-compile-team/roles/reviewer.yaml +4 -0
  17. package/src/__tests__/fixtures/loadout-compile-team/team.yaml +15 -0
  18. package/src/__tests__/loadout-materializer.test.mjs +578 -0
  19. package/src/__tests__/loadout-schema-bridge.test.mjs +176 -0
  20. package/src/__tests__/loadout-skilltree-compile-e2e.test.mjs +444 -0
  21. package/src/__tests__/loadout-template-shape.test.mjs +102 -0
  22. package/src/__tests__/mcp-health-checker.test.mjs +327 -0
  23. package/src/__tests__/scope-check.test.mjs +210 -0
  24. package/src/__tests__/sidecar-nudge.test.mjs +137 -0
  25. package/src/__tests__/skilltree-client.test.mjs +185 -1
  26. package/src/agent-generator.mjs +135 -8
  27. package/src/bootstrap.mjs +17 -9
  28. package/src/context-output.mjs +32 -0
  29. package/src/loadout-materializer.mjs +315 -0
  30. package/src/map-events.mjs +8 -1
  31. package/src/mcp-health-checker.mjs +237 -0
  32. package/src/sidecar-server.mjs +36 -0
  33. package/src/skilltree-client.mjs +135 -24
  34. 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
+ }
@@ -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
  };
@@ -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
+ */
@@ -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
  }