@heretyc/subagent-mcp 2.12.4 → 2.12.5-beta.0

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,13 +1,18 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
2
- import { platform } from "node:os";
3
- import { join } from "node:path";
3
+ import { homedir, platform } from "node:os";
4
+ import { dirname, join, parse as parsePath, resolve } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { CONCURRENCY_SCAFFOLD } from "./config-scaffold.js";
6
7
  import { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, } from "./zombie.js";
7
8
  export const DEFAULT_CAP = 20;
8
9
  export const MIN_CAP = 10;
9
10
  export const DEFAULT_CHECK_FOR_UPDATES = true;
10
- export const CONFIG_FILENAME = "global-concurrency.jsonc";
11
+ export const DEFAULT_PERMISSIONS_CEILING = "auto";
12
+ export const DEFAULT_ESCALATION = "irreversible-only";
13
+ export const DEFAULT_STRICT_READ_PARITY = "warn";
14
+ export const CONFIG_FILENAME = "global-subagent-mcp-config.jsonc";
15
+ export const LEGACY_CONFIG_FILENAME = "global-concurrency.jsonc";
11
16
  export { cullStaleSlots, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_LIVE_IDLE_MS, ZOMBIE_TERMINAL_IDLE_MS, buildProcessTreeKillCommands, drainZombieIntents, drainZombieReports, parseSlotMetadata, readSlotMetadata, slotPathForAgent, writeSlotMetadata, };
