bosun 0.41.7 → 0.41.9
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/README.md +23 -1
- package/agent/agent-event-bus.mjs +31 -2
- package/agent/agent-pool.mjs +251 -11
- package/agent/agent-prompts.mjs +5 -1
- package/agent/agent-supervisor.mjs +22 -0
- package/agent/primary-agent.mjs +115 -5
- package/cli.mjs +3 -2
- package/config/config.mjs +4 -1
- package/desktop/main.mjs +350 -25
- package/desktop/preload.cjs +8 -0
- package/desktop/preload.mjs +19 -0
- package/entrypoint.mjs +332 -0
- package/infra/health-status.mjs +72 -0
- package/infra/library-manager.mjs +58 -1
- package/infra/maintenance.mjs +1 -2
- package/infra/monitor.mjs +25 -7
- package/infra/session-tracker.mjs +30 -3
- package/package.json +10 -4
- package/server/bosun-mcp-server.mjs +1004 -0
- package/server/setup-web-server.mjs +287 -258
- package/server/ui-server.mjs +218 -23
- package/shell/claude-shell.mjs +14 -1
- package/shell/codex-model-profiles.mjs +166 -29
- package/shell/codex-shell.mjs +56 -18
- package/shell/opencode-providers.mjs +20 -8
- package/task/task-executor.mjs +28 -0
- package/task/task-store.mjs +13 -4
- package/tools/list-todos.mjs +7 -1
- package/ui/app.js +3 -2
- package/ui/components/agent-selector.js +127 -0
- package/ui/components/session-list.js +2 -0
- package/ui/demo-defaults.js +6 -6
- package/ui/modules/router.js +2 -0
- package/ui/modules/state.js +13 -5
- package/ui/tabs/chat.js +3 -0
- package/ui/tabs/library.js +284 -52
- package/ui/tabs/tasks.js +5 -13
- package/workflow/workflow-engine.mjs +16 -4
- package/workflow/workflow-nodes/definitions.mjs +37 -0
- package/workflow/workflow-nodes.mjs +489 -153
- package/workflow/workflow-templates.mjs +0 -5
- package/workflow-templates/github.mjs +106 -16
- package/workspace/worktree-manager.mjs +1 -1
package/server/ui-server.mjs
CHANGED
|
@@ -80,6 +80,7 @@ import {
|
|
|
80
80
|
scaffoldAgentProfiles,
|
|
81
81
|
getBosunHomeDir,
|
|
82
82
|
syncAutoDiscoveredLibraryEntries,
|
|
83
|
+
resolveAgentProfileLibraryMetadata,
|
|
83
84
|
} from "../infra/library-manager.mjs";
|
|
84
85
|
import {
|
|
85
86
|
listCatalog,
|
|
@@ -319,6 +320,17 @@ function unblockInternalTask(taskId, options = {}) {
|
|
|
319
320
|
}
|
|
320
321
|
}
|
|
321
322
|
|
|
323
|
+
function resetExecutorTaskThrottleState(taskId, options = {}) {
|
|
324
|
+
const executor = uiDeps.getInternalExecutor?.() || null;
|
|
325
|
+
const fn = executor?.resetTaskThrottleState;
|
|
326
|
+
if (typeof fn !== "function") return false;
|
|
327
|
+
try {
|
|
328
|
+
return fn.call(executor, taskId, options) === true;
|
|
329
|
+
} catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
322
334
|
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
323
335
|
const repoRoot = resolveRepoRoot();
|
|
324
336
|
const uiRootPreferred = resolve(__dirname, "..", "ui");
|
|
@@ -729,10 +741,44 @@ function isVoiceAgentProfileEntry(entry, profile) {
|
|
|
729
741
|
}
|
|
730
742
|
|
|
731
743
|
function resolveAgentProfileType(entry, profile) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
744
|
+
return resolveAgentProfileLibraryMetadata(entry, profile).agentType;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function resolveAgentProfileLibraryView(entry, profile, storageScope) {
|
|
748
|
+
const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
|
|
749
|
+
return {
|
|
750
|
+
...entry,
|
|
751
|
+
storageScope,
|
|
752
|
+
...metadata,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function listManualAgentProfiles(workspaceContext) {
|
|
757
|
+
const resolved = listLibraryEntriesAcrossRoots(workspaceContext, { type: "agent" });
|
|
758
|
+
const profiles = [];
|
|
759
|
+
for (const { entry, rootInfo } of resolved.entries) {
|
|
760
|
+
const profile = getEntryContent(rootInfo.rootDir, entry);
|
|
761
|
+
const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
|
|
762
|
+
if (metadata.agentCategory !== "interactive" || metadata.showInChatDropdown !== true) continue;
|
|
763
|
+
const sectionLabel = metadata.interactiveLabel
|
|
764
|
+
|| (metadata.interactiveMode ? metadata.interactiveMode.charAt(0).toUpperCase() + metadata.interactiveMode.slice(1) : "Manual");
|
|
765
|
+
profiles.push({
|
|
766
|
+
id: entry.id,
|
|
767
|
+
name: entry.name || entry.id,
|
|
768
|
+
description: entry.description || "",
|
|
769
|
+
storageScope: rootInfo.scope,
|
|
770
|
+
model: String(profile?.model || "").trim() || null,
|
|
771
|
+
sdk: String(profile?.sdk || "").trim() || null,
|
|
772
|
+
sectionLabel,
|
|
773
|
+
...metadata,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
profiles.sort((a, b) => {
|
|
777
|
+
const sectionCmp = String(a.sectionLabel || "").localeCompare(String(b.sectionLabel || ""));
|
|
778
|
+
if (sectionCmp !== 0) return sectionCmp;
|
|
779
|
+
return String(a.name || a.id).localeCompare(String(b.name || b.id));
|
|
780
|
+
});
|
|
781
|
+
return profiles;
|
|
736
782
|
}
|
|
737
783
|
|
|
738
784
|
function resolveVoiceLibraryRoot(callContext = {}) {
|
|
@@ -4072,6 +4118,13 @@ const workflowEngineListenerCleanup = new WeakMap();
|
|
|
4072
4118
|
let workflowWsSeq = 0;
|
|
4073
4119
|
let uiInstanceLockPath = "";
|
|
4074
4120
|
let uiInstanceLockHeld = false;
|
|
4121
|
+
|
|
4122
|
+
// ── Unified setup state (entrypoint integration) ────────────────────────────
|
|
4123
|
+
// When running via entrypoint.mjs, `_setupMode` starts true and flips to false
|
|
4124
|
+
// once the wizard completes. In standalone ui-server mode, it's always false.
|
|
4125
|
+
let _setupMode = false;
|
|
4126
|
+
/** @type {(() => void)|null} */
|
|
4127
|
+
let _setupOnComplete = null;
|
|
4075
4128
|
let _sessionTokenLastTouchedAt = 0;
|
|
4076
4129
|
let _localRequestAddressCache = {
|
|
4077
4130
|
loadedAt: 0,
|
|
@@ -4134,19 +4187,39 @@ async function resolveExecPrimaryPrompt() {
|
|
|
4134
4187
|
|
|
4135
4188
|
/**
|
|
4136
4189
|
* Resolve the bosun config directory. Falls back through:
|
|
4137
|
-
* 1. uiDeps.configDir (injected at server start)
|
|
4138
|
-
* 2.
|
|
4139
|
-
* 3.
|
|
4190
|
+
* 1. uiDeps.configDir (explicitly injected at server start)
|
|
4191
|
+
* 2. BOSUN_CONFIG_PATH parent directory
|
|
4192
|
+
* 3. repo-local config when REPO_ROOT explicitly points at a managed repo
|
|
4193
|
+
* 4. BOSUN_HOME/BOSUN_DIR/test sandbox/default home dir
|
|
4140
4194
|
* Ensures the directory exists.
|
|
4141
4195
|
*/
|
|
4142
4196
|
function resolveUiConfigDir() {
|
|
4143
4197
|
const sandbox = ensureTestRuntimeSandbox();
|
|
4198
|
+
if (uiDeps.configDir) {
|
|
4199
|
+
const injectedDir = resolve(String(uiDeps.configDir));
|
|
4200
|
+
try { mkdirSync(injectedDir, { recursive: true }); } catch { /* ok */ }
|
|
4201
|
+
return injectedDir;
|
|
4202
|
+
}
|
|
4144
4203
|
if (process.env.BOSUN_CONFIG_PATH) {
|
|
4145
4204
|
const fromConfigPath = dirname(resolve(process.env.BOSUN_CONFIG_PATH));
|
|
4146
4205
|
try { mkdirSync(fromConfigPath, { recursive: true }); } catch { /* ok */ }
|
|
4147
|
-
if (!uiDeps.configDir) uiDeps.configDir = fromConfigPath;
|
|
4148
4206
|
return fromConfigPath;
|
|
4149
4207
|
}
|
|
4208
|
+
if (String(process.env.REPO_ROOT || "").trim()) {
|
|
4209
|
+
const repoLocalConfigDirCandidates = [
|
|
4210
|
+
resolve(repoRoot, ".bosun"),
|
|
4211
|
+
repoRoot,
|
|
4212
|
+
];
|
|
4213
|
+
for (const candidate of repoLocalConfigDirCandidates) {
|
|
4214
|
+
try {
|
|
4215
|
+
if (!existsSync(resolve(candidate, "bosun.config.json"))) continue;
|
|
4216
|
+
mkdirSync(candidate, { recursive: true });
|
|
4217
|
+
return candidate;
|
|
4218
|
+
} catch {
|
|
4219
|
+
// Fall through to the next candidate.
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4150
4223
|
const isWslInteropRuntime = Boolean(
|
|
4151
4224
|
process.env.WSL_DISTRO_NAME
|
|
4152
4225
|
|| process.env.WSL_INTEROP
|
|
@@ -4176,8 +4249,6 @@ function resolveUiConfigDir() {
|
|
|
4176
4249
|
|| resolve(baseDir, "bosun");
|
|
4177
4250
|
if (dir) {
|
|
4178
4251
|
try { mkdirSync(dir, { recursive: true }); } catch { /* ok */ }
|
|
4179
|
-
// Cache it so subsequent calls don't re-resolve
|
|
4180
|
-
if (!uiDeps.configDir) uiDeps.configDir = dir;
|
|
4181
4252
|
}
|
|
4182
4253
|
return dir;
|
|
4183
4254
|
}
|
|
@@ -8379,10 +8450,49 @@ function getExpectedDesktopApiKey() {
|
|
|
8379
8450
|
return fromEnv;
|
|
8380
8451
|
}
|
|
8381
8452
|
|
|
8453
|
+
/**
|
|
8454
|
+
* Check whether the request carries a valid user-configured API key.
|
|
8455
|
+
* Set via BOSUN_API_KEY env var — intended for external clients (Electron app
|
|
8456
|
+
* connecting to a remote/Docker instance, CLI tools, third-party integrations).
|
|
8457
|
+
* Unlike the desktop API key (auto-generated per install), this is a
|
|
8458
|
+
* user-chosen secret that can be set in .env, docker-compose.yml, etc.
|
|
8459
|
+
*/
|
|
8460
|
+
function checkApiKey(req) {
|
|
8461
|
+
const expected = String(process.env.BOSUN_API_KEY || "").trim();
|
|
8462
|
+
if (!expected || expected.length < 8) return false;
|
|
8463
|
+
const authHeader = req.headers.authorization || "";
|
|
8464
|
+
// Accept as Bearer token
|
|
8465
|
+
if (authHeader.startsWith("Bearer ")) {
|
|
8466
|
+
const provided = authHeader.slice(7).trim();
|
|
8467
|
+
if (!provided) return false;
|
|
8468
|
+
try {
|
|
8469
|
+
const a = Buffer.from(provided);
|
|
8470
|
+
const b = Buffer.from(expected);
|
|
8471
|
+
if (a.length !== b.length) return false;
|
|
8472
|
+
return timingSafeEqual(a, b);
|
|
8473
|
+
} catch { return false; }
|
|
8474
|
+
}
|
|
8475
|
+
// Accept as X-API-Key header
|
|
8476
|
+
const apiKeyHeader = String(req.headers["x-api-key"] || "").trim();
|
|
8477
|
+
if (apiKeyHeader) {
|
|
8478
|
+
try {
|
|
8479
|
+
const a = Buffer.from(apiKeyHeader);
|
|
8480
|
+
const b = Buffer.from(expected);
|
|
8481
|
+
if (a.length !== b.length) return false;
|
|
8482
|
+
return timingSafeEqual(a, b);
|
|
8483
|
+
} catch { return false; }
|
|
8484
|
+
}
|
|
8485
|
+
return false;
|
|
8486
|
+
}
|
|
8487
|
+
|
|
8382
8488
|
async function requireAuth(req) {
|
|
8383
8489
|
if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
|
|
8490
|
+
// User-configured API key (BOSUN_API_KEY env) — external clients, Docker
|
|
8491
|
+
// Issue a session cookie so the browser can authenticate WebSocket upgrades
|
|
8492
|
+
// (the WS constructor doesn't support custom headers).
|
|
8493
|
+
if (checkApiKey(req)) return { ok: true, source: "api-key", issueSessionCookie: true };
|
|
8384
8494
|
// Desktop Electron API key — non-expiring, set via BOSUN_DESKTOP_API_KEY env
|
|
8385
|
-
if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie:
|
|
8495
|
+
if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: true };
|
|
8386
8496
|
// Session token (browser access)
|
|
8387
8497
|
if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
|
|
8388
8498
|
// Telegram initData HMAC
|
|
@@ -8406,6 +8516,19 @@ async function requireAuth(req) {
|
|
|
8406
8516
|
|
|
8407
8517
|
function resolveWsAuthSource(req, url) {
|
|
8408
8518
|
if (isAllowUnsafe()) return "unsafe";
|
|
8519
|
+
// User-configured API key (BOSUN_API_KEY) — check Bearer header and query param
|
|
8520
|
+
if (checkApiKey(req)) return "api-key";
|
|
8521
|
+
const qApiKey = url.searchParams.get("apiKey") || "";
|
|
8522
|
+
if (qApiKey) {
|
|
8523
|
+
const expected = String(process.env.BOSUN_API_KEY || "").trim();
|
|
8524
|
+
if (expected && expected.length >= 8) {
|
|
8525
|
+
try {
|
|
8526
|
+
const a = Buffer.from(qApiKey);
|
|
8527
|
+
const b = Buffer.from(expected);
|
|
8528
|
+
if (a.length === b.length && timingSafeEqual(a, b)) return "api-key";
|
|
8529
|
+
} catch { /* ignore */ }
|
|
8530
|
+
}
|
|
8531
|
+
}
|
|
8409
8532
|
// Desktop Electron API key (query param: desktopKey=...)
|
|
8410
8533
|
const desktopKey = getExpectedDesktopApiKey();
|
|
8411
8534
|
if (desktopKey) {
|
|
@@ -12898,7 +13021,14 @@ async function handleApi(req, res, url) {
|
|
|
12898
13021
|
: undefined;
|
|
12899
13022
|
const metadataPatch = buildTaskMetadataPatch(body || {});
|
|
12900
13023
|
const requestedStatus = normalizeTaskStatusKey(body?.status);
|
|
12901
|
-
const
|
|
13024
|
+
const currentLooksBlocked =
|
|
13025
|
+
normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
|
|
13026
|
+
Boolean(previousTask?.blockedReason) ||
|
|
13027
|
+
Boolean(previousTask?.cooldownUntil) ||
|
|
13028
|
+
Boolean(previousTask?.meta?.autoRecovery) ||
|
|
13029
|
+
Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
|
|
13030
|
+
const clearsBlockedState =
|
|
13031
|
+
currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
|
|
12902
13032
|
const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
|
|
12903
13033
|
? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
|
|
12904
13034
|
: null;
|
|
@@ -12932,6 +13062,9 @@ async function handleApi(req, res, url) {
|
|
|
12932
13062
|
? await adapter.updateTask(taskId, patch)
|
|
12933
13063
|
: await adapter.updateTaskStatus(taskId, patch.status);
|
|
12934
13064
|
const updated = withTaskMetadataTopLevel(updatedRaw);
|
|
13065
|
+
if (clearsBlockedState) {
|
|
13066
|
+
resetExecutorTaskThrottleState(taskId);
|
|
13067
|
+
}
|
|
12935
13068
|
const nextStatus = updated?.status || patch.status || null;
|
|
12936
13069
|
const lifecycleAction = inferLifecycleAction(
|
|
12937
13070
|
previousTask?.status || null,
|
|
@@ -13039,7 +13172,14 @@ async function handleApi(req, res, url) {
|
|
|
13039
13172
|
: undefined;
|
|
13040
13173
|
const metadataPatch = buildTaskMetadataPatch(body || {});
|
|
13041
13174
|
const requestedStatus = normalizeTaskStatusKey(body?.status);
|
|
13042
|
-
const
|
|
13175
|
+
const currentLooksBlocked =
|
|
13176
|
+
normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
|
|
13177
|
+
Boolean(previousTask?.blockedReason) ||
|
|
13178
|
+
Boolean(previousTask?.cooldownUntil) ||
|
|
13179
|
+
Boolean(previousTask?.meta?.autoRecovery) ||
|
|
13180
|
+
Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
|
|
13181
|
+
const clearsBlockedState =
|
|
13182
|
+
currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
|
|
13043
13183
|
const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
|
|
13044
13184
|
? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
|
|
13045
13185
|
: null;
|
|
@@ -13073,6 +13213,9 @@ async function handleApi(req, res, url) {
|
|
|
13073
13213
|
? await adapter.updateTask(taskId, patch)
|
|
13074
13214
|
: await adapter.updateTaskStatus(taskId, patch.status);
|
|
13075
13215
|
const updated = withTaskMetadataTopLevel(updatedRaw);
|
|
13216
|
+
if (clearsBlockedState) {
|
|
13217
|
+
resetExecutorTaskThrottleState(taskId);
|
|
13218
|
+
}
|
|
13076
13219
|
const nextStatus = updated?.status || patch.status || null;
|
|
13077
13220
|
const lifecycleAction = inferLifecycleAction(
|
|
13078
13221
|
previousTask?.status || null,
|
|
@@ -13491,16 +13634,14 @@ async function handleApi(req, res, url) {
|
|
|
13491
13634
|
search: search || undefined,
|
|
13492
13635
|
});
|
|
13493
13636
|
let data = resolved.entries.map(({ entry, rootInfo }) => {
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13637
|
+
if (entry?.type !== "agent") {
|
|
13638
|
+
return {
|
|
13639
|
+
...entry,
|
|
13640
|
+
storageScope: rootInfo.scope,
|
|
13641
|
+
};
|
|
13642
|
+
}
|
|
13499
13643
|
const profile = getEntryContent(rootInfo.rootDir, entry);
|
|
13500
|
-
return
|
|
13501
|
-
...base,
|
|
13502
|
-
agentType: resolveAgentProfileType(entry, profile),
|
|
13503
|
-
};
|
|
13644
|
+
return resolveAgentProfileLibraryView(entry, profile, rootInfo.scope);
|
|
13504
13645
|
});
|
|
13505
13646
|
if (type === "agent" && (agentTypeRaw === "voice" || agentTypeRaw === "task" || agentTypeRaw === "chat")) {
|
|
13506
13647
|
data = data.filter((entry) => {
|
|
@@ -16836,6 +16977,7 @@ async function handleApi(req, res, url) {
|
|
|
16836
16977
|
status: "todo",
|
|
16837
16978
|
source: "manual-retry",
|
|
16838
16979
|
});
|
|
16980
|
+
resetExecutorTaskThrottleState(taskId);
|
|
16839
16981
|
if (!nextTask) {
|
|
16840
16982
|
if (typeof adapter.updateTask === "function") {
|
|
16841
16983
|
await adapter.updateTask(taskId, {
|
|
@@ -16900,6 +17042,7 @@ async function handleApi(req, res, url) {
|
|
|
16900
17042
|
status: targetStatus,
|
|
16901
17043
|
source: "api.tasks.unblock",
|
|
16902
17044
|
});
|
|
17045
|
+
resetExecutorTaskThrottleState(taskId);
|
|
16903
17046
|
if (!updatedTask) {
|
|
16904
17047
|
const nextMeta = task?.meta && typeof task.meta === "object"
|
|
16905
17048
|
? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
|
|
@@ -17079,10 +17222,13 @@ async function handleApi(req, res, url) {
|
|
|
17079
17222
|
|
|
17080
17223
|
if (path === "/api/agents/available" && req.method === "GET") {
|
|
17081
17224
|
try {
|
|
17225
|
+
const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false })
|
|
17226
|
+
|| { workspaceDir: repoRoot, workspaceRoot: repoRoot };
|
|
17082
17227
|
const agents = getAvailableAgents();
|
|
17083
17228
|
const active = getPrimaryAgentSelection();
|
|
17084
17229
|
const mode = getAgentMode();
|
|
17085
|
-
|
|
17230
|
+
const manualAgents = listManualAgentProfiles(workspaceContext);
|
|
17231
|
+
jsonResponse(res, 200, { ok: true, agents, active, mode, manualAgents });
|
|
17086
17232
|
} catch (err) {
|
|
17087
17233
|
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
17088
17234
|
}
|
|
@@ -19255,6 +19401,21 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19255
19401
|
const taskStoreModule = await ensureTaskStoreApi();
|
|
19256
19402
|
const sandbox = ensureTestRuntimeSandbox();
|
|
19257
19403
|
|
|
19404
|
+
// ── Setup mode integration (entrypoint.mjs) ───────────────────────────
|
|
19405
|
+
if (options.setupMode) {
|
|
19406
|
+
_setupMode = true;
|
|
19407
|
+
_setupOnComplete = () => {
|
|
19408
|
+
_setupMode = false;
|
|
19409
|
+
console.log("[telegram-ui] setup complete — portal mode active");
|
|
19410
|
+
// Notify entrypoint to start monitor
|
|
19411
|
+
try {
|
|
19412
|
+
import("../entrypoint.mjs").then((m) => {
|
|
19413
|
+
if (typeof m.markSetupComplete === "function") m.markSetupComplete();
|
|
19414
|
+
}).catch(() => {});
|
|
19415
|
+
} catch { /* not running via entrypoint */ }
|
|
19416
|
+
};
|
|
19417
|
+
}
|
|
19418
|
+
|
|
19258
19419
|
const rawPort = options.port ?? getDefaultPort();
|
|
19259
19420
|
const configuredPort = Number(rawPort);
|
|
19260
19421
|
const isTestRun =
|
|
@@ -19427,6 +19588,18 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19427
19588
|
return;
|
|
19428
19589
|
}
|
|
19429
19590
|
|
|
19591
|
+
// Docker / load-balancer health check — no auth required
|
|
19592
|
+
if (url.pathname === "/healthz") {
|
|
19593
|
+
try {
|
|
19594
|
+
const { getHealthStatus } = await import("../infra/health-status.mjs");
|
|
19595
|
+
jsonResponse(res, 200, getHealthStatus());
|
|
19596
|
+
} catch {
|
|
19597
|
+
// Fallback if health module unavailable
|
|
19598
|
+
jsonResponse(res, 200, { status: "ok", server: "bosun" });
|
|
19599
|
+
}
|
|
19600
|
+
return;
|
|
19601
|
+
}
|
|
19602
|
+
|
|
19430
19603
|
// GitHub OAuth callback — public (no session auth required)
|
|
19431
19604
|
// Accept both /github/callback (registered in GitHub App settings) and
|
|
19432
19605
|
// /api/github/callback (documented API path) so either works.
|
|
@@ -19445,6 +19618,16 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19445
19618
|
return;
|
|
19446
19619
|
}
|
|
19447
19620
|
|
|
19621
|
+
// Setup wizard API routes — handled before the general /api/ catch-all
|
|
19622
|
+
// so the setup wizard works whether running standalone or unified with portal.
|
|
19623
|
+
if (url.pathname.startsWith("/api/setup/")) {
|
|
19624
|
+
const { handleSetupApi } = await import("./setup-web-server.mjs");
|
|
19625
|
+
const handled = await handleSetupApi(req, res, url, {
|
|
19626
|
+
onComplete: _setupOnComplete || undefined,
|
|
19627
|
+
});
|
|
19628
|
+
if (handled) return;
|
|
19629
|
+
}
|
|
19630
|
+
|
|
19448
19631
|
if (url.pathname.startsWith("/api/")) {
|
|
19449
19632
|
await handleApi(req, res, url);
|
|
19450
19633
|
return;
|
|
@@ -19463,6 +19646,15 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
19463
19646
|
return;
|
|
19464
19647
|
}
|
|
19465
19648
|
|
|
19649
|
+
// ── Setup wizard page ──────────────────────────────────────────────────
|
|
19650
|
+
// When in setup mode, redirect / → /setup so the user lands on the wizard.
|
|
19651
|
+
// When setup is complete, /setup still works for re-configuration access.
|
|
19652
|
+
if (_setupMode && url.pathname === "/") {
|
|
19653
|
+
res.writeHead(302, { Location: "/setup" });
|
|
19654
|
+
res.end();
|
|
19655
|
+
return;
|
|
19656
|
+
}
|
|
19657
|
+
|
|
19466
19658
|
// /demo and /ui/demo are convenience aliases for /demo.html (the self-contained mock UI demo)
|
|
19467
19659
|
if (url.pathname === "/demo" || url.pathname === "/ui/demo") {
|
|
19468
19660
|
const qs = url.search || "";
|
|
@@ -20111,6 +20303,9 @@ export function stopTelegramUiServer() {
|
|
|
20111
20303
|
if (!uiServer) return;
|
|
20112
20304
|
stopTunnel();
|
|
20113
20305
|
stopWsHeartbeat();
|
|
20306
|
+
// Clear injected configDir so it does not leak between server lifecycles
|
|
20307
|
+
// (tests start/stop servers repeatedly with different config directories).
|
|
20308
|
+
delete uiDeps.configDir;
|
|
20114
20309
|
for (const socket of wsClients) {
|
|
20115
20310
|
try {
|
|
20116
20311
|
stopLogStream(socket);
|
package/shell/claude-shell.mjs
CHANGED
|
@@ -437,9 +437,22 @@ async function saveState() {
|
|
|
437
437
|
}
|
|
438
438
|
|
|
439
439
|
function extractTaskHeading(msg) {
|
|
440
|
-
// Prompt first line is "# TASKID — Task Title"
|
|
440
|
+
// Prompt first line is "# Task: Task Title" (or legacy "# TASKID — Task Title").
|
|
441
441
|
// Return just the task title portion, or a short fallback.
|
|
442
442
|
const firstLine = msg.split(/\r?\n/)[0].replace(/^#+\s*/, '').trim();
|
|
443
|
+
if (!firstLine) return 'Execute Task';
|
|
444
|
+
const lowerLine = firstLine.toLowerCase();
|
|
445
|
+
if (lowerLine.startsWith('task')) {
|
|
446
|
+
let index = 4;
|
|
447
|
+
while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
|
|
448
|
+
const separator = firstLine[index];
|
|
449
|
+
if (separator === ':' || separator === '-' || separator === '\u2014') {
|
|
450
|
+
index += 1;
|
|
451
|
+
while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
|
|
452
|
+
const heading = firstLine.slice(index).trim();
|
|
453
|
+
if (heading) return heading;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
443
456
|
const dashIdx = firstLine.indexOf(' \u2014 ');
|
|
444
457
|
const heading = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
|
|
445
458
|
return heading || 'Execute Task';
|
|
@@ -9,6 +9,33 @@ function clean(value) {
|
|
|
9
9
|
return String(value ?? "").trim();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function isAzureOpenAIBaseUrl(value) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = value instanceof URL ? value : new URL(String(value || ""));
|
|
15
|
+
const host = String(parsed.hostname || "").toLowerCase();
|
|
16
|
+
return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeAzureOpenAIBaseUrl(value) {
|
|
23
|
+
const raw = clean(value);
|
|
24
|
+
if (!raw) return "";
|
|
25
|
+
try {
|
|
26
|
+
const parsed = new URL(raw);
|
|
27
|
+
if (!isAzureOpenAIBaseUrl(parsed)) {
|
|
28
|
+
return raw;
|
|
29
|
+
}
|
|
30
|
+
parsed.pathname = "/openai/v1";
|
|
31
|
+
parsed.search = "";
|
|
32
|
+
parsed.hash = "";
|
|
33
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
34
|
+
} catch {
|
|
35
|
+
return raw;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
12
39
|
function normalizeProfileName(value, fallback = DEFAULT_ACTIVE_PROFILE) {
|
|
13
40
|
const raw = clean(value).toLowerCase();
|
|
14
41
|
if (!raw) return fallback;
|
|
@@ -19,20 +46,6 @@ function profilePrefix(name) {
|
|
|
19
46
|
return `CODEX_MODEL_PROFILE_${name.toUpperCase()}_`;
|
|
20
47
|
}
|
|
21
48
|
|
|
22
|
-
function inferGlobalProvider(env) {
|
|
23
|
-
const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
|
|
24
|
-
if ((() => {
|
|
25
|
-
try {
|
|
26
|
-
const parsed = new URL(baseUrl);
|
|
27
|
-
const host = String(parsed.hostname || "").toLowerCase();
|
|
28
|
-
return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
|
|
29
|
-
} catch {
|
|
30
|
-
return false;
|
|
31
|
-
}
|
|
32
|
-
})()) return "azure";
|
|
33
|
-
return "openai";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
49
|
function normalizeProvider(value, fallback) {
|
|
37
50
|
const raw = clean(value).toLowerCase();
|
|
38
51
|
if (!raw) return fallback;
|
|
@@ -48,6 +61,17 @@ function normalizeProvider(value, fallback) {
|
|
|
48
61
|
return fallback;
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
function hasEnvValue(env, key) {
|
|
65
|
+
return Boolean(key && clean(env?.[key]));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inferProviderKindFromSection(name, section, fallback = "openai") {
|
|
69
|
+
if (isAzureOpenAIBaseUrl(section?.baseUrl)) return "azure";
|
|
70
|
+
const normalized = normalizeProvider(name, "");
|
|
71
|
+
if (normalized) return normalized;
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
51
75
|
function readProfileField(env, profileName, field) {
|
|
52
76
|
return clean(env[`${profilePrefix(profileName)}${field}`]);
|
|
53
77
|
}
|
|
@@ -75,17 +99,99 @@ function profileRecord(env, profileName, globalProvider) {
|
|
|
75
99
|
};
|
|
76
100
|
}
|
|
77
101
|
|
|
78
|
-
function
|
|
102
|
+
function readCodexConfigRuntimeDefaults() {
|
|
79
103
|
try {
|
|
80
104
|
const configPath = resolve(homedir(), ".codex", "config.toml");
|
|
81
|
-
if (!existsSync(configPath))
|
|
105
|
+
if (!existsSync(configPath)) {
|
|
106
|
+
return { model: "", modelProvider: "", providers: {} };
|
|
107
|
+
}
|
|
82
108
|
const content = readFileSync(configPath, "utf8");
|
|
83
109
|
const head = content.split(/\n\[/)[0] || "";
|
|
84
|
-
const
|
|
85
|
-
|
|
110
|
+
const modelMatch = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
|
|
111
|
+
const modelProviderMatch = head.match(/^\s*model_provider\s*=\s*"([^"]+)"/m);
|
|
112
|
+
const providers = {};
|
|
113
|
+
const providerSectionRegex = /^\[model_providers\.([^\]]+)\]\s*([\s\S]*?)(?=^\[[^\]]+\]|\Z)/gm;
|
|
114
|
+
for (const match of content.matchAll(providerSectionRegex)) {
|
|
115
|
+
const [, rawName = "", body = ""] = match;
|
|
116
|
+
const name = clean(rawName);
|
|
117
|
+
if (!name) continue;
|
|
118
|
+
const baseUrlMatch = body.match(/^\s*base_url\s*=\s*"([^"]+)"/m);
|
|
119
|
+
const envKeyMatch = body.match(/^\s*env_key\s*=\s*"([^"]+)"/m);
|
|
120
|
+
providers[name] = {
|
|
121
|
+
name,
|
|
122
|
+
baseUrl: clean(baseUrlMatch?.[1]),
|
|
123
|
+
envKey: clean(envKeyMatch?.[1]),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
model: clean(modelMatch?.[1]),
|
|
128
|
+
modelProvider: clean(modelProviderMatch?.[1]),
|
|
129
|
+
providers,
|
|
130
|
+
};
|
|
86
131
|
} catch {
|
|
87
|
-
return "";
|
|
132
|
+
return { model: "", modelProvider: "", providers: {} };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readCodexConfigTopLevelModel() {
|
|
137
|
+
return readCodexConfigRuntimeDefaults().model;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function selectConfigProviderForRuntime(configDefaults, env, preferredProvider = "") {
|
|
141
|
+
const providers = configDefaults?.providers || {};
|
|
142
|
+
const preferred = clean(preferredProvider).toLowerCase();
|
|
143
|
+
const entries = Object.values(providers).map((section) => ({
|
|
144
|
+
...section,
|
|
145
|
+
provider: inferProviderKindFromSection(section.name, section, preferred || "openai"),
|
|
146
|
+
}));
|
|
147
|
+
const matchingEntries = preferred
|
|
148
|
+
? entries.filter((section) => section.provider === preferred)
|
|
149
|
+
: entries;
|
|
150
|
+
const envBackedEntries = matchingEntries.filter(
|
|
151
|
+
(section) => !section.envKey || hasEnvValue(env, section.envKey),
|
|
152
|
+
);
|
|
153
|
+
const preferredNames = preferred === "azure"
|
|
154
|
+
? ["azure"]
|
|
155
|
+
: preferred === "openai"
|
|
156
|
+
? ["openai"]
|
|
157
|
+
: [];
|
|
158
|
+
const findNamed = (sections) => preferredNames
|
|
159
|
+
.map((name) => sections.find((section) => section.name === name))
|
|
160
|
+
.find(Boolean);
|
|
161
|
+
|
|
162
|
+
const configuredName = clean(configDefaults?.modelProvider);
|
|
163
|
+
if (configuredName) {
|
|
164
|
+
const configuredSection = providers[configuredName];
|
|
165
|
+
if (configuredSection) {
|
|
166
|
+
const configured = {
|
|
167
|
+
...configuredSection,
|
|
168
|
+
provider: inferProviderKindFromSection(
|
|
169
|
+
configuredSection.name,
|
|
170
|
+
configuredSection,
|
|
171
|
+
preferred || "openai",
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
const preferredMatches = !preferred || configured.provider === preferred;
|
|
175
|
+
if (preferredMatches && (!configured.envKey || hasEnvValue(env, configured.envKey))) {
|
|
176
|
+
return configured;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
88
179
|
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
findNamed(envBackedEntries) ||
|
|
183
|
+
envBackedEntries[0] ||
|
|
184
|
+
findNamed(matchingEntries) ||
|
|
185
|
+
matchingEntries[0] ||
|
|
186
|
+
null
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function inferGlobalProvider(env, configDefaults = null) {
|
|
191
|
+
const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
|
|
192
|
+
if (isAzureOpenAIBaseUrl(baseUrl)) return "azure";
|
|
193
|
+
const configured = selectConfigProviderForRuntime(configDefaults, env);
|
|
194
|
+
return configured?.provider || "openai";
|
|
89
195
|
}
|
|
90
196
|
|
|
91
197
|
/**
|
|
@@ -96,6 +202,7 @@ function readCodexConfigTopLevelModel() {
|
|
|
96
202
|
*/
|
|
97
203
|
export function resolveCodexProfileRuntime(envInput = process.env) {
|
|
98
204
|
const sourceEnv = { ...envInput };
|
|
205
|
+
const configDefaults = readCodexConfigRuntimeDefaults();
|
|
99
206
|
const activeProfile = normalizeProfileName(
|
|
100
207
|
sourceEnv.CODEX_MODEL_PROFILE,
|
|
101
208
|
DEFAULT_ACTIVE_PROFILE,
|
|
@@ -105,7 +212,7 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
|
|
|
105
212
|
DEFAULT_SUBAGENT_PROFILE,
|
|
106
213
|
);
|
|
107
214
|
|
|
108
|
-
const globalProvider = inferGlobalProvider(sourceEnv);
|
|
215
|
+
const globalProvider = inferGlobalProvider(sourceEnv, configDefaults);
|
|
109
216
|
const active = profileRecord(sourceEnv, activeProfile, globalProvider);
|
|
110
217
|
const sub = profileRecord(sourceEnv, subagentProfile, globalProvider);
|
|
111
218
|
|
|
@@ -116,24 +223,54 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
|
|
|
116
223
|
if (active.model) {
|
|
117
224
|
env.CODEX_MODEL = active.model;
|
|
118
225
|
}
|
|
119
|
-
if (active.baseUrl) {
|
|
120
|
-
env.OPENAI_BASE_URL = active.baseUrl;
|
|
121
|
-
}
|
|
122
226
|
|
|
123
227
|
const profileApiKey = active.apiKey;
|
|
124
228
|
const resolvedProvider = active.provider || globalProvider;
|
|
229
|
+
const configProvider = selectConfigProviderForRuntime(
|
|
230
|
+
configDefaults,
|
|
231
|
+
sourceEnv,
|
|
232
|
+
resolvedProvider,
|
|
233
|
+
);
|
|
234
|
+
const runtimeBaseUrl =
|
|
235
|
+
clean(active.baseUrl) ||
|
|
236
|
+
clean(env.OPENAI_BASE_URL) ||
|
|
237
|
+
clean(configProvider?.baseUrl);
|
|
238
|
+
if (runtimeBaseUrl) {
|
|
239
|
+
const normalizedBaseUrl =
|
|
240
|
+
resolvedProvider === "azure"
|
|
241
|
+
? normalizeAzureOpenAIBaseUrl(runtimeBaseUrl)
|
|
242
|
+
: runtimeBaseUrl;
|
|
243
|
+
env.OPENAI_BASE_URL = normalizedBaseUrl;
|
|
244
|
+
active.baseUrl = normalizedBaseUrl;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!profileApiKey && configProvider?.envKey && hasEnvValue(sourceEnv, configProvider.envKey)) {
|
|
248
|
+
const configuredApiKey = clean(sourceEnv[configProvider.envKey]);
|
|
249
|
+
if (resolvedProvider === "azure") {
|
|
250
|
+
env.AZURE_OPENAI_API_KEY = configuredApiKey;
|
|
251
|
+
if (!env.OPENAI_API_KEY) {
|
|
252
|
+
env.OPENAI_API_KEY = configuredApiKey;
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
env.OPENAI_API_KEY = configuredApiKey;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
125
258
|
|
|
126
259
|
// Azure deployments often differ from default model names.
|
|
127
260
|
// If the env is using Azure and the model is still the default,
|
|
128
261
|
// prefer the top-level ~/.codex/config.toml model when present.
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
262
|
+
const activeProfileModelExplicit = Boolean(
|
|
263
|
+
readProfileField(sourceEnv, activeProfile, "MODEL"),
|
|
264
|
+
);
|
|
265
|
+
const runtimeModelExplicit = Boolean(clean(sourceEnv.CODEX_MODEL));
|
|
266
|
+
const activeModelValue = clean(env.CODEX_MODEL);
|
|
267
|
+
const shouldPreferAzureConfigModel =
|
|
133
268
|
resolvedProvider === "azure" &&
|
|
134
269
|
configModel &&
|
|
135
|
-
|
|
136
|
-
|
|
270
|
+
!activeProfileModelExplicit &&
|
|
271
|
+
!runtimeModelExplicit &&
|
|
272
|
+
!activeModelValue;
|
|
273
|
+
if (shouldPreferAzureConfigModel) {
|
|
137
274
|
env.CODEX_MODEL = configModel;
|
|
138
275
|
active.model = configModel;
|
|
139
276
|
}
|