bosun 0.34.6 → 0.34.8

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
4
  import { executeBlockingHooks, executeHooks, loadHooks, registerBuiltinHooks } from "./agent-hooks.mjs";
5
+ import { shouldRunAgentHookBridge } from "./task-context.mjs";
5
6
 
6
7
  const TAG = "[agent-hook-bridge]";
7
8
 
@@ -161,12 +162,11 @@ function mapEvents(sourceEvent, payload) {
161
162
  }
162
163
 
163
164
  async function run() {
164
- // ── VE_MANAGED guard ──────────────────────────────────────────────────────
165
- // Only execute hooks for sessions managed by bosun.
166
- // bosun sets VE_MANAGED=1 in all spawned agent environments.
167
- // If this env var is missing, we're running inside a standalone agent session
168
- // that just happens to have the hook files in its config — exit silently.
169
- if (!process.env.VE_MANAGED && !process.env.BOSUN_HOOKS_FORCE) {
165
+ // ── task-context guard ────────────────────────────────────────────────────
166
+ // Execute hooks only for active Bosun task sessions.
167
+ // This prevents globally scaffolded hook configs from affecting standalone
168
+ // sessions in user repos. BOSUN_HOOKS_FORCE remains an explicit override.
169
+ if (!shouldRunAgentHookBridge(process.env)) {
170
170
  process.exit(0);
171
171
  }
172
172
 
package/agent-pool.mjs CHANGED
@@ -45,6 +45,16 @@ import { loadConfig } from "./config.mjs";
45
45
  import { resolveRepoRoot, resolveAgentRepoRoot } from "./repo-root.mjs";
46
46
  import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
47
47
 
48
+ // Lazy-load MCP registry to avoid circular dependencies.
49
+ // Cached at module scope per AGENTS.md hard rules.
50
+ let _mcpRegistry = null;
51
+ async function getMcpRegistry() {
52
+ if (!_mcpRegistry) {
53
+ _mcpRegistry = await import("./mcp-registry.mjs");
54
+ }
55
+ return _mcpRegistry;
56
+ }
57
+
48
58
  const __filename = fileURLToPath(import.meta.url);
49
59
  const __dirname = dirname(__filename);
50
60
 
@@ -897,7 +907,7 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
897
907
  * Build CLI arguments for ephemeral Copilot agent-pool sessions.
898
908
  * Mirrors copilot-shell.mjs buildCliArgs() for feature parity.
899
909
  */
900
- function buildPoolCopilotCliArgs() {
910
+ function buildPoolCopilotCliArgs(mcpConfigPath) {
901
911
  const args = [];
902
912
  if (!envFlagEnabled(process.env.COPILOT_NO_EXPERIMENTAL)) {
903
913
  args.push("--experimental");
@@ -915,9 +925,10 @@ function buildPoolCopilotCliArgs() {
915
925
  if (envFlagEnabled(process.env.COPILOT_DISABLE_BUILTIN_MCPS)) {
916
926
  args.push("--disable-builtin-mcps");
917
927
  }
918
- const mcpConfigPath = process.env.COPILOT_ADDITIONAL_MCP_CONFIG;
919
- if (mcpConfigPath) {
920
- args.push("--additional-mcp-config", mcpConfigPath);
928
+ // MCP config: prefer per-launch resolved config, fall back to env var
929
+ const effectiveMcpConfig = mcpConfigPath || process.env.COPILOT_ADDITIONAL_MCP_CONFIG;
930
+ if (effectiveMcpConfig) {
931
+ args.push("--additional-mcp-config", effectiveMcpConfig);
921
932
  }
922
933
  return args;
923
934
  }
@@ -1024,7 +1035,17 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1024
1035
  clientOpts = { cliUrl, env: clientEnv };
1025
1036
  } else {
1026
1037
  // Local mode (default): stdio for full capability
1027
- const cliArgs = buildPoolCopilotCliArgs();
1038
+ // Write temp MCP config if resolved MCP servers are available
1039
+ let mcpConfigPath = null;
1040
+ if (extra._resolvedMcpServers?.length) {
1041
+ try {
1042
+ const registry = await getMcpRegistry();
1043
+ mcpConfigPath = registry.writeTempCopilotMcpConfig(cwd, extra._resolvedMcpServers);
1044
+ } catch (mcpErr) {
1045
+ console.warn(`${TAG} copilot MCP config write failed (non-fatal): ${mcpErr.message}`);
1046
+ }
1047
+ }
1048
+ const cliArgs = buildPoolCopilotCliArgs(mcpConfigPath);
1028
1049
  clientOpts = {
1029
1050
  cwd,
1030
1051
  env: clientEnv,
@@ -1465,6 +1486,27 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1465
1486
  }
1466
1487
 
1467
1488
  // ── 4. Execute query ─────────────────────────────────────────────────────
1489
+ // Inject MCP server config for Claude via environment variable
1490
+ let _claudeMcpEnvCleanup = null;
1491
+ if (extra._resolvedMcpServers?.length) {
1492
+ try {
1493
+ const registry = await getMcpRegistry();
1494
+ const { envVar } = registry.buildClaudeMcpEnv(extra._resolvedMcpServers);
1495
+ if (envVar) {
1496
+ const prev = process.env.CLAUDE_MCP_SERVERS;
1497
+ process.env.CLAUDE_MCP_SERVERS = envVar;
1498
+ _claudeMcpEnvCleanup = () => {
1499
+ if (prev === undefined) {
1500
+ delete process.env.CLAUDE_MCP_SERVERS;
1501
+ } else {
1502
+ process.env.CLAUDE_MCP_SERVERS = prev;
1503
+ }
1504
+ };
1505
+ }
1506
+ } catch (mcpErr) {
1507
+ console.warn(`${TAG} claude MCP env setup failed (non-fatal): ${mcpErr.message}`);
1508
+ }
1509
+ }
1468
1510
  try {
1469
1511
  const msgQueue = createMessageQueue();
1470
1512
 
@@ -1596,6 +1638,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1596
1638
 
1597
1639
  const output =
1598
1640
  finalResponse.trim() || "(Agent completed with no text output)";
1641
+ if (typeof _claudeMcpEnvCleanup === "function") _claudeMcpEnvCleanup();
1599
1642
  return {
1600
1643
  success: true,
1601
1644
  output,
@@ -1605,6 +1648,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1605
1648
  threadId: activeClaudeSessionId,
1606
1649
  };
1607
1650
  } catch (err) {
1651
+ if (typeof _claudeMcpEnvCleanup === "function") _claudeMcpEnvCleanup();
1608
1652
  clearAbortScope();
1609
1653
  if (hardTimer) clearTimeout(hardTimer);
1610
1654
  if (steerKey) unregisterActiveSession(steerKey);
@@ -1714,6 +1758,32 @@ export async function launchEphemeralThread(
1714
1758
  timeoutMs = DEFAULT_TIMEOUT_MS,
1715
1759
  extra = {},
1716
1760
  ) {
1761
+ // ── Resolve MCP servers for this launch ──────────────────────────────────
1762
+ try {
1763
+ if (!extra._mcpResolved) {
1764
+ const cfg = loadConfig();
1765
+ const mcpCfg = cfg.mcpServers || {};
1766
+ if (mcpCfg.enabled !== false) {
1767
+ const requestedIds = extra.mcpServers || [];
1768
+ const defaultIds = mcpCfg.defaultServers || [];
1769
+ if (requestedIds.length || defaultIds.length) {
1770
+ const registry = await getMcpRegistry();
1771
+ const resolved = await registry.resolveMcpServersForAgent(
1772
+ cwd,
1773
+ requestedIds,
1774
+ { defaultServers: defaultIds, catalogOverrides: mcpCfg.catalogOverrides || {} },
1775
+ );
1776
+ if (resolved.length) {
1777
+ extra._resolvedMcpServers = resolved;
1778
+ }
1779
+ }
1780
+ }
1781
+ extra._mcpResolved = true;
1782
+ }
1783
+ } catch (mcpErr) {
1784
+ console.warn(`${TAG} MCP server resolution failed (non-fatal): ${mcpErr.message}`);
1785
+ }
1786
+
1717
1787
  // Determine the primary SDK to try
1718
1788
  const requestedSdk = extra.sdk
1719
1789
  ? String(extra.sdk).trim().toLowerCase()
@@ -63,6 +63,88 @@ const activeSessions = new Map();
63
63
  // Alert cooldowns: "alert_type:attempt_id" -> timestamp
64
64
  const alertCooldowns = new Map();
65
65
  const ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between same alert
66
+ const FAILED_SESSION_ALERT_MIN_COOLDOWN_MS = 60 * 60 * 1000; // Keep noisy failed-session summaries coarse-grained
67
+ const ALERT_COOLDOWN_RETENTION_MS = Math.max(
68
+ FAILED_SESSION_ALERT_MIN_COOLDOWN_MS * 3,
69
+ 3 * 60 * 60 * 1000,
70
+ ); // keep cooldown history bounded
71
+ const ALERT_COOLDOWN_REPLAY_MAX_BYTES = Math.max(
72
+ 256 * 1024,
73
+ Number(process.env.AGENT_ALERT_COOLDOWN_REPLAY_MAX_BYTES || 2 * 1024 * 1024) || 2 * 1024 * 1024,
74
+ );
75
+
76
+ function getAlertCooldownMs(alert) {
77
+ const type = String(alert?.type || "").trim().toLowerCase();
78
+ if (type === "failed_session_high_errors") {
79
+ return Math.max(ALERT_COOLDOWN_MS, FAILED_SESSION_ALERT_MIN_COOLDOWN_MS);
80
+ }
81
+ return Math.max(0, ALERT_COOLDOWN_MS);
82
+ }
83
+
84
+ function extractTaskToken(value) {
85
+ const normalized = String(value || "").trim();
86
+ if (!normalized) return "";
87
+ const prefixMatch = normalized.match(
88
+ /^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})(?:-|$)/i,
89
+ );
90
+ return prefixMatch?.[1] || normalized;
91
+ }
92
+
93
+ function deriveAlertScopeId(alert) {
94
+ const taskId = extractTaskToken(alert?.task_id);
95
+ if (taskId) return taskId;
96
+ return extractTaskToken(alert?.attempt_id);
97
+ }
98
+
99
+ function buildAlertCooldownKey(alert) {
100
+ const type = String(alert?.type || "unknown").trim().toLowerCase() || "unknown";
101
+ const scopeId = deriveAlertScopeId(alert);
102
+ if (scopeId && (type === "failed_session_high_errors" || type === "stuck_agent")) {
103
+ return `${type}:task:${scopeId}`;
104
+ }
105
+ return `${type}:${String(alert?.attempt_id || "unknown")}`;
106
+ }
107
+
108
+ function pruneStaleAlertCooldowns(nowMs = Date.now()) {
109
+ const now = Number(nowMs) || Date.now();
110
+ const cutoff = now - ALERT_COOLDOWN_RETENTION_MS;
111
+ for (const [key, ts] of alertCooldowns.entries()) {
112
+ const lastTs = Number(ts);
113
+ if (!Number.isFinite(lastTs) || lastTs < cutoff) {
114
+ alertCooldowns.delete(key);
115
+ }
116
+ }
117
+ }
118
+
119
+ async function hydrateAlertCooldownsFromLog() {
120
+ if (!existsSync(ALERTS_LOG)) return;
121
+ try {
122
+ const fileStat = await stat(ALERTS_LOG);
123
+ if (!fileStat.size) return;
124
+ const start = Math.max(0, fileStat.size - ALERT_COOLDOWN_REPLAY_MAX_BYTES);
125
+ const stream = createReadStream(ALERTS_LOG, { start, encoding: "utf8" });
126
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
127
+ const maxCooldownMs = Math.max(ALERT_COOLDOWN_MS, FAILED_SESSION_ALERT_MIN_COOLDOWN_MS);
128
+ const cutoff = Date.now() - maxCooldownMs;
129
+ for await (const line of rl) {
130
+ const trimmed = String(line || "").trim();
131
+ if (!trimmed) continue;
132
+ try {
133
+ const entry = JSON.parse(trimmed);
134
+ const ts = Date.parse(String(entry?.timestamp || ""));
135
+ if (!Number.isFinite(ts) || ts < cutoff) continue;
136
+ const cooldownMs = getAlertCooldownMs(entry);
137
+ if (ts < Date.now() - cooldownMs) continue;
138
+ const key = String(entry?._cooldown_key || "").trim() || buildAlertCooldownKey(entry);
139
+ alertCooldowns.set(key, ts);
140
+ } catch {
141
+ // ignore malformed jsonl
142
+ }
143
+ }
144
+ } catch {
145
+ // best-effort hydration only
146
+ }
147
+ }
66
148
 
67
149
  // ── Log Tailing ─────────────────────────────────────────────────────────────
68
150
 
@@ -70,6 +152,14 @@ let filePosition = 0;
70
152
  let isRunning = false;
71
153
  let stuckSweepTimer = null;
72
154
 
155
+ function parseEnvBoolean(value, fallback = false) {
156
+ if (value == null || value === "") return fallback;
157
+ const normalized = String(value).trim().toLowerCase();
158
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
159
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
160
+ return fallback;
161
+ }
162
+
73
163
  /**
74
164
  * Start the analyzer loop
75
165
  */
@@ -88,14 +178,29 @@ export async function startAnalyzer() {
88
178
  if (!existsSync(ALERTS_LOG)) {
89
179
  await writeFile(ALERTS_LOG, "");
90
180
  }
181
+ await hydrateAlertCooldownsFromLog();
91
182
  } catch (err) {
92
183
  console.warn(`[agent-work-analyzer] Failed to init alerts log: ${err.message}`);
93
184
  }
94
185
 
95
- // Initial read of existing log
186
+ // Initial positioning for existing log.
187
+ // Default behavior is true tailing (start at EOF) to avoid replaying stale
188
+ // historical sessions on monitor restart, which can re-emit old alerts and
189
+ // trigger noisy false-positive loops. Operators can opt in to replay for
190
+ // forensics via AGENT_ANALYZER_REPLAY_STARTUP=1.
96
191
  if (existsSync(AGENT_WORK_STREAM)) {
97
- filePosition = await processLogFile(filePosition);
98
- pruneStaleSessionsAfterReplay();
192
+ const replayStartup = parseEnvBoolean(
193
+ process.env.AGENT_ANALYZER_REPLAY_STARTUP,
194
+ false,
195
+ );
196
+ if (replayStartup) {
197
+ filePosition = await processLogFile(filePosition);
198
+ pruneStaleSessionsAfterReplay();
199
+ } else {
200
+ const streamStats = await stat(AGENT_WORK_STREAM);
201
+ filePosition = Math.max(0, Number(streamStats?.size || 0));
202
+ activeSessions.clear();
203
+ }
99
204
  } else {
100
205
  // Ensure the stream file exists so the watcher doesn't throw
101
206
  try {
@@ -154,7 +259,12 @@ export function stopAnalyzer() {
154
259
  async function processLogFile(startPosition) {
155
260
  try {
156
261
  const stats = await stat(AGENT_WORK_STREAM);
157
- if (stats.size <= startPosition) {
262
+ if (stats.size < startPosition) {
263
+ // Log file was truncated/rotated. Reset offset so new entries are not
264
+ // skipped forever after rotation.
265
+ return 0;
266
+ }
267
+ if (stats.size === startPosition) {
158
268
  return startPosition; // No new data
159
269
  }
160
270
 
@@ -163,12 +273,29 @@ async function processLogFile(startPosition) {
163
273
  encoding: "utf8",
164
274
  });
165
275
 
166
- const rl = createInterface({ input: stream });
167
- let bytesRead = startPosition;
276
+ let chunkText = "";
277
+ for await (const chunk of stream) {
278
+ chunkText += String(chunk || "");
279
+ }
280
+ if (!chunkText) {
281
+ return startPosition;
282
+ }
168
283
 
169
- for await (const line of rl) {
170
- bytesRead += Buffer.byteLength(line, "utf8") + 1; // +1 for newline
284
+ const lastNewlineIdx = chunkText.lastIndexOf("\n");
285
+ let processText = "";
286
+ let trailing = "";
287
+ if (lastNewlineIdx >= 0) {
288
+ processText = chunkText.slice(0, lastNewlineIdx + 1);
289
+ trailing = chunkText.slice(lastNewlineIdx + 1);
290
+ } else {
291
+ trailing = chunkText;
292
+ }
171
293
 
294
+ const lines = processText
295
+ .split(/\r?\n/)
296
+ .map((line) => String(line || "").trim())
297
+ .filter(Boolean);
298
+ for (const line of lines) {
172
299
  try {
173
300
  const event = JSON.parse(line);
174
301
  await analyzeEvent(event);
@@ -179,7 +306,21 @@ async function processLogFile(startPosition) {
179
306
  }
180
307
  }
181
308
 
182
- return bytesRead;
309
+ // If trailing text is present without newline, treat it as a potentially
310
+ // partial line and only consume it when it is valid JSON. This avoids data
311
+ // loss when writers flush an incomplete line temporarily.
312
+ const trailingTrimmed = String(trailing || "").trim();
313
+ if (trailingTrimmed) {
314
+ try {
315
+ const trailingEvent = JSON.parse(trailingTrimmed);
316
+ await analyzeEvent(trailingEvent);
317
+ return startPosition + Buffer.byteLength(chunkText, "utf8");
318
+ } catch {
319
+ return startPosition + Buffer.byteLength(processText, "utf8");
320
+ }
321
+ }
322
+
323
+ return startPosition + Buffer.byteLength(processText, "utf8");
183
324
  } catch (err) {
184
325
  if (err.code !== "ENOENT") {
185
326
  console.error(`[agent-work-analyzer] Error reading log: ${err.message}`);
@@ -450,11 +591,12 @@ function startStuckSweep() {
450
591
  * @param {Object} alert - Alert data
451
592
  */
452
593
  async function emitAlert(alert) {
453
- const alertKey = `${alert.type}:${alert.attempt_id}`;
594
+ const alertKey = buildAlertCooldownKey(alert);
595
+ const cooldownMs = getAlertCooldownMs(alert);
454
596
 
455
597
  // Check cooldown
456
598
  const lastAlert = alertCooldowns.get(alertKey);
457
- if (lastAlert && Date.now() - lastAlert < ALERT_COOLDOWN_MS) {
599
+ if (lastAlert && Date.now() - lastAlert < cooldownMs) {
458
600
  return; // Skip duplicate alerts
459
601
  }
460
602
 
@@ -462,6 +604,7 @@ async function emitAlert(alert) {
462
604
 
463
605
  const alertEntry = {
464
606
  timestamp: new Date().toISOString(),
607
+ _cooldown_key: alertKey,
465
608
  ...alert,
466
609
  };
467
610
 
@@ -477,7 +620,7 @@ async function emitAlert(alert) {
477
620
 
478
621
  // ── Cleanup Old Sessions ────────────────────────────────────────────────────
479
622
 
480
- setInterval(() => {
623
+ const cleanupTimer = setInterval(() => {
481
624
  const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour
482
625
 
483
626
  for (const [attemptId, session] of activeSessions.entries()) {
@@ -486,7 +629,8 @@ setInterval(() => {
486
629
  activeSessions.delete(attemptId);
487
630
  }
488
631
  }
632
+ pruneStaleAlertCooldowns();
489
633
  }, 10 * 60 * 1000); // Cleanup every 10 minutes
634
+ cleanupTimer.unref?.();
490
635
 
491
636
  // ── Exports ─────────────────────────────────────────────────────────────────
492
-
package/bosun.schema.json CHANGED
@@ -606,6 +606,37 @@
606
606
  "type": "object",
607
607
  "description": "Hook definitions loaded by agent-hooks.mjs (event -> hook list).",
608
608
  "additionalProperties": true
609
+ },
610
+ "mcpServers": {
611
+ "type": "object",
612
+ "description": "MCP (Model Context Protocol) server management. Controls which MCP servers are available to agents.",
613
+ "additionalProperties": false,
614
+ "properties": {
615
+ "enabled": {
616
+ "type": "boolean",
617
+ "default": true,
618
+ "description": "Enable MCP server integration for agent launches"
619
+ },
620
+ "defaultServers": {
621
+ "type": "array",
622
+ "items": { "type": "string" },
623
+ "description": "MCP server IDs attached to every agent launch by default (e.g. [\"context7\", \"microsoft-docs\"])"
624
+ },
625
+ "catalogOverrides": {
626
+ "type": "object",
627
+ "additionalProperties": {
628
+ "type": "object",
629
+ "description": "Per-server environment variable overrides",
630
+ "additionalProperties": { "type": "string" }
631
+ },
632
+ "description": "Environment variable overrides keyed by MCP server ID"
633
+ },
634
+ "autoInstallDefaults": {
635
+ "type": "boolean",
636
+ "default": true,
637
+ "description": "Automatically install defaultServers from catalog if not already installed"
638
+ }
639
+ }
609
640
  }
610
641
  }
611
642
  }
package/cli.mjs CHANGED
@@ -1484,14 +1484,21 @@ function detectExistingMonitorLockOwner(excludePid = null) {
1484
1484
  return null;
1485
1485
  }
1486
1486
 
1487
- function runMonitor() {
1487
+ function runMonitor({ restartReason = "" } = {}) {
1488
1488
  return new Promise((resolve, reject) => {
1489
1489
  const monitorPath = fileURLToPath(
1490
1490
  new URL("./monitor.mjs", import.meta.url),
1491
1491
  );
1492
+ const childEnv = { ...process.env };
1493
+ if (restartReason) {
1494
+ childEnv.BOSUN_MONITOR_RESTART_REASON = restartReason;
1495
+ } else {
1496
+ delete childEnv.BOSUN_MONITOR_RESTART_REASON;
1497
+ }
1492
1498
  monitorChild = fork(monitorPath, process.argv.slice(2), {
1493
1499
  stdio: "inherit",
1494
1500
  execArgv: ["--max-old-space-size=4096"],
1501
+ env: childEnv,
1495
1502
  windowsHide: IS_DAEMON_CHILD && process.platform === "win32",
1496
1503
  });
1497
1504
  daemonCrashTracker.markStart();
@@ -1504,7 +1511,7 @@ function runMonitor() {
1504
1511
  "\n \u21BB Monitor restarting with fresh modules...\n",
1505
1512
  );
1506
1513
  // Small delay to let file writes / port releases settle
1507
- setTimeout(() => resolve(runMonitor()), 2000);
1514
+ setTimeout(() => resolve(runMonitor({ restartReason: "self-restart" })), 2000);
1508
1515
  } else {
1509
1516
  const exitCode = code ?? (signal ? 1 : 0);
1510
1517
  const existingOwner =
@@ -1572,7 +1579,15 @@ function runMonitor() {
1572
1579
  restartAttempt: daemonRestartCount,
1573
1580
  maxRestarts: IS_DAEMON_CHILD ? DAEMON_MAX_RESTARTS : 0,
1574
1581
  }).catch(() => {});
1575
- setTimeout(() => resolve(runMonitor()), delayMs);
1582
+ setTimeout(
1583
+ () =>
1584
+ resolve(
1585
+ runMonitor({
1586
+ restartReason: isOSKill ? "os-kill" : "crash",
1587
+ }),
1588
+ ),
1589
+ delayMs,
1590
+ );
1576
1591
  return;
1577
1592
  }
1578
1593
 
package/codex-config.mjs CHANGED
@@ -2,12 +2,15 @@
2
2
  * codex-config.mjs — Manages the Codex CLI config (~/.codex/config.toml)
3
3
  *
4
4
  * Ensures the user's Codex CLI configuration has:
5
- * 1. A vibe_kanban MCP server section with the correct env vars
6
- * 2. Sufficient stream_idle_timeout_ms on all model providers
7
- * 3. Recommended defaults for long-running agentic workloads
8
- * 4. Feature flags for sub-agents, memory, undo, collaboration
9
- * 5. Sandbox permissions and shell environment policy
10
- * 6. Common MCP servers (context7, microsoft-docs)
5
+ * 1. Sufficient stream_idle_timeout_ms on all model providers
6
+ * 2. Recommended defaults for long-running agentic workloads
7
+ * 3. Feature flags for sub-agents, memory, undo, collaboration
8
+ * 4. Sandbox permissions and shell environment policy
9
+ * 5. Common MCP servers (context7, microsoft-docs)
10
+ *
11
+ * NOTE: Vibe-Kanban MCP is workspace-scoped and managed by repo-config.mjs
12
+ * inside each repo's `.codex/config.toml`. Global config no longer auto-adds
13
+ * `[mcp_servers.vibe_kanban]`.
11
14
  *
12
15
  * SCOPE: This manages the GLOBAL ~/.codex/config.toml which contains:
13
16
  * - Model provider configs (API keys, base URLs) — MUST be global
@@ -1305,13 +1308,15 @@ export function ensureRetrySettings(toml, providerName) {
1305
1308
  * @param {object} opts
1306
1309
  * @param {string} [opts.vkBaseUrl]
1307
1310
  * @param {boolean} [opts.skipVk]
1311
+ * @param {boolean} [opts.manageVkMcp] Explicit opt-in to manage VK MCP in global config
1308
1312
  * @param {boolean} [opts.dryRun] If true, returns result without writing
1309
1313
  * @param {object} [opts.env] Environment overrides (defaults to process.env)
1310
1314
  * @param {string} [opts.primarySdk] Primary agent SDK: "codex", "copilot", or "claude"
1311
1315
  */
1312
1316
  export function ensureCodexConfig({
1313
1317
  vkBaseUrl = "http://127.0.0.1:54089",
1314
- skipVk = false,
1318
+ skipVk = true,
1319
+ manageVkMcp = false,
1315
1320
  dryRun = false,
1316
1321
  env = process.env,
1317
1322
  primarySdk,
@@ -1424,7 +1429,8 @@ export function ensureCodexConfig({
1424
1429
  result.featuresAdded = featureResult.added;
1425
1430
  toml = featureResult.toml;
1426
1431
 
1427
- if (skipVk) {
1432
+ const shouldManageGlobalVkMcp = Boolean(manageVkMcp) && !skipVk;
1433
+ if (!shouldManageGlobalVkMcp) {
1428
1434
  if (hasVibeKanbanMcp(toml)) {
1429
1435
  toml = removeVibeKanbanMcp(toml);
1430
1436
  result.vkRemoved = true;
@@ -1564,7 +1570,7 @@ export function printConfigSummary(result, log = console.log) {
1564
1570
  }
1565
1571
 
1566
1572
  if (result.vkRemoved) {
1567
- log(" 🗑️ Removed Vibe-Kanban MCP server (VK backend not active)");
1573
+ log(" 🗑️ Removed Vibe-Kanban MCP server from global config (workspace-scoped only)");
1568
1574
  }
1569
1575
 
1570
1576
  if (result.vkEnvUpdated) {
package/copilot-shell.mjs CHANGED
@@ -17,6 +17,15 @@ import { getGitHubToken } from "./github-auth-manager.mjs";
17
17
 
18
18
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
19
19
 
20
+ // Lazy-import MCP registry — cached at module scope per AGENTS.md rules.
21
+ let _mcpRegistry = null;
22
+ async function getMcpRegistry() {
23
+ if (!_mcpRegistry) {
24
+ _mcpRegistry = await import("./mcp-registry.mjs");
25
+ }
26
+ return _mcpRegistry;
27
+ }
28
+
20
29
  // ── Configuration ────────────────────────────────────────────────────────────
21
30
 
22
31
  const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min for agentic tasks
@@ -174,7 +183,7 @@ function logSessionEvent(logPath, event) {
174
183
  * Enables experimental features (fleet, autopilot), auto-permissions,
175
184
  * sub-agents, and autonomy.
176
185
  */
177
- function buildCliArgs() {
186
+ async function buildCliArgs() {
178
187
  const args = [];
179
188
 
180
189
  // Always enable experimental features (fleet, autopilot, persisted permissions, etc.)
@@ -218,6 +227,26 @@ function buildCliArgs() {
218
227
  args.push("--additional-mcp-config", mcpConfigPath);
219
228
  }
220
229
 
230
+ // Also write a temp MCP config from the library if installed servers exist
231
+ // (non-fatal: library MCP is a convenience, not a hard requirement)
232
+ if (!mcpConfigPath) {
233
+ try {
234
+ const registry = await getMcpRegistry();
235
+ const installed = await registry.listInstalledMcpServers(REPO_ROOT);
236
+ if (installed && installed.length) {
237
+ const ids = installed.map((e) => e.id);
238
+ const resolved = await registry.resolveMcpServersForAgent(REPO_ROOT, ids);
239
+ if (resolved && resolved.length) {
240
+ const tmpPath = registry.writeTempCopilotMcpConfig(REPO_ROOT, resolved);
241
+ args.push("--additional-mcp-config", tmpPath);
242
+ console.log(`[copilot-shell] injected ${resolved.length} library MCP server(s) via CLI args`);
243
+ }
244
+ }
245
+ } catch (err) {
246
+ console.warn(`[copilot-shell] failed to inject library MCP servers into CLI args: ${err.message}`);
247
+ }
248
+ }
249
+
221
250
  if (args.length > 0) {
222
251
  console.log(`[copilot-shell] cliArgs: ${args.join(" ")}`);
223
252
  }
@@ -399,7 +428,7 @@ async function ensureClientStarted() {
399
428
  const sessionMode = (process.env.COPILOT_SESSION_MODE || "local").trim().toLowerCase();
400
429
 
401
430
  // Build cliArgs for experimental features, permissions, and autonomy
402
- const cliArgs = buildCliArgs();
431
+ const cliArgs = await buildCliArgs();
403
432
 
404
433
  let clientOptions;
405
434
  if (transport === "url") {
@@ -553,7 +582,46 @@ function loadMcpServers(profile = null) {
553
582
  return loadMcpServersFromFile(configPath);
554
583
  }
555
584
 
556
- function buildSessionConfig() {
585
+ /**
586
+ * Merge installed MCP library servers into an existing mcpServers map.
587
+ * Called during session build to inject library-managed MCP servers into
588
+ * the Copilot SDK session alongside any profile/env servers.
589
+ *
590
+ * Non-fatal: if the registry is unavailable or encounters errors, the
591
+ * original servers map is returned unchanged.
592
+ *
593
+ * @param {Object|null} existingServers — mcpServers from profile/env/config
594
+ * @returns {Promise<Object|null>} — merged servers map
595
+ */
596
+ async function mergeLibraryMcpServers(existingServers) {
597
+ try {
598
+ const registry = await getMcpRegistry();
599
+ const installed = await registry.listInstalledMcpServers(REPO_ROOT);
600
+ if (!installed || !installed.length) return existingServers;
601
+
602
+ // Resolve all installed servers into full configs
603
+ const installedIds = installed.map((e) => e.id);
604
+ const resolved = await registry.resolveMcpServersForAgent(REPO_ROOT, installedIds);
605
+ if (!resolved || !resolved.length) return existingServers;
606
+
607
+ // Convert to Copilot mcpServers format: { [id]: { command, args, env? } | { url } }
608
+ const copilotJson = registry.buildCopilotMcpJson(resolved);
609
+ const libraryServers = copilotJson?.mcpServers || {};
610
+ if (!Object.keys(libraryServers).length) return existingServers;
611
+
612
+ // Merge: existing servers take precedence over library ones (user overrides win)
613
+ const merged = { ...libraryServers, ...(existingServers || {}) };
614
+ console.log(
615
+ `[copilot-shell] Merged ${Object.keys(libraryServers).length} library MCP server(s) into session`,
616
+ );
617
+ return merged;
618
+ } catch (err) {
619
+ console.warn(`[copilot-shell] Failed to merge library MCP servers: ${err.message}`);
620
+ return existingServers;
621
+ }
622
+ }
623
+
624
+ async function buildSessionConfig() {
557
625
  const profile = resolveCopilotProfile();
558
626
  const config = {
559
627
  streaming: true,
@@ -588,7 +656,9 @@ function buildSessionConfig() {
588
656
  config.reasoningEffort = effort.toLowerCase();
589
657
  }
590
658
 
591
- const mcpServers = loadMcpServers(profile);
659
+ // Load MCP servers from profile/env/config, then merge library-managed servers
660
+ const baseServers = loadMcpServers(profile);
661
+ const mcpServers = await mergeLibraryMcpServers(baseServers);
592
662
  if (mcpServers) config.mcpServers = mcpServers;
593
663
  return config;
594
664
  }
@@ -600,7 +670,7 @@ async function getSession() {
600
670
  const started = await ensureClientStarted();
601
671
  if (!started) throw new Error("Copilot SDK not available");
602
672
 
603
- const config = buildSessionConfig();
673
+ const config = await buildSessionConfig();
604
674
 
605
675
  if (activeSessionId && typeof copilotClient?.resumeSession === "function") {
606
676
  try {