bosun 0.34.8 → 0.35.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.
- package/.env.example +10 -0
- package/README.md +1 -0
- package/agent-pool.mjs +270 -44
- package/anomaly-detector.mjs +13 -1
- package/cli.mjs +181 -116
- package/codex-config.mjs +56 -2
- package/config.mjs +77 -2
- package/desktop/main.mjs +6 -3
- package/kanban-adapter.mjs +6 -2
- package/maintenance.mjs +162 -18
- package/monitor.mjs +204 -41
- package/package.json +1 -1
- package/repo-config.mjs +9 -0
- package/repo-root.mjs +41 -0
- package/setup-web-server.mjs +222 -2
- package/setup.mjs +4 -0
- package/task-executor.mjs +253 -85
- package/task-store.mjs +58 -1
- package/telegram-bot.mjs +72 -1
- package/ui/app.js +44 -29
- package/ui/components/chat-view.js +101 -10
- package/ui/components/forms.js +6 -0
- package/ui/components/shared.js +49 -0
- package/ui/demo.html +13 -0
- package/ui/modules/router.js +157 -6
- package/ui/modules/state.js +31 -0
- package/ui/setup.html +1340 -112
- package/ui/styles/components.css +29 -0
- package/ui/styles/layout.css +19 -15
- package/ui/styles/sessions.css +58 -7
- package/ui/styles.css +160 -16
- package/ui/tabs/agents.js +227 -69
- package/ui/tabs/chat.js +16 -0
- package/ui/tabs/dashboard.js +34 -9
- package/ui/tabs/library.js +36 -6
- package/ui/tabs/settings.js +9 -1
- package/ui/tabs/tasks.js +245 -10
- package/ui/tabs/workflows.js +124 -5
- package/ui-server.mjs +206 -49
- package/update-check.mjs +54 -0
- package/workflow-engine.mjs +75 -11
- package/workflow-migration.mjs +1 -0
- package/workflow-nodes.mjs +537 -19
- package/workflow-templates/agents.mjs +32 -4
- package/workflow-templates/github.mjs +102 -36
- package/workflow-templates/reliability.mjs +131 -3
- package/workflow-templates.mjs +187 -10
package/.env.example
CHANGED
|
@@ -83,6 +83,16 @@ TELEGRAM_MINIAPP_ENABLED=false
|
|
|
83
83
|
# Public hostname override. By default the server auto-detects your LAN IP.
|
|
84
84
|
# Set this when using a tunnel (ngrok, Cloudflare) or a public domain.
|
|
85
85
|
# TELEGRAM_UI_PUBLIC_HOST=your-lan-ip-or-domain
|
|
86
|
+
# Browser URL handling:
|
|
87
|
+
# manual (default) = never auto-open browser windows/tabs
|
|
88
|
+
# auto = permit auto-open when BOSUN_UI_AUTO_OPEN_BROWSER=true
|
|
89
|
+
# BOSUN_UI_BROWSER_OPEN_MODE=manual
|
|
90
|
+
# Legacy auto-open toggle for UI server (requires BOSUN_UI_BROWSER_OPEN_MODE=auto)
|
|
91
|
+
# BOSUN_UI_AUTO_OPEN_BROWSER=false
|
|
92
|
+
# Show full /?token=... browser URL in logs (default: false; token is hidden)
|
|
93
|
+
# BOSUN_UI_LOG_TOKENIZED_BROWSER_URL=false
|
|
94
|
+
# Setup wizard browser auto-open (default: true when mode=auto)
|
|
95
|
+
# BOSUN_SETUP_AUTO_OPEN_BROWSER=true
|
|
86
96
|
# Full public URL override (takes precedence over host/port auto-detection).
|
|
87
97
|
# Use when you have a reverse proxy or tunnel with HTTPS.
|
|
88
98
|
# TELEGRAM_UI_BASE_URL=https://your-public-ui.example.com
|
package/README.md
CHANGED
|
@@ -48,6 +48,7 @@ Requires:
|
|
|
48
48
|
|
|
49
49
|
- Routes work across Codex, Copilot, and Claude executors
|
|
50
50
|
- Automates retries, failover, and PR lifecycle management
|
|
51
|
+
- Auto-labels attached PRs with `bosun-needs-fix` when CI fails (`Build + Tests`)
|
|
51
52
|
- Monitors runs and recovers from stalled or broken states
|
|
52
53
|
- Provides Telegram control and a Mini App dashboard
|
|
53
54
|
- Integrates with GitHub, Jira, and Vibe-Kanban boards
|
package/agent-pool.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import { fileURLToPath } from "node:url";
|
|
|
44
44
|
import { loadConfig } from "./config.mjs";
|
|
45
45
|
import { resolveRepoRoot, resolveAgentRepoRoot } from "./repo-root.mjs";
|
|
46
46
|
import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
|
|
47
|
+
import { getGitHubToken } from "./github-auth-manager.mjs";
|
|
47
48
|
|
|
48
49
|
// Lazy-load MCP registry to avoid circular dependencies.
|
|
49
50
|
// Cached at module scope per AGENTS.md hard rules.
|
|
@@ -86,6 +87,36 @@ const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
|
|
|
86
87
|
/** Tag for console logging */
|
|
87
88
|
const TAG = "[agent-pool]";
|
|
88
89
|
const MAX_PROMPT_BYTES = 180_000;
|
|
90
|
+
const MAX_SET_TIMEOUT_MS = 2_147_483_647; // Node.js setTimeout 32-bit signed max
|
|
91
|
+
let timeoutClampWarningKey = "";
|
|
92
|
+
const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 120_000;
|
|
93
|
+
|
|
94
|
+
function clampTimerDelayMs(delayMs, label = "timer") {
|
|
95
|
+
const parsed = Number(delayMs);
|
|
96
|
+
if (!Number.isFinite(parsed) || parsed < 1) return 1;
|
|
97
|
+
const clamped = Math.min(Math.trunc(parsed), MAX_SET_TIMEOUT_MS);
|
|
98
|
+
if (clamped !== Math.trunc(parsed)) {
|
|
99
|
+
const warningKey = `${label}:${parsed}`;
|
|
100
|
+
if (timeoutClampWarningKey !== warningKey) {
|
|
101
|
+
timeoutClampWarningKey = warningKey;
|
|
102
|
+
console.warn(
|
|
103
|
+
`${TAG} ${label} delay ${parsed}ms exceeds Node timer max; clamped to ${MAX_SET_TIMEOUT_MS}ms`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return clamped;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getFirstEventTimeoutMs(totalTimeoutMs) {
|
|
111
|
+
const configured = Number(
|
|
112
|
+
process.env.AGENT_POOL_FIRST_EVENT_TIMEOUT_MS || DEFAULT_FIRST_EVENT_TIMEOUT_MS,
|
|
113
|
+
);
|
|
114
|
+
if (!Number.isFinite(configured) || configured <= 0) return null;
|
|
115
|
+
const budgetMs = Number(totalTimeoutMs);
|
|
116
|
+
if (!Number.isFinite(budgetMs) || budgetMs <= 2_000) return null;
|
|
117
|
+
const maxAllowed = Math.max(5_000, budgetMs - 1_000);
|
|
118
|
+
return clampTimerDelayMs(Math.min(Math.trunc(configured), maxAllowed), "first-event-timeout");
|
|
119
|
+
}
|
|
89
120
|
|
|
90
121
|
function sanitizeAndBoundPrompt(text) {
|
|
91
122
|
if (typeof text !== "string") return "";
|
|
@@ -112,6 +143,82 @@ function envFlagEnabled(value) {
|
|
|
112
143
|
return ["1", "true", "yes", "on", "y"].includes(raw);
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
const GITHUB_TOKEN_CACHE_TTL_MS = 60_000;
|
|
147
|
+
let cachedGithubSessionToken = null;
|
|
148
|
+
let cachedGithubSessionTokenAt = 0;
|
|
149
|
+
let githubSessionTokenPromise = null;
|
|
150
|
+
let githubTokenSourceLogged = "";
|
|
151
|
+
|
|
152
|
+
function parseGithubRepoSlug(raw) {
|
|
153
|
+
const text = String(raw || "").trim();
|
|
154
|
+
if (!text) return { owner: "", repo: "" };
|
|
155
|
+
const m = text.match(/^([^/\s]+)\/([^/\s]+)$/);
|
|
156
|
+
if (!m) return { owner: "", repo: "" };
|
|
157
|
+
return { owner: m[1], repo: m[2] };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function resolveGithubSessionToken() {
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
if (
|
|
163
|
+
cachedGithubSessionToken &&
|
|
164
|
+
now - cachedGithubSessionTokenAt < GITHUB_TOKEN_CACHE_TTL_MS
|
|
165
|
+
) {
|
|
166
|
+
return cachedGithubSessionToken;
|
|
167
|
+
}
|
|
168
|
+
if (githubSessionTokenPromise) return githubSessionTokenPromise;
|
|
169
|
+
|
|
170
|
+
githubSessionTokenPromise = (async () => {
|
|
171
|
+
const explicitToken =
|
|
172
|
+
process.env.GH_TOKEN ||
|
|
173
|
+
process.env.GITHUB_TOKEN ||
|
|
174
|
+
process.env.COPILOT_CLI_TOKEN ||
|
|
175
|
+
process.env.GITHUB_PAT ||
|
|
176
|
+
"";
|
|
177
|
+
if (explicitToken) {
|
|
178
|
+
cachedGithubSessionToken = explicitToken;
|
|
179
|
+
cachedGithubSessionTokenAt = Date.now();
|
|
180
|
+
return explicitToken;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const { owner, repo } = parseGithubRepoSlug(process.env.GITHUB_REPOSITORY);
|
|
185
|
+
const { token, type } = await getGitHubToken({
|
|
186
|
+
owner: owner || undefined,
|
|
187
|
+
repo: repo || undefined,
|
|
188
|
+
});
|
|
189
|
+
if (token) {
|
|
190
|
+
cachedGithubSessionToken = token;
|
|
191
|
+
cachedGithubSessionTokenAt = Date.now();
|
|
192
|
+
if (githubTokenSourceLogged !== type) {
|
|
193
|
+
githubTokenSourceLogged = type || "unknown";
|
|
194
|
+
console.log(`${TAG} injected GitHub token into agent session env (${type || "unknown"})`);
|
|
195
|
+
}
|
|
196
|
+
return token;
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// best effort
|
|
200
|
+
}
|
|
201
|
+
return "";
|
|
202
|
+
})();
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
return await githubSessionTokenPromise;
|
|
206
|
+
} finally {
|
|
207
|
+
githubSessionTokenPromise = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function injectGitHubSessionEnv(baseEnv, token) {
|
|
212
|
+
const env = { ...(baseEnv || {}) };
|
|
213
|
+
const resolved = String(token || "").trim();
|
|
214
|
+
if (!resolved) return env;
|
|
215
|
+
if (!env.GH_TOKEN) env.GH_TOKEN = resolved;
|
|
216
|
+
if (!env.GITHUB_TOKEN) env.GITHUB_TOKEN = resolved;
|
|
217
|
+
if (!env.GITHUB_PAT) env.GITHUB_PAT = resolved;
|
|
218
|
+
if (!env.COPILOT_CLI_TOKEN) env.COPILOT_CLI_TOKEN = resolved;
|
|
219
|
+
return env;
|
|
220
|
+
}
|
|
221
|
+
|
|
115
222
|
/**
|
|
116
223
|
* Extract a human-readable task heading from the prompt built by _buildTaskPrompt.
|
|
117
224
|
* The first line is "# TASKID — Task Title"; we return the title portion only.
|
|
@@ -172,7 +279,7 @@ function createScopedAbortController(externalAC, timeoutMs) {
|
|
|
172
279
|
if (!controller.signal.aborted) {
|
|
173
280
|
controller.abort("timeout");
|
|
174
281
|
}
|
|
175
|
-
}, timeoutMs);
|
|
282
|
+
}, clampTimerDelayMs(timeoutMs, "abort-timeout"));
|
|
176
283
|
if (timeoutHandle && typeof timeoutHandle.unref === "function") {
|
|
177
284
|
timeoutHandle.unref();
|
|
178
285
|
}
|
|
@@ -253,7 +360,7 @@ function shouldFallbackForSdkError(error) {
|
|
|
253
360
|
* @param {string} name SDK canonical name
|
|
254
361
|
* @returns {{ ok: boolean, reason: string|null }}
|
|
255
362
|
*/
|
|
256
|
-
function hasSdkPrerequisites(name) {
|
|
363
|
+
function hasSdkPrerequisites(name, runtimeEnv = process.env) {
|
|
257
364
|
// Test mocks bypass prerequisite checks
|
|
258
365
|
if (process.env[`__MOCK_${name.toUpperCase()}_AVAILABLE`] === "1") {
|
|
259
366
|
return { ok: true, reason: null };
|
|
@@ -262,10 +369,10 @@ function hasSdkPrerequisites(name) {
|
|
|
262
369
|
if (name === "codex") {
|
|
263
370
|
// Codex needs an OpenAI API key (or Azure key, or profile-specific key)
|
|
264
371
|
const hasKey =
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
372
|
+
runtimeEnv.OPENAI_API_KEY ||
|
|
373
|
+
runtimeEnv.AZURE_OPENAI_API_KEY ||
|
|
374
|
+
runtimeEnv.CODEX_MODEL_PROFILE_XL_API_KEY ||
|
|
375
|
+
runtimeEnv.CODEX_MODEL_PROFILE_M_API_KEY;
|
|
269
376
|
if (!hasKey) {
|
|
270
377
|
return { ok: false, reason: "no API key (OPENAI_API_KEY / AZURE_OPENAI_API_KEY)" };
|
|
271
378
|
}
|
|
@@ -277,7 +384,7 @@ function hasSdkPrerequisites(name) {
|
|
|
277
384
|
return { ok: true, reason: null };
|
|
278
385
|
}
|
|
279
386
|
if (name === "claude") {
|
|
280
|
-
const hasKey =
|
|
387
|
+
const hasKey = runtimeEnv.ANTHROPIC_API_KEY;
|
|
281
388
|
if (!hasKey) {
|
|
282
389
|
return { ok: false, reason: "no ANTHROPIC_API_KEY" };
|
|
283
390
|
}
|
|
@@ -337,6 +444,36 @@ async function withSanitizedOpenAiEnv(fn) {
|
|
|
337
444
|
}
|
|
338
445
|
}
|
|
339
446
|
|
|
447
|
+
async function withTemporaryEnv(overrides, fn) {
|
|
448
|
+
if (!overrides || typeof overrides !== "object") {
|
|
449
|
+
return await fn();
|
|
450
|
+
}
|
|
451
|
+
const prev = new Map();
|
|
452
|
+
const touched = [];
|
|
453
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
454
|
+
if (!key) continue;
|
|
455
|
+
prev.set(key, process.env[key]);
|
|
456
|
+
touched.push(key);
|
|
457
|
+
if (value == null) {
|
|
458
|
+
delete process.env[key];
|
|
459
|
+
} else {
|
|
460
|
+
process.env[key] = String(value);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
try {
|
|
464
|
+
return await fn();
|
|
465
|
+
} finally {
|
|
466
|
+
for (const key of touched) {
|
|
467
|
+
const oldValue = prev.get(key);
|
|
468
|
+
if (oldValue == null) {
|
|
469
|
+
delete process.env[key];
|
|
470
|
+
} else {
|
|
471
|
+
process.env[key] = oldValue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
340
477
|
/**
|
|
341
478
|
* Build Codex SDK constructor options with Azure auto-detection.
|
|
342
479
|
* When OPENAI_BASE_URL points to Azure, configures the SDK with Azure
|
|
@@ -706,6 +843,8 @@ export function getAvailableSdks() {
|
|
|
706
843
|
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
707
844
|
*/
|
|
708
845
|
async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
846
|
+
// Coerce to number — prevents string concatenation in setTimeout arithmetic
|
|
847
|
+
timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
709
848
|
const {
|
|
710
849
|
onEvent,
|
|
711
850
|
abortController: externalAC,
|
|
@@ -816,6 +955,9 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
816
955
|
// Hard timeout: safety net if the SDK's async iterator ignores AbortSignal.
|
|
817
956
|
// Fires HARD_TIMEOUT_BUFFER_MS after the soft timeout to forcibly break the loop.
|
|
818
957
|
let hardTimer;
|
|
958
|
+
let firstEventTimer = null;
|
|
959
|
+
let firstEventTimeoutHit = false;
|
|
960
|
+
let eventCount = 0;
|
|
819
961
|
|
|
820
962
|
// ── 4. Stream the turn ───────────────────────────────────────────────────
|
|
821
963
|
try {
|
|
@@ -826,19 +968,23 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
826
968
|
|
|
827
969
|
let finalResponse = "";
|
|
828
970
|
const allItems = [];
|
|
829
|
-
|
|
830
971
|
// Race the event iterator against a hard timeout.
|
|
831
972
|
// The soft timeout fires controller.abort() which the SDK should honor.
|
|
832
973
|
// The hard timeout is a safety net in case the SDK iterator ignores the abort.
|
|
833
974
|
const hardTimeoutPromise = new Promise((_, reject) => {
|
|
834
975
|
hardTimer = setTimeout(
|
|
835
976
|
() => reject(new Error("hard_timeout")),
|
|
836
|
-
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
977
|
+
clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "codex-hard-timeout"),
|
|
837
978
|
);
|
|
838
979
|
});
|
|
839
980
|
|
|
840
981
|
const iterateEvents = async () => {
|
|
841
982
|
for await (const event of turn.events) {
|
|
983
|
+
eventCount += 1;
|
|
984
|
+
if (firstEventTimer) {
|
|
985
|
+
clearTimeout(firstEventTimer);
|
|
986
|
+
firstEventTimer = null;
|
|
987
|
+
}
|
|
842
988
|
if (controller.signal.aborted) break;
|
|
843
989
|
if (event?.type === "thread.started" && event?.thread_id) {
|
|
844
990
|
emitThreadReady(event.thread_id);
|
|
@@ -859,8 +1005,19 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
859
1005
|
}
|
|
860
1006
|
};
|
|
861
1007
|
|
|
1008
|
+
const firstEventTimeoutMs = getFirstEventTimeoutMs(timeoutMs);
|
|
1009
|
+
if (firstEventTimeoutMs) {
|
|
1010
|
+
firstEventTimer = setTimeout(() => {
|
|
1011
|
+
if (eventCount > 0 || controller.signal.aborted) return;
|
|
1012
|
+
firstEventTimeoutHit = true;
|
|
1013
|
+
controller.abort("first_event_timeout");
|
|
1014
|
+
}, firstEventTimeoutMs);
|
|
1015
|
+
if (typeof firstEventTimer.unref === "function") firstEventTimer.unref();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
862
1018
|
await Promise.race([iterateEvents(), hardTimeoutPromise]);
|
|
863
1019
|
clearTimeout(hardTimer);
|
|
1020
|
+
if (firstEventTimer) clearTimeout(firstEventTimer);
|
|
864
1021
|
clearAbortScope();
|
|
865
1022
|
|
|
866
1023
|
const output =
|
|
@@ -877,17 +1034,21 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
877
1034
|
} catch (err) {
|
|
878
1035
|
clearAbortScope();
|
|
879
1036
|
if (hardTimer) clearTimeout(hardTimer);
|
|
1037
|
+
if (firstEventTimer) clearTimeout(firstEventTimer);
|
|
880
1038
|
if (steerKey) unregisterActiveSession(steerKey);
|
|
881
1039
|
const isTimeout =
|
|
882
1040
|
err.name === "AbortError" ||
|
|
883
1041
|
String(err) === "timeout" ||
|
|
884
1042
|
err.message === "hard_timeout";
|
|
885
1043
|
if (isTimeout) {
|
|
1044
|
+
const firstEventSuffix = firstEventTimeoutHit
|
|
1045
|
+
? ` (no events received within ${getFirstEventTimeoutMs(timeoutMs)}ms)`
|
|
1046
|
+
: "";
|
|
886
1047
|
return {
|
|
887
1048
|
success: false,
|
|
888
1049
|
output: "",
|
|
889
1050
|
items: [],
|
|
890
|
-
error: `${TAG} codex timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout — SDK iterator unresponsive)" : ""}`,
|
|
1051
|
+
error: `${TAG} codex timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout — SDK iterator unresponsive)" : ""}${firstEventSuffix}`,
|
|
891
1052
|
sdk: "codex",
|
|
892
1053
|
threadId: null,
|
|
893
1054
|
};
|
|
@@ -971,6 +1132,8 @@ function autoRespondToUserInput(request) {
|
|
|
971
1132
|
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
972
1133
|
*/
|
|
973
1134
|
async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
1135
|
+
// Coerce to number — prevents string concatenation in setTimeout arithmetic
|
|
1136
|
+
timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
974
1137
|
const {
|
|
975
1138
|
onEvent,
|
|
976
1139
|
abortController: externalAC,
|
|
@@ -978,6 +1141,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
978
1141
|
onThreadReady = null,
|
|
979
1142
|
model: requestedModel = null,
|
|
980
1143
|
taskKey: steerKey = null,
|
|
1144
|
+
envOverrides = null,
|
|
981
1145
|
} = extra;
|
|
982
1146
|
|
|
983
1147
|
// ── 1. Load the SDK ──────────────────────────────────────────────────────
|
|
@@ -998,11 +1162,15 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
998
1162
|
}
|
|
999
1163
|
|
|
1000
1164
|
// ── 2. Detect auth token ─────────────────────────────────────────────────
|
|
1165
|
+
const runtimeEnv =
|
|
1166
|
+
envOverrides && typeof envOverrides === "object"
|
|
1167
|
+
? { ...process.env, ...envOverrides }
|
|
1168
|
+
: process.env;
|
|
1001
1169
|
const token =
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1170
|
+
runtimeEnv.COPILOT_CLI_TOKEN ||
|
|
1171
|
+
runtimeEnv.GITHUB_TOKEN ||
|
|
1172
|
+
runtimeEnv.GH_TOKEN ||
|
|
1173
|
+
runtimeEnv.GITHUB_PAT ||
|
|
1006
1174
|
undefined;
|
|
1007
1175
|
|
|
1008
1176
|
// ── 3. Create & start ephemeral client (LOCAL mode) ──────────────────────
|
|
@@ -1023,10 +1191,10 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1023
1191
|
const autoApprovePermissions = shouldAutoApproveCopilotPermissions();
|
|
1024
1192
|
const clientEnv = autoApprovePermissions
|
|
1025
1193
|
? {
|
|
1026
|
-
...
|
|
1027
|
-
COPILOT_ALLOW_ALL:
|
|
1194
|
+
...runtimeEnv,
|
|
1195
|
+
COPILOT_ALLOW_ALL: runtimeEnv.COPILOT_ALLOW_ALL || "true",
|
|
1028
1196
|
}
|
|
1029
|
-
:
|
|
1197
|
+
: runtimeEnv;
|
|
1030
1198
|
try {
|
|
1031
1199
|
await withSanitizedOpenAiEnv(async () => {
|
|
1032
1200
|
let clientOpts;
|
|
@@ -1236,7 +1404,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1236
1404
|
// the run to continue rather than stalling for the full hard timeout.
|
|
1237
1405
|
if (finalResponse.trim()) return finish(resolveP);
|
|
1238
1406
|
finish(() => rejectP(new Error("timeout_waiting_for_idle")));
|
|
1239
|
-
}, timeoutMs + 1000);
|
|
1407
|
+
}, clampTimerDelayMs(timeoutMs + 1000, "copilot-idle-timeout"));
|
|
1240
1408
|
if (idleTimer && typeof idleTimer.unref === "function") {
|
|
1241
1409
|
idleTimer.unref();
|
|
1242
1410
|
}
|
|
@@ -1247,7 +1415,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1247
1415
|
const copilotHardTimeout = new Promise((_, reject) => {
|
|
1248
1416
|
const ht = setTimeout(
|
|
1249
1417
|
() => reject(new Error("hard_timeout")),
|
|
1250
|
-
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
1418
|
+
clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "copilot-hard-timeout"),
|
|
1251
1419
|
);
|
|
1252
1420
|
// Don't let this timer keep the process alive
|
|
1253
1421
|
if (ht && typeof ht.unref === "function") ht.unref();
|
|
@@ -1367,6 +1535,8 @@ async function resumeCopilotThread(
|
|
|
1367
1535
|
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
|
|
1368
1536
|
*/
|
|
1369
1537
|
async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
1538
|
+
// Coerce to number — prevents string concatenation in setTimeout arithmetic
|
|
1539
|
+
timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
1370
1540
|
const {
|
|
1371
1541
|
onEvent,
|
|
1372
1542
|
abortController: externalAC,
|
|
@@ -1376,6 +1546,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1376
1546
|
onThreadReady = null,
|
|
1377
1547
|
model: requestedModel = null,
|
|
1378
1548
|
taskKey: steerKey = null,
|
|
1549
|
+
envOverrides = null,
|
|
1379
1550
|
} = extra;
|
|
1380
1551
|
|
|
1381
1552
|
// ── 1. Load the SDK ──────────────────────────────────────────────────────
|
|
@@ -1396,10 +1567,14 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1396
1567
|
}
|
|
1397
1568
|
|
|
1398
1569
|
// ── 2. Detect auth ──────────────────────────────────────────────────────
|
|
1570
|
+
const runtimeEnv =
|
|
1571
|
+
envOverrides && typeof envOverrides === "object"
|
|
1572
|
+
? { ...process.env, ...envOverrides }
|
|
1573
|
+
: process.env;
|
|
1399
1574
|
const apiKey =
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1575
|
+
runtimeEnv.ANTHROPIC_API_KEY ||
|
|
1576
|
+
runtimeEnv.CLAUDE_API_KEY ||
|
|
1577
|
+
runtimeEnv.CLAUDE_KEY ||
|
|
1403
1578
|
undefined;
|
|
1404
1579
|
|
|
1405
1580
|
// ── 3. Build message queue ───────────────────────────────────────────────
|
|
@@ -1539,31 +1714,33 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1539
1714
|
settingSources: ["user", "project"],
|
|
1540
1715
|
permissionMode:
|
|
1541
1716
|
claudePermissionMode ||
|
|
1542
|
-
|
|
1717
|
+
runtimeEnv.CLAUDE_PERMISSION_MODE ||
|
|
1543
1718
|
"bypassPermissions",
|
|
1544
1719
|
};
|
|
1545
1720
|
if (apiKey) options.apiKey = apiKey;
|
|
1546
1721
|
const explicitAllowedTools = normalizeList(claudeAllowedTools);
|
|
1547
1722
|
const allowedTools = explicitAllowedTools.length
|
|
1548
1723
|
? explicitAllowedTools
|
|
1549
|
-
: normalizeList(
|
|
1724
|
+
: normalizeList(runtimeEnv.CLAUDE_ALLOWED_TOOLS);
|
|
1550
1725
|
if (allowedTools.length) {
|
|
1551
1726
|
options.allowedTools = allowedTools;
|
|
1552
1727
|
}
|
|
1553
1728
|
|
|
1554
1729
|
const model = String(
|
|
1555
1730
|
requestedModel ||
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1731
|
+
runtimeEnv.CLAUDE_MODEL ||
|
|
1732
|
+
runtimeEnv.CLAUDE_CODE_MODEL ||
|
|
1733
|
+
runtimeEnv.ANTHROPIC_MODEL ||
|
|
1559
1734
|
"",
|
|
1560
1735
|
).trim();
|
|
1561
1736
|
if (model) options.model = model;
|
|
1562
1737
|
|
|
1563
|
-
const result =
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1738
|
+
const result = await withTemporaryEnv(runtimeEnv, async () =>
|
|
1739
|
+
queryFn({
|
|
1740
|
+
prompt: msgQueue.iterator(),
|
|
1741
|
+
options,
|
|
1742
|
+
}),
|
|
1743
|
+
);
|
|
1567
1744
|
|
|
1568
1745
|
let finalResponse = "";
|
|
1569
1746
|
let activeClaudeSessionId = resumeThreadId || null;
|
|
@@ -1623,7 +1800,10 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1623
1800
|
})();
|
|
1624
1801
|
|
|
1625
1802
|
const hardTimeout = new Promise((_, reject) => {
|
|
1626
|
-
hardTimer = setTimeout(
|
|
1803
|
+
hardTimer = setTimeout(
|
|
1804
|
+
() => reject(new Error("hard-timeout")),
|
|
1805
|
+
clampTimerDelayMs(hardTimeoutMs, "claude-hard-timeout"),
|
|
1806
|
+
);
|
|
1627
1807
|
if (hardTimer && typeof hardTimer.unref === "function") {
|
|
1628
1808
|
hardTimer.unref();
|
|
1629
1809
|
}
|
|
@@ -1758,13 +1938,24 @@ export async function launchEphemeralThread(
|
|
|
1758
1938
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
1759
1939
|
extra = {},
|
|
1760
1940
|
) {
|
|
1941
|
+
const resolvedGithubToken = await resolveGithubSessionToken();
|
|
1942
|
+
const baseRuntimeEnv =
|
|
1943
|
+
extra?.envOverrides && typeof extra.envOverrides === "object"
|
|
1944
|
+
? { ...process.env, ...extra.envOverrides }
|
|
1945
|
+
: { ...process.env };
|
|
1946
|
+
const sessionEnv = injectGitHubSessionEnv(baseRuntimeEnv, resolvedGithubToken);
|
|
1947
|
+
const launchExtra = {
|
|
1948
|
+
...extra,
|
|
1949
|
+
envOverrides: sessionEnv,
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1761
1952
|
// ── Resolve MCP servers for this launch ──────────────────────────────────
|
|
1762
1953
|
try {
|
|
1763
|
-
if (!
|
|
1954
|
+
if (!launchExtra._mcpResolved) {
|
|
1764
1955
|
const cfg = loadConfig();
|
|
1765
1956
|
const mcpCfg = cfg.mcpServers || {};
|
|
1766
1957
|
if (mcpCfg.enabled !== false) {
|
|
1767
|
-
const requestedIds =
|
|
1958
|
+
const requestedIds = launchExtra.mcpServers || [];
|
|
1768
1959
|
const defaultIds = mcpCfg.defaultServers || [];
|
|
1769
1960
|
if (requestedIds.length || defaultIds.length) {
|
|
1770
1961
|
const registry = await getMcpRegistry();
|
|
@@ -1774,19 +1965,19 @@ export async function launchEphemeralThread(
|
|
|
1774
1965
|
{ defaultServers: defaultIds, catalogOverrides: mcpCfg.catalogOverrides || {} },
|
|
1775
1966
|
);
|
|
1776
1967
|
if (resolved.length) {
|
|
1777
|
-
|
|
1968
|
+
launchExtra._resolvedMcpServers = resolved;
|
|
1778
1969
|
}
|
|
1779
1970
|
}
|
|
1780
1971
|
}
|
|
1781
|
-
|
|
1972
|
+
launchExtra._mcpResolved = true;
|
|
1782
1973
|
}
|
|
1783
1974
|
} catch (mcpErr) {
|
|
1784
1975
|
console.warn(`${TAG} MCP server resolution failed (non-fatal): ${mcpErr.message}`);
|
|
1785
1976
|
}
|
|
1786
1977
|
|
|
1787
1978
|
// Determine the primary SDK to try
|
|
1788
|
-
const requestedSdk =
|
|
1789
|
-
? String(
|
|
1979
|
+
const requestedSdk = launchExtra.sdk
|
|
1980
|
+
? String(launchExtra.sdk).trim().toLowerCase()
|
|
1790
1981
|
: null;
|
|
1791
1982
|
|
|
1792
1983
|
const primaryName =
|
|
@@ -1794,7 +1985,7 @@ export async function launchEphemeralThread(
|
|
|
1794
1985
|
? requestedSdk
|
|
1795
1986
|
: resolvePoolSdkName();
|
|
1796
1987
|
|
|
1797
|
-
const attemptOrder =
|
|
1988
|
+
const attemptOrder = launchExtra?.disableFallback
|
|
1798
1989
|
? [primaryName]
|
|
1799
1990
|
: [
|
|
1800
1991
|
primaryName,
|
|
@@ -1805,7 +1996,7 @@ export async function launchEphemeralThread(
|
|
|
1805
1996
|
const triedSdkNames = [];
|
|
1806
1997
|
const missingPrereqSdks = [];
|
|
1807
1998
|
const cooledDownSdks = [];
|
|
1808
|
-
const ignoreSdkCooldown =
|
|
1999
|
+
const ignoreSdkCooldown = launchExtra?.ignoreSdkCooldown === true;
|
|
1809
2000
|
|
|
1810
2001
|
for (const name of attemptOrder) {
|
|
1811
2002
|
const adapter = SDK_ADAPTERS[name];
|
|
@@ -1831,7 +2022,7 @@ export async function launchEphemeralThread(
|
|
|
1831
2022
|
}
|
|
1832
2023
|
|
|
1833
2024
|
// Check prerequisites before wasting time trying an unconfigured SDK
|
|
1834
|
-
const prereq = hasSdkPrerequisites(name);
|
|
2025
|
+
const prereq = hasSdkPrerequisites(name, sessionEnv);
|
|
1835
2026
|
if (!prereq.ok) {
|
|
1836
2027
|
missingPrereqSdks.push({ name, reason: prereq.reason });
|
|
1837
2028
|
if (name === primaryName) {
|
|
@@ -1858,7 +2049,7 @@ export async function launchEphemeralThread(
|
|
|
1858
2049
|
|
|
1859
2050
|
triedSdkNames.push(name);
|
|
1860
2051
|
const launcher = await adapter.load();
|
|
1861
|
-
const result = await launcher(prompt, cwd, timeoutMs,
|
|
2052
|
+
const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
|
|
1862
2053
|
lastAttemptResult = result;
|
|
1863
2054
|
|
|
1864
2055
|
if (result.success) {
|
|
@@ -2256,6 +2447,8 @@ function isPoisonedCodexResumeError(errorValue) {
|
|
|
2256
2447
|
* @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>}
|
|
2257
2448
|
*/
|
|
2258
2449
|
async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
2450
|
+
// Coerce to number — prevents string concatenation in setTimeout arithmetic
|
|
2451
|
+
timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
2259
2452
|
const { onEvent, abortController: externalAC, envOverrides = null } = extra;
|
|
2260
2453
|
|
|
2261
2454
|
let CodexClass;
|
|
@@ -2324,6 +2517,9 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
|
2324
2517
|
timeoutMs,
|
|
2325
2518
|
);
|
|
2326
2519
|
let hardTimer;
|
|
2520
|
+
let firstEventTimer = null;
|
|
2521
|
+
let firstEventTimeoutHit = false;
|
|
2522
|
+
let eventCount = 0;
|
|
2327
2523
|
|
|
2328
2524
|
try {
|
|
2329
2525
|
const safePrompt = sanitizeAndBoundPrompt(prompt);
|
|
@@ -2337,12 +2533,17 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
|
2337
2533
|
const hardTimeoutPromise = new Promise((_, reject) => {
|
|
2338
2534
|
hardTimer = setTimeout(
|
|
2339
2535
|
() => reject(new Error("hard_timeout")),
|
|
2340
|
-
timeoutMs + HARD_TIMEOUT_BUFFER_MS,
|
|
2536
|
+
clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "codex-resume-hard-timeout"),
|
|
2341
2537
|
);
|
|
2342
2538
|
});
|
|
2343
2539
|
|
|
2344
2540
|
const iterateEvents = async () => {
|
|
2345
2541
|
for await (const event of turn.events) {
|
|
2542
|
+
eventCount += 1;
|
|
2543
|
+
if (firstEventTimer) {
|
|
2544
|
+
clearTimeout(firstEventTimer);
|
|
2545
|
+
firstEventTimer = null;
|
|
2546
|
+
}
|
|
2346
2547
|
if (controller.signal.aborted) break;
|
|
2347
2548
|
if (typeof onEvent === "function")
|
|
2348
2549
|
try {
|
|
@@ -2359,8 +2560,19 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
|
2359
2560
|
}
|
|
2360
2561
|
};
|
|
2361
2562
|
|
|
2563
|
+
const firstEventTimeoutMs = getFirstEventTimeoutMs(timeoutMs);
|
|
2564
|
+
if (firstEventTimeoutMs) {
|
|
2565
|
+
firstEventTimer = setTimeout(() => {
|
|
2566
|
+
if (eventCount > 0 || controller.signal.aborted) return;
|
|
2567
|
+
firstEventTimeoutHit = true;
|
|
2568
|
+
controller.abort("first_event_timeout");
|
|
2569
|
+
}, firstEventTimeoutMs);
|
|
2570
|
+
if (typeof firstEventTimer.unref === "function") firstEventTimer.unref();
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2362
2573
|
await Promise.race([iterateEvents(), hardTimeoutPromise]);
|
|
2363
2574
|
clearTimeout(hardTimer);
|
|
2575
|
+
if (firstEventTimer) clearTimeout(firstEventTimer);
|
|
2364
2576
|
clearAbortScope();
|
|
2365
2577
|
|
|
2366
2578
|
const newThreadId = thread.id || threadId;
|
|
@@ -2375,16 +2587,21 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
|
|
|
2375
2587
|
} catch (err) {
|
|
2376
2588
|
clearAbortScope();
|
|
2377
2589
|
if (hardTimer) clearTimeout(hardTimer);
|
|
2590
|
+
if (firstEventTimer) clearTimeout(firstEventTimer);
|
|
2378
2591
|
const isTimeout =
|
|
2379
2592
|
err.name === "AbortError" ||
|
|
2380
2593
|
String(err) === "timeout" ||
|
|
2381
2594
|
err.message === "hard_timeout";
|
|
2595
|
+
const firstEventSuffix =
|
|
2596
|
+
isTimeout && firstEventTimeoutHit
|
|
2597
|
+
? ` (no events received within ${getFirstEventTimeoutMs(timeoutMs)}ms)`
|
|
2598
|
+
: "";
|
|
2382
2599
|
return {
|
|
2383
2600
|
success: false,
|
|
2384
2601
|
output: "",
|
|
2385
2602
|
items: [],
|
|
2386
2603
|
error: isTimeout
|
|
2387
|
-
? `${TAG} codex resume timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout)" : ""}`
|
|
2604
|
+
? `${TAG} codex resume timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout)" : ""}${firstEventSuffix}`
|
|
2388
2605
|
: `Thread resume error: ${err.message}`,
|
|
2389
2606
|
sdk: "codex",
|
|
2390
2607
|
threadId: null,
|
|
@@ -2456,6 +2673,15 @@ export async function launchOrResumeThread(
|
|
|
2456
2673
|
) {
|
|
2457
2674
|
await ensureThreadRegistryLoaded();
|
|
2458
2675
|
const { taskKey, ...restExtra } = extra;
|
|
2676
|
+
const resolvedGithubToken = await resolveGithubSessionToken();
|
|
2677
|
+
const restBaseEnv =
|
|
2678
|
+
restExtra?.envOverrides && typeof restExtra.envOverrides === "object"
|
|
2679
|
+
? { ...process.env, ...restExtra.envOverrides }
|
|
2680
|
+
: { ...process.env };
|
|
2681
|
+
restExtra.envOverrides = injectGitHubSessionEnv(
|
|
2682
|
+
restBaseEnv,
|
|
2683
|
+
resolvedGithubToken,
|
|
2684
|
+
);
|
|
2459
2685
|
// Pass taskKey through as steer key so SDK launchers can register active sessions
|
|
2460
2686
|
restExtra.taskKey = taskKey;
|
|
2461
2687
|
if (restExtra.sdk) {
|
package/anomaly-detector.mjs
CHANGED
|
@@ -308,6 +308,12 @@ const RE_ERROR_NOISE = [
|
|
|
308
308
|
// Session completion indicators
|
|
309
309
|
const RE_SESSION_DONE = /"Done"\s*:\s*"/;
|
|
310
310
|
const STR_TASK_COMPLETE = "task_complete";
|
|
311
|
+
const INTERNAL_SESSION_COMPLETION_MARKERS = [
|
|
312
|
+
"EVT[turn.completed]",
|
|
313
|
+
"EVT[session.completed]",
|
|
314
|
+
"EVT[response.completed]",
|
|
315
|
+
"EVT[thread.completed]",
|
|
316
|
+
];
|
|
311
317
|
|
|
312
318
|
// ── Main Detector Class ─────────────────────────────────────────────────────
|
|
313
319
|
|
|
@@ -1061,7 +1067,13 @@ export class AnomalyDetector {
|
|
|
1061
1067
|
* Detect session completion (mark as dead to stop analysis).
|
|
1062
1068
|
*/
|
|
1063
1069
|
#detectSessionCompletion(line, state) {
|
|
1064
|
-
if (
|
|
1070
|
+
if (
|
|
1071
|
+
RE_SESSION_DONE.test(line) ||
|
|
1072
|
+
line.includes(STR_TASK_COMPLETE) ||
|
|
1073
|
+
INTERNAL_SESSION_COMPLETION_MARKERS.some((marker) =>
|
|
1074
|
+
line.includes(marker),
|
|
1075
|
+
)
|
|
1076
|
+
) {
|
|
1065
1077
|
state.isDead = true;
|
|
1066
1078
|
}
|
|
1067
1079
|
}
|