12
17
  export function clampCap(raw) {
13
18
  if (!Number.isInteger(raw))
@@ -22,6 +27,13 @@ export function clampCap(raw) {
22
27
  export function stripJsoncComments(text) {
23
28
  return text.replace(/^\s*\/\/.*$/gm, "");
24
29
  }
30
+ function parseJsonObject(text) {
31
+ const parsed = JSON.parse(stripJsoncComments(text));
32
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
33
+ throw new Error("expected JSON object");
34
+ }
35
+ return parsed;
36
+ }
25
37
  export function parseConcurrencyConfig(text) {
26
38
  let raw;
27
39
  try {
@@ -42,34 +54,336 @@ export function parseCheckForUpdatesConfig(text) {
42
54
  return DEFAULT_CHECK_FOR_UPDATES;
43
55
  }
44
56
  }
57
+ export function parsePermissionsCeilingConfig(text) {
58
+ try {
59
+ const raw = parseJsonObject(text).permissionsCeiling;
60
+ if (raw === "yolo" || raw === "manual" || raw === "auto")
61
+ return raw;
62
+ return raw === undefined ? DEFAULT_PERMISSIONS_CEILING : "manual";
63
+ }
64
+ catch {
65
+ return "manual";
66
+ }
67
+ }
68
+ export function parseEscalationConfig(text) {
69
+ try {
70
+ const raw = parseJsonObject(text).escalation;
71
+ return raw === "irreversible-only" || raw === "off" ? raw : DEFAULT_ESCALATION;
72
+ }
73
+ catch {
74
+ return DEFAULT_ESCALATION;
75
+ }
76
+ }
77
+ export function parseStrictReadParityConfig(text) {
78
+ try {
79
+ const raw = parseJsonObject(text).strictReadParity;
80
+ return raw === "warn" || raw === "off" ? raw : DEFAULT_STRICT_READ_PARITY;
81
+ }
82
+ catch {
83
+ return DEFAULT_STRICT_READ_PARITY;
84
+ }
85
+ }
45
86
  export function defaultConfigPath() {
46
87
  return fileURLToPath(new URL("./" + CONFIG_FILENAME, import.meta.url));
47
88
  }
89
+ export function legacyConfigPath(path = defaultConfigPath()) {
90
+ return join(dirname(path), LEGACY_CONFIG_FILENAME);
91
+ }
92
+ export function resolveGlobalConfigPath(path = defaultConfigPath()) {
93
+ if (existsSync(path))
94
+ return { path, usedLegacyPath: false };
95
+ const legacyPath = legacyConfigPath(path);
96
+ if (existsSync(legacyPath))
97
+ return { path: legacyPath, usedLegacyPath: true };
98
+ return { path, usedLegacyPath: false };
99
+ }
48
100
  export function ensureConcurrencyConfig(path = defaultConfigPath()) {
49
101
  try {
50
- if (existsSync(path))
102
+ const resolved = resolveGlobalConfigPath(path);
103
+ if (existsSync(resolved.path))
51
104
  return;
52
105
  writeFileSync(path, CONCURRENCY_SCAFFOLD);
53
106
  }
54
107
  catch { }
55
108
  }
56
- export function readGlobalCap(path = defaultConfigPath()) {
109
+ export function readGlobalConfig(path = defaultConfigPath()) {
57
110
  try {
58
111
  ensureConcurrencyConfig(path);
59
- return parseConcurrencyConfig(readFileSync(path, "utf8"));
112
+ const resolved = resolveGlobalConfigPath(path);
113
+ const text = readFileSync(resolved.path, "utf8");
114
+ parseJsonObject(text);
115
+ return {
116
+ globalConcurrentSubagents: parseConcurrencyConfig(text),
117
+ checkForUpdates: parseCheckForUpdatesConfig(text),
118
+ permissionsCeiling: parsePermissionsCeilingConfig(text),
119
+ escalation: parseEscalationConfig(text),
120
+ strictReadParity: parseStrictReadParityConfig(text),
121
+ path: resolved.path,
122
+ usedLegacyPath: resolved.usedLegacyPath,
123
+ parseFailure: null,
124
+ };
60
125
  }
61
- catch {
62
- return DEFAULT_CAP;
126
+ catch (e) {
127
+ return {
128
+ globalConcurrentSubagents: DEFAULT_CAP,
129
+ checkForUpdates: DEFAULT_CHECK_FOR_UPDATES,
130
+ permissionsCeiling: "manual",
131
+ escalation: DEFAULT_ESCALATION,
132
+ strictReadParity: DEFAULT_STRICT_READ_PARITY,
133
+ path,
134
+ usedLegacyPath: false,
135
+ parseFailure: e instanceof Error ? e.message : String(e),
136
+ };
63
137
  }
64
138
  }
139
+ export function readGlobalCap(path = defaultConfigPath()) {
140
+ return readGlobalConfig(path).globalConcurrentSubagents;
141
+ }
65
142
  export function readCheckForUpdates(path = defaultConfigPath()) {
143
+ return readGlobalConfig(path).checkForUpdates;
144
+ }
145
+ export function readPermissionsCeiling(path = defaultConfigPath()) {
146
+ return readGlobalConfig(path).permissionsCeiling;
147
+ }
148
+ export function readEscalation(path = defaultConfigPath()) {
149
+ return readGlobalConfig(path).escalation;
150
+ }
151
+ export function readStrictReadParity(path = defaultConfigPath()) {
152
+ return readGlobalConfig(path).strictReadParity;
153
+ }
154
+ let legacyConfigDeprecationPending = false;
155
+ export function noteLegacyConfigIfUsed(path = defaultConfigPath()) {
156
+ if (resolveGlobalConfigPath(path).usedLegacyPath)
157
+ legacyConfigDeprecationPending = true;
158
+ }
159
+ export function consumeLegacyConfigDeprecationNotice() {
160
+ if (!legacyConfigDeprecationPending)
161
+ return null;
162
+ legacyConfigDeprecationPending = false;
163
+ return `${LEGACY_CONFIG_FILENAME} is deprecated; rename it to ${CONFIG_FILENAME}. The legacy name is still read for one major version when the new file is absent.`;
164
+ }
165
+ function fileDigest(path) {
66
166
  try {
67
- ensureConcurrencyConfig(path);
68
- return parseCheckForUpdatesConfig(readFileSync(path, "utf8"));
167
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
69
168
  }
70
169
  catch {
71
- return DEFAULT_CHECK_FOR_UPDATES;
170
+ return null;
171
+ }
172
+ }
173
+ let startupCeilingDigest = fileDigest(resolveGlobalConfigPath().path);
174
+ export function checkCeilingIntegrity(path = defaultConfigPath()) {
175
+ const digest = fileDigest(resolveGlobalConfigPath(path).path);
176
+ if (digest !== startupCeilingDigest) {
177
+ startupCeilingDigest = digest;
178
+ return "changed_since_startup";
179
+ }
180
+ return null;
181
+ }
182
+ export function configSelfProtectionDenyRules(path = defaultConfigPath()) {
183
+ const current = resolve(path);
184
+ const legacy = resolve(legacyConfigPath(path));
185
+ return [current, legacy].flatMap((p) => [`Edit(${p})`, `Write(${p})`, `NotebookEdit(${p})`]);
186
+ }
187
+ function asStringArray(raw) {
188
+ if (raw === undefined)
189
+ return [];
190
+ if (!Array.isArray(raw) || raw.some((v) => typeof v !== "string"))
191
+ return null;
192
+ return raw;
193
+ }
194
+ function readClaudePermissions(source, path, userScoped) {
195
+ try {
196
+ const parsed = parseJsonObject(readFileSync(path, "utf8"));
197
+ const permissions = parsed.permissions;
198
+ if (permissions !== undefined && (!permissions || typeof permissions !== "object" || Array.isArray(permissions))) {
199
+ throw new Error("permissions must be an object");
200
+ }
201
+ const p = (permissions ?? {});
202
+ const allow = asStringArray(p.allow);
203
+ const deny = asStringArray(p.deny);
204
+ const ask = asStringArray(p.ask);
205
+ const additionalDirectories = asStringArray(p.additionalDirectories);
206
+ if (!allow || !deny || !ask || !additionalDirectories) {
207
+ throw new Error("permissions arrays must contain only strings");
208
+ }
209
+ const disableBypass = userScoped && parsed.disableBypassPermissionsMode === "disable";
210
+ return {
211
+ rules: { allow, deny, ask, additionalDirectories },
212
+ disableBypass,
213
+ failure: null,
214
+ };
215
+ }
216
+ catch (e) {
217
+ return {
218
+ rules: blanketMutatingAskRules(),
219
+ disableBypass: false,
220
+ failure: { source, path, error: e instanceof Error ? e.message : String(e) },
221
+ };
222
+ }
223
+ }
224
+ function blanketMutatingAskRules() {
225
+ return {
226
+ allow: [],
227
+ deny: [],
228
+ ask: ["Bash", "Edit", "Write", "NotebookEdit", "MultiEdit"],
229
+ additionalDirectories: [],
230
+ };
231
+ }
232
+ function mergeRules(target, next) {
233
+ target.allow.push(...next.allow);
234
+ target.deny.push(...next.deny);
235
+ target.ask.push(...next.ask);
236
+ target.additionalDirectories.push(...next.additionalDirectories);
237
+ }
238
+ function unique(items) {
239
+ return [...new Set(items)];
240
+ }
241
+ function codexTomlValue(text, key) {
242
+ const m = text.match(new RegExp(`^\\s*${key}\\s*=\\s*["']?([^"'\\r\\n#]+)["']?`, "m"));
243
+ return m ? m[1].trim() : null;
244
+ }
245
+ function codexTomlArray(text, key) {
246
+ const m = text.match(new RegExp(`^\\s*${key}\\s*=\\s*\\[([^\\]]*)\\]`, "m"));
247
+ if (!m)
248
+ return [];
249
+ return [...m[1].matchAll(/["']([^"']+)["']/g)].map((v) => v[1]);
250
+ }
251
+ function assertBasicTomlParsable(text) {
252
+ let inArray = false;
253
+ for (const line of text.split(/\r?\n/)) {
254
+ const trimmed = line.replace(/#.*$/, "").trim();
255
+ if (!trimmed)
256
+ continue;
257
+ if (/^\[[^\]]+\]$/.test(trimmed))
258
+ continue;
259
+ if (!trimmed.includes("=") && !inArray)
260
+ throw new Error(`malformed TOML line: ${trimmed}`);
261
+ const quoteCount = (trimmed.match(/(?<!\\)"/g) ?? []).length + (trimmed.match(/(?<!\\)'/g) ?? []).length;
262
+ if (quoteCount % 2 !== 0)
263
+ throw new Error(`unbalanced TOML quotes: ${trimmed}`);
264
+ const opens = (trimmed.match(/\[/g) ?? []).length;
265
+ const closes = (trimmed.match(/\]/g) ?? []).length;
266
+ inArray = inArray || opens > closes;
267
+ if (closes > opens)
268
+ inArray = false;
269
+ }
270
+ if (inArray)
271
+ throw new Error("unterminated TOML array");
272
+ }
273
+ function readCodexConfig(path) {
274
+ try {
275
+ const text = readFileSync(path, "utf8");
276
+ assertBasicTomlParsable(text);
277
+ const rules = { allow: [], deny: [], ask: [], additionalDirectories: [] };
278
+ if (codexTomlValue(text, "sandbox_mode") === "read-only") {
279
+ rules.deny.push("Edit", "Write", "NotebookEdit");
280
+ }
281
+ rules.additionalDirectories.push(...codexTomlArray(text, "writable_roots"));
282
+ for (const m of text.matchAll(/^\s*path\s*=\s*["']([^"']+)["']\s*\r?\n\s*access\s*=\s*["']deny["']/gm)) {
283
+ rules.deny.push(`Edit(${m[1]})`, `Write(${m[1]})`);
284
+ }
285
+ for (const m of text.matchAll(/^\s*domain\s*=\s*["']([^"']+)["']\s*\r?\n\s*access\s*=\s*["']deny["']/gm)) {
286
+ rules.deny.push(`WebFetch(domain:${m[1]})`);
287
+ }
288
+ return { rules, failure: null };
289
+ }
290
+ catch (e) {
291
+ return {
292
+ rules: blanketMutatingAskRules(),
293
+ failure: { source: "repo-codex-config", path, error: e instanceof Error ? e.message : String(e) },
294
+ };
295
+ }
296
+ }
297
+ function codexConfigChain(cwd) {
298
+ const out = [];
299
+ let cur = resolve(cwd);
300
+ const root = parsePath(cur).root;
301
+ const dirs = [];
302
+ while (true) {
303
+ dirs.push(cur);
304
+ if (cur === root)
305
+ break;
306
+ cur = dirname(cur);
307
+ }
308
+ for (const dir of dirs.reverse()) {
309
+ const cfg = join(dir, ".codex", "config.toml");
310
+ if (existsSync(cfg))
311
+ out.push(cfg);
312
+ }
313
+ return out;
314
+ }
315
+ function repoConfigDigest(cwd) {
316
+ const files = [
317
+ join(cwd, ".claude", "settings.json"),
318
+ join(cwd, ".claude", "settings.local.json"),
319
+ ...codexConfigChain(cwd),
320
+ ].filter((p) => existsSync(p));
321
+ const h = createHash("sha256");
322
+ for (const file of files)
323
+ h.update(file).update("\0").update(readFileSync(file));
324
+ return h.digest("hex");
325
+ }
326
+ const firstRepoDigests = new Map();
327
+ export function readMergedPermissionConfig(cwd, path = defaultConfigPath()) {
328
+ const global = readGlobalConfig(path);
329
+ noteLegacyConfigIfUsed(path);
330
+ const merged = { allow: [], deny: [], ask: [], additionalDirectories: [] };
331
+ const failures = [];
332
+ let ceiling = global.permissionsCeiling;
333
+ if (global.parseFailure) {
334
+ failures.push({ source: "builtin", path: global.path, error: global.parseFailure });
335
+ ceiling = "manual";
336
+ mergeRules(merged, blanketMutatingAskRules());
337
+ }
338
+ let disableBypass = false;
339
+ const sources = [
340
+ ["user-settings", join(homedir(), ".subagent-mcp", "settings.json"), true],
341
+ ["user-settings-local", join(homedir(), ".subagent-mcp", "settings.local.json"), true],
342
+ ["repo-claude-settings", join(cwd, ".claude", "settings.json"), false],
343
+ ["repo-claude-settings-local", join(cwd, ".claude", "settings.local.json"), false],
344
+ ];
345
+ for (const [source, file, userScoped] of sources) {
346
+ if (!existsSync(file))
347
+ continue;
348
+ const read = readClaudePermissions(source, file, userScoped);
349
+ mergeRules(merged, read.rules);
350
+ if (read.disableBypass)
351
+ disableBypass = true;
352
+ if (read.failure) {
353
+ failures.push(read.failure);
354
+ ceiling = "manual";
355
+ }
356
+ }
357
+ for (const file of codexConfigChain(cwd)) {
358
+ const read = readCodexConfig(file);
359
+ mergeRules(merged, read.rules);
360
+ if (read.failure) {
361
+ failures.push(read.failure);
362
+ ceiling = "manual";
363
+ }
72
364
  }
365
+ if (disableBypass && ceiling === "yolo")
366
+ ceiling = "auto";
367
+ const digest = repoConfigDigest(cwd);
368
+ const first = firstRepoDigests.get(cwd);
369
+ const repoConfigChangedSinceFirstSeen = first !== undefined && first !== digest;
370
+ if (first === undefined)
371
+ firstRepoDigests.set(cwd, digest);
372
+ const selfProtectionDeny = configSelfProtectionDenyRules(path);
373
+ const protectedDirs = new Set([resolve(path), resolve(legacyConfigPath(path))]);
374
+ merged.deny.push(...selfProtectionDeny);
375
+ return {
376
+ allow: unique(merged.allow),
377
+ deny: unique(merged.deny),
378
+ ask: unique(merged.ask),
379
+ additionalDirectories: unique(merged.additionalDirectories).filter((p) => !protectedDirs.has(resolve(p))),
380
+ permissionsCeiling: ceiling,
381
+ escalation: global.escalation,
382
+ strictReadParity: global.strictReadParity,
383
+ configParseFailure: failures,
384
+ repoConfigChangedSinceFirstSeen,
385
+ selfProtectionDeny,
386
+ };
73
387
  }
74
388
  export function slotDir() {
75
389
  if (process.env.SUBAGENT_SLOT_DIR)
@@ -1,2 +1,2 @@
1
- // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/global-concurrency.jsonc — DO NOT EDIT.
2
- export const CONCURRENCY_SCAFFOLD = "// subagent-mcp Global Concurrent Subagent Cap\r\n// ------------------------------------------------------------------\r\n// SOLE source of truth for the machine-wide limit on how many subagents\r\n// may be ALIVE AT ONCE across EVERY session, process, and user on this\r\n// machine. There is NO environment-variable override for the cap.\r\n//\r\n// The whole recursive descendant tree counts toward this ONE number: a\r\n// subagent that itself launches subagents adds to the same machine-wide\r\n// total, and OTHER active agentic sessions count too.\r\n//\r\n// RE-READ on every launch_agent call edits take effect immediately, no\r\n// server restart required.\r\n//\r\n// Value rules (forcibly applied to the number below):\r\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\r\n// - 1 through 9 -> forced UP to minimum 10\r\n// - 10 or greater -> used as-is\r\n//\r\n// Zombie culling is always enabled. There is no config knob. Before cap\r\n// rejection, launch/tool/hook paths refresh live owned slots, preserve stale\r\n// slots whose owner server is still alive, and cull stale slots whose owner is\r\n// gone. Managed stale slots terminate the child process tree, then force-kill\r\n// after 20s when needed; unmanaged stale slots are only unlinked.\r\n//\r\n// When the cap is reached after culling, launch_agent is REJECTED (never\r\n// queued). Free a slot with list_agents + kill_agent, then retry.\r\n//\r\n// checkForUpdates controls the silent npmjs update check started when the MCP\r\n// server connects. Default true. Set to false to skip the registry fetch and\r\n// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.\r\n{\r\n \"globalConcurrentSubagents\": 20,\r\n \"checkForUpdates\": true\r\n}\r\n";
1
+ // GENERATED by scripts/gen-ruleset-scaffold.mjs from src/global-subagent-mcp-config.jsonc — DO NOT EDIT.
2
+ export const CONCURRENCY_SCAFFOLD = "// subagent-mcp - Global Subagent MCP Config\n// ------------------------------------------------------------------\n// SOLE source of truth for machine-wide subagent-mcp defaults that must be\n// available beside the compiled server.\n//\n// globalConcurrentSubagents controls how many subagents may be ALIVE AT ONCE\n// across EVERY session, process, and user on this machine. There is NO\n// environment-variable override for the cap.\n//\n// RE-READ on every launch_agent call - edits take effect immediately, no\n// server restart required.\n//\n// Cap value rules:\n// - missing / unset / non-integer / 0 / negative -> reset to default 20\n// - 1 through 9 -> forced UP to minimum 10\n// - 10 or greater -> used as-is\n//\n// checkForUpdates controls the silent npmjs update check started when the MCP\n// server connects. Default true. Set to false to skip the registry fetch and\n// suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.\n//\n// permissionsCeiling controls launch-time permissions posture:\n// - auto (default): shared engine gates unsafe/residue actions\n// - manual: human approval for residue while still auto-denying DANGER\n// - yolo: preserve the historical bypass/danger-full-access path, except\n// for non-bypassable config-file self-protection\n//\n// escalation applies only in auto mode. irreversible-only routes irreversible\n// NEUTRAL residue to a human; off leaves residue to orchestrator judgment.\n//\n// strictReadParity controls logging only. Unparseable Codex approval payloads\n// always fail closed to ask; warn logs the construct, off silences that log.\n{\n \"globalConcurrentSubagents\": 20,\n \"checkForUpdates\": true,\n \"permissionsCeiling\": \"auto\",\n \"escalation\": \"irreversible-only\",\n \"strictReadParity\": \"warn\"\n}\n";