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.
- package/agent-hook-bridge.mjs +6 -6
- package/agent-pool.mjs +75 -5
- package/agent-work-analyzer.mjs +157 -13
- package/bosun.schema.json +31 -0
- package/cli.mjs +18 -3
- package/codex-config.mjs +15 -9
- package/copilot-shell.mjs +75 -5
- package/desktop/main.mjs +3 -0
- package/git-commit-helpers.mjs +31 -3
- package/lib/logger.mjs +65 -1
- package/library-manager.mjs +6 -4
- package/maintenance.mjs +2 -3
- package/monitor.mjs +886 -63
- package/package.json +2 -1
- package/postinstall.mjs +8 -5
- package/primary-agent.mjs +16 -1
- package/repo-config.mjs +72 -4
- package/setup-web-server.mjs +308 -51
- package/setup.mjs +93 -68
- package/task-context.mjs +200 -0
- package/task-executor.mjs +50 -16
- package/telegram-bot.mjs +13 -4
- package/ui/app.js +1 -1
- package/ui/components/agent-selector.js +6 -1
- package/ui/components/chat-view.js +2 -2
- package/ui/components/diff-viewer.js +1 -1
- package/ui/components/workspace-switcher.js +4 -4
- package/ui/modules/agent-display.js +5 -5
- package/ui/styles/base.css +1 -1
- package/ui/styles/workspace-switcher.css +10 -0
- package/ui/tabs/agents.js +6 -6
- package/ui/tabs/chat.js +33 -33
- package/ui/tabs/control.js +13 -1
- package/ui/tabs/dashboard.js +10 -10
- package/ui/tabs/library.js +18 -16
- package/ui/tabs/settings.js +183 -24
- package/ui/tabs/tasks.js +8 -8
- package/ui/tabs/workflows.js +3 -2
- package/ui-server.mjs +233 -16
- package/workflow-nodes.mjs +500 -13
- package/workspace-manager.mjs +8 -1
package/agent-hook-bridge.mjs
CHANGED
|
@@ -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
|
-
// ──
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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()
|
package/agent-work-analyzer.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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 =
|
|
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 <
|
|
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(
|
|
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.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
10
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|