bosun 0.36.2 → 0.36.4
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-prompts.mjs +95 -0
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/bosun.schema.json +101 -3
- package/codex-shell.mjs +85 -10
- package/desktop/main.mjs +871 -48
- package/desktop/preload.mjs +54 -1
- package/desktop-shortcut.mjs +90 -11
- package/git-editor-fix.mjs +273 -0
- package/mcp-registry.mjs +579 -0
- package/meeting-workflow-service.mjs +631 -0
- package/monitor.mjs +18 -103
- package/package.json +21 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/setup-web-server.mjs +20 -10
- package/setup.mjs +376 -83
- package/startup-service.mjs +51 -6
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +164 -4
- package/ui/components/agent-selector.js +145 -1
- package/ui/components/chat-view.js +161 -15
- package/ui/components/session-list.js +2 -2
- package/ui/components/shared.js +188 -15
- package/ui/modules/icons.js +13 -0
- package/ui/modules/utils.js +44 -0
- package/ui/modules/voice-client-sdk.js +733 -0
- package/ui/modules/voice-overlay.js +128 -15
- package/ui/modules/voice.js +15 -6
- package/ui/setup.html +281 -81
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +122 -14
- package/ui/styles.css +14 -0
- package/ui/tabs/agents.js +1 -1
- package/ui/tabs/chat.js +123 -14
- package/ui/tabs/control.js +16 -22
- package/ui/tabs/dashboard.js +85 -8
- package/ui/tabs/library.js +113 -17
- package/ui/tabs/settings.js +116 -2
- package/ui/tabs/tasks.js +388 -39
- package/ui/tabs/telemetry.js +0 -1
- package/ui/tabs/workflows.js +4 -0
- package/ui-server.mjs +400 -22
- package/update-check.mjs +41 -13
- package/voice-action-dispatcher.mjs +844 -0
- package/voice-agents-sdk.mjs +664 -0
- package/voice-auth-manager.mjs +164 -0
- package/voice-relay.mjs +1194 -0
- package/voice-tools.mjs +914 -0
- package/workflow-templates/agents.mjs +6 -2
- package/workflow-templates/github.mjs +154 -12
- package/workflow-templates.mjs +3 -0
- package/github-reconciler.mjs +0 -506
- package/merge-strategy.mjs +0 -1210
- package/pr-cleanup-daemon.mjs +0 -992
- package/workspace-reaper.mjs +0 -405
package/ui-server.mjs
CHANGED
|
@@ -1253,6 +1253,85 @@ async function applySharedStateToTasks(tasks) {
|
|
|
1253
1253
|
});
|
|
1254
1254
|
}
|
|
1255
1255
|
|
|
1256
|
+
function normalizeCandidatePath(input) {
|
|
1257
|
+
if (!input) return "";
|
|
1258
|
+
const raw = String(input).trim();
|
|
1259
|
+
if (!raw) return "";
|
|
1260
|
+
try {
|
|
1261
|
+
return resolve(raw);
|
|
1262
|
+
} catch {
|
|
1263
|
+
return "";
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function pickWorkspaceRepoDir(workspace) {
|
|
1268
|
+
if (!workspace || typeof workspace !== "object") return "";
|
|
1269
|
+
const repos = Array.isArray(workspace.repos) ? workspace.repos : [];
|
|
1270
|
+
const activeRepoName = String(workspace.activeRepo || "").trim();
|
|
1271
|
+
const selectedRepo =
|
|
1272
|
+
(activeRepoName
|
|
1273
|
+
? repos.find((repo) => String(repo?.name || "").trim() === activeRepoName)
|
|
1274
|
+
: null) ||
|
|
1275
|
+
repos.find((repo) => repo?.primary) ||
|
|
1276
|
+
repos[0] ||
|
|
1277
|
+
null;
|
|
1278
|
+
|
|
1279
|
+
const candidates = [];
|
|
1280
|
+
const selectedRepoPath = normalizeCandidatePath(selectedRepo?.path);
|
|
1281
|
+
if (selectedRepoPath) candidates.push(selectedRepoPath);
|
|
1282
|
+
const workspacePath = normalizeCandidatePath(workspace.path);
|
|
1283
|
+
if (workspacePath && selectedRepo?.name) {
|
|
1284
|
+
const joined = normalizeCandidatePath(resolve(workspacePath, String(selectedRepo.name)));
|
|
1285
|
+
if (joined) candidates.push(joined);
|
|
1286
|
+
}
|
|
1287
|
+
if (workspacePath) candidates.push(workspacePath);
|
|
1288
|
+
|
|
1289
|
+
for (const candidate of candidates) {
|
|
1290
|
+
if (!candidate || !existsSync(candidate)) continue;
|
|
1291
|
+
if (existsSync(resolve(candidate, ".git"))) return candidate;
|
|
1292
|
+
}
|
|
1293
|
+
for (const candidate of candidates) {
|
|
1294
|
+
if (candidate && existsSync(candidate)) return candidate;
|
|
1295
|
+
}
|
|
1296
|
+
return "";
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function resolveActiveWorkspaceExecutionContext() {
|
|
1300
|
+
const fallback = { workspaceId: "", workspaceDir: repoRoot };
|
|
1301
|
+
const configDir = resolveUiConfigDir();
|
|
1302
|
+
if (!configDir) return fallback;
|
|
1303
|
+
|
|
1304
|
+
const listed = listManagedWorkspaces(configDir, { repoRoot });
|
|
1305
|
+
const active = getActiveManagedWorkspace(configDir);
|
|
1306
|
+
const activeId = String(active?.id || "").trim();
|
|
1307
|
+
const workspace =
|
|
1308
|
+
(activeId
|
|
1309
|
+
? listed.find((entry) => String(entry?.id || "") === activeId)
|
|
1310
|
+
: null) ||
|
|
1311
|
+
active ||
|
|
1312
|
+
listed[0] ||
|
|
1313
|
+
null;
|
|
1314
|
+
if (!workspace) return fallback;
|
|
1315
|
+
|
|
1316
|
+
const workspaceId = String(workspace.id || "").trim();
|
|
1317
|
+
const workspaceDir = pickWorkspaceRepoDir(workspace) || fallback.workspaceDir;
|
|
1318
|
+
return {
|
|
1319
|
+
workspaceId,
|
|
1320
|
+
workspaceDir,
|
|
1321
|
+
};
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function resolveSessionWorkspaceDir(session = null) {
|
|
1325
|
+
const metadata =
|
|
1326
|
+
session && typeof session.metadata === "object" && session.metadata
|
|
1327
|
+
? session.metadata
|
|
1328
|
+
: null;
|
|
1329
|
+
const explicit = normalizeCandidatePath(metadata?.workspaceDir);
|
|
1330
|
+
if (explicit && existsSync(explicit)) return explicit;
|
|
1331
|
+
const context = resolveActiveWorkspaceExecutionContext();
|
|
1332
|
+
return context.workspaceDir || repoRoot;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1256
1335
|
function normalizeWorktreePath(input) {
|
|
1257
1336
|
if (!input) return "";
|
|
1258
1337
|
try {
|
|
@@ -2359,7 +2438,11 @@ const SETTINGS_SENSITIVE_KEYS = new Set([
|
|
|
2359
2438
|
"CLOUDFLARE_TUNNEL_CREDENTIALS", "CLOUDFLARE_API_TOKEN",
|
|
2360
2439
|
]);
|
|
2361
2440
|
|
|
2362
|
-
const
|
|
2441
|
+
const SETTINGS_SCHEMA_KEYS = SETTINGS_SCHEMA
|
|
2442
|
+
.map((def) => String(def?.key || "").trim())
|
|
2443
|
+
.filter((key) => key && !key.startsWith("_"));
|
|
2444
|
+
const SETTINGS_KNOWN_SET = new Set([...SETTINGS_KNOWN_KEYS, ...SETTINGS_SCHEMA_KEYS]);
|
|
2445
|
+
const SETTINGS_EFFECTIVE_KEYS = Array.from(SETTINGS_KNOWN_SET);
|
|
2363
2446
|
let _settingsLastUpdateTime = 0;
|
|
2364
2447
|
const ASYNC_UI_COMMAND_BASES = new Set(["/plan"]);
|
|
2365
2448
|
|
|
@@ -2411,7 +2494,7 @@ function buildSettingsResponseData() {
|
|
|
2411
2494
|
);
|
|
2412
2495
|
const { configData } = readConfigDocument();
|
|
2413
2496
|
|
|
2414
|
-
for (const key of
|
|
2497
|
+
for (const key of SETTINGS_EFFECTIVE_KEYS) {
|
|
2415
2498
|
const def = defsByKey.get(key);
|
|
2416
2499
|
let rawValue = process.env[key];
|
|
2417
2500
|
let source = hasSettingValue(rawValue) ? "env" : "unset";
|
|
@@ -2447,7 +2530,7 @@ function buildSettingsResponseData() {
|
|
|
2447
2530
|
}
|
|
2448
2531
|
|
|
2449
2532
|
const displayValue = toSettingsDisplayValue(def, rawValue);
|
|
2450
|
-
if (SETTINGS_SENSITIVE_KEYS.has(key)) {
|
|
2533
|
+
if (SETTINGS_SENSITIVE_KEYS.has(key) || def?.sensitive) {
|
|
2451
2534
|
data[key] = displayValue ? "••••••" : "";
|
|
2452
2535
|
} else {
|
|
2453
2536
|
data[key] = displayValue;
|
|
@@ -3611,7 +3694,9 @@ async function startNamedTunnel(cfBin, { tunnelName, credentialsPath }, localPor
|
|
|
3611
3694
|
mode: TUNNEL_MODE_NAMED,
|
|
3612
3695
|
});
|
|
3613
3696
|
console.warn(
|
|
3614
|
-
"[telegram-ui] named tunnel requires CLOUDFLARE_TUNNEL_NAME + CLOUDFLARE_TUNNEL_CREDENTIALS"
|
|
3697
|
+
"[telegram-ui] named tunnel requires CLOUDFLARE_TUNNEL_NAME + CLOUDFLARE_TUNNEL_CREDENTIALS.\n" +
|
|
3698
|
+
"[telegram-ui] Run \"bosun --setup\" and choose \"Named tunnel\" to configure Cloudflare credentials,\n" +
|
|
3699
|
+
"[telegram-ui] or set TELEGRAM_UI_TUNNEL=quick for an ephemeral trycloudflare.com URL.",
|
|
3615
3700
|
);
|
|
3616
3701
|
return null;
|
|
3617
3702
|
}
|
|
@@ -3623,7 +3708,11 @@ async function startNamedTunnel(cfBin, { tunnelName, credentialsPath }, localPor
|
|
|
3623
3708
|
mode: TUNNEL_MODE_NAMED,
|
|
3624
3709
|
tunnelName: normalizedTunnelName,
|
|
3625
3710
|
});
|
|
3626
|
-
console.warn(
|
|
3711
|
+
console.warn(
|
|
3712
|
+
`[telegram-ui] named tunnel credentials not found or invalid: ${normalizedCredsPath}\n` +
|
|
3713
|
+
`[telegram-ui] Ensure the path is correct and the file exists.\n` +
|
|
3714
|
+
`[telegram-ui] Re-run "bosun --setup" to reconfigure Cloudflare tunnel credentials.`,
|
|
3715
|
+
);
|
|
3627
3716
|
return null;
|
|
3628
3717
|
}
|
|
3629
3718
|
|
|
@@ -4210,6 +4299,34 @@ function checkSessionToken(req) {
|
|
|
4210
4299
|
return false;
|
|
4211
4300
|
}
|
|
4212
4301
|
|
|
4302
|
+
/**
|
|
4303
|
+
* Check whether the request carries a valid desktop API key.
|
|
4304
|
+
*
|
|
4305
|
+
* The Electron main process sets BOSUN_DESKTOP_API_KEY in process.env before
|
|
4306
|
+
* starting (or connecting to) the UI server. When that env var is present any
|
|
4307
|
+
* request bearing the matching Bearer token is treated as fully authenticated —
|
|
4308
|
+
* no session cookie or Telegram initData required.
|
|
4309
|
+
*
|
|
4310
|
+
* This allows the desktop app to connect to a separately-running daemon server
|
|
4311
|
+
* without needing to share the TTL-based session token.
|
|
4312
|
+
*/
|
|
4313
|
+
function checkDesktopApiKey(req) {
|
|
4314
|
+
const expected = (process.env.BOSUN_DESKTOP_API_KEY || "").trim();
|
|
4315
|
+
if (!expected) return false;
|
|
4316
|
+
const authHeader = req.headers.authorization || "";
|
|
4317
|
+
if (!authHeader.startsWith("Bearer ")) return false;
|
|
4318
|
+
const provided = authHeader.slice(7).trim();
|
|
4319
|
+
if (!provided) return false;
|
|
4320
|
+
try {
|
|
4321
|
+
const a = Buffer.from(provided);
|
|
4322
|
+
const b = Buffer.from(expected);
|
|
4323
|
+
if (a.length !== b.length) return false;
|
|
4324
|
+
return timingSafeEqual(a, b);
|
|
4325
|
+
} catch {
|
|
4326
|
+
return false;
|
|
4327
|
+
}
|
|
4328
|
+
}
|
|
4329
|
+
|
|
4213
4330
|
function getTelegramInitData(req, url = null) {
|
|
4214
4331
|
return String(
|
|
4215
4332
|
req.headers["x-telegram-initdata"] ||
|
|
@@ -4235,6 +4352,8 @@ function getHeaderString(value) {
|
|
|
4235
4352
|
|
|
4236
4353
|
async function requireAuth(req) {
|
|
4237
4354
|
if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
|
|
4355
|
+
// Desktop Electron API key — non-expiring, set via BOSUN_DESKTOP_API_KEY env
|
|
4356
|
+
if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: false };
|
|
4238
4357
|
// Session token (browser access)
|
|
4239
4358
|
if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
|
|
4240
4359
|
// Telegram initData HMAC
|
|
@@ -4258,6 +4377,22 @@ async function requireAuth(req) {
|
|
|
4258
4377
|
|
|
4259
4378
|
function requireWsAuth(req, url) {
|
|
4260
4379
|
if (isAllowUnsafe()) return true;
|
|
4380
|
+
// Desktop Electron API key (query param: desktopKey=...)
|
|
4381
|
+
const desktopKey = (process.env.BOSUN_DESKTOP_API_KEY || "").trim();
|
|
4382
|
+
if (desktopKey) {
|
|
4383
|
+
const qDesktopKey = url.searchParams.get("desktopKey") || "";
|
|
4384
|
+
if (qDesktopKey) {
|
|
4385
|
+
try {
|
|
4386
|
+
const a = Buffer.from(qDesktopKey);
|
|
4387
|
+
const b = Buffer.from(desktopKey);
|
|
4388
|
+
if (a.length === b.length && timingSafeEqual(a, b)) return true;
|
|
4389
|
+
} catch {
|
|
4390
|
+
/* ignore */
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
// Also accept via Authorization header (for WS upgrade requests that support it)
|
|
4394
|
+
if (checkDesktopApiKey(req)) return true;
|
|
4395
|
+
}
|
|
4261
4396
|
// Session token (query param or cookie)
|
|
4262
4397
|
if (checkSessionToken(req)) return true;
|
|
4263
4398
|
if (sessionToken) {
|
|
@@ -8490,9 +8625,28 @@ async function handleApi(req, res, url) {
|
|
|
8490
8625
|
}
|
|
8491
8626
|
const args = (body?.args || "").trim();
|
|
8492
8627
|
const adapter = (body?.adapter || "").trim() || undefined;
|
|
8493
|
-
const
|
|
8628
|
+
const requestedSessionId = String(body?.sessionId || "").trim();
|
|
8629
|
+
const tracker = getSessionTracker();
|
|
8630
|
+
const commandSession = requestedSessionId
|
|
8631
|
+
? tracker.getSessionById(requestedSessionId)
|
|
8632
|
+
: null;
|
|
8633
|
+
const commandCwd = resolveSessionWorkspaceDir(commandSession);
|
|
8634
|
+
const runSdkCommand =
|
|
8635
|
+
typeof uiDeps.execSdkCommand === "function"
|
|
8636
|
+
? uiDeps.execSdkCommand
|
|
8637
|
+
: execSdkCommand;
|
|
8638
|
+
const result = await runSdkCommand(command, args, adapter, {
|
|
8639
|
+
cwd: commandCwd,
|
|
8640
|
+
sessionId: requestedSessionId || undefined,
|
|
8641
|
+
});
|
|
8494
8642
|
const parsed = typeof result === "string" ? result : JSON.stringify(result);
|
|
8495
|
-
jsonResponse(res, 200, {
|
|
8643
|
+
jsonResponse(res, 200, {
|
|
8644
|
+
ok: true,
|
|
8645
|
+
result: parsed,
|
|
8646
|
+
command,
|
|
8647
|
+
adapter: adapter || getPrimaryAgentName(),
|
|
8648
|
+
sessionId: requestedSessionId || null,
|
|
8649
|
+
});
|
|
8496
8650
|
broadcastUiEvent(["agents", "sessions"], "invalidate", {
|
|
8497
8651
|
reason: "sdk-command-executed",
|
|
8498
8652
|
command,
|
|
@@ -8659,6 +8813,13 @@ async function handleApi(req, res, url) {
|
|
|
8659
8813
|
const body = await readJsonBody(req);
|
|
8660
8814
|
const type = body?.type || "manual";
|
|
8661
8815
|
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
8816
|
+
const workspaceContext = resolveActiveWorkspaceExecutionContext();
|
|
8817
|
+
const requestedWorkspaceId = String(body?.workspaceId || "").trim();
|
|
8818
|
+
const requestedWorkspaceDir = normalizeCandidatePath(body?.workspaceDir);
|
|
8819
|
+
const resolvedWorkspaceId =
|
|
8820
|
+
requestedWorkspaceId || workspaceContext.workspaceId;
|
|
8821
|
+
const resolvedWorkspaceDir =
|
|
8822
|
+
requestedWorkspaceDir || workspaceContext.workspaceDir || repoRoot;
|
|
8662
8823
|
const tracker = getSessionTracker();
|
|
8663
8824
|
const session = tracker.createSession({
|
|
8664
8825
|
id,
|
|
@@ -8668,6 +8829,8 @@ async function handleApi(req, res, url) {
|
|
|
8668
8829
|
agent: body?.agent || getPrimaryAgentName(),
|
|
8669
8830
|
mode: body?.mode || getAgentMode(),
|
|
8670
8831
|
model: body?.model || undefined,
|
|
8832
|
+
...(resolvedWorkspaceId ? { workspaceId: resolvedWorkspaceId } : {}),
|
|
8833
|
+
...(resolvedWorkspaceDir ? { workspaceDir: resolvedWorkspaceDir } : {}),
|
|
8671
8834
|
},
|
|
8672
8835
|
});
|
|
8673
8836
|
jsonResponse(res, 200, { ok: true, session: { id: session.id, type: session.type, status: session.status, metadata: session.metadata } });
|
|
@@ -8813,6 +8976,7 @@ async function handleApi(req, res, url) {
|
|
|
8813
8976
|
exec = await resolveExecPrimaryPrompt();
|
|
8814
8977
|
}
|
|
8815
8978
|
if (exec) {
|
|
8979
|
+
const sessionWorkspaceDir = resolveSessionWorkspaceDir(session);
|
|
8816
8980
|
// Don't record user event here — execPrimaryPrompt records it
|
|
8817
8981
|
// Respond immediately so the UI doesn't block on agent execution
|
|
8818
8982
|
jsonResponse(res, 200, { ok: true, messageId });
|
|
@@ -8853,6 +9017,7 @@ async function handleApi(req, res, url) {
|
|
|
8853
9017
|
sessionType: "primary",
|
|
8854
9018
|
mode: messageMode,
|
|
8855
9019
|
model: messageModel,
|
|
9020
|
+
cwd: sessionWorkspaceDir,
|
|
8856
9021
|
persistent: true,
|
|
8857
9022
|
sendRawEvents: true,
|
|
8858
9023
|
attachments,
|
|
@@ -8893,6 +9058,44 @@ async function handleApi(req, res, url) {
|
|
|
8893
9058
|
return;
|
|
8894
9059
|
}
|
|
8895
9060
|
|
|
9061
|
+
if (action === "message/edit" && req.method === "POST") {
|
|
9062
|
+
try {
|
|
9063
|
+
const tracker = getSessionTracker();
|
|
9064
|
+
const session = tracker.getSessionById(sessionId);
|
|
9065
|
+
if (!session) {
|
|
9066
|
+
jsonResponse(res, 404, { ok: false, error: "Session not found" });
|
|
9067
|
+
return;
|
|
9068
|
+
}
|
|
9069
|
+
const body = await readJsonBody(req);
|
|
9070
|
+
const content = String(body?.content || "").trim();
|
|
9071
|
+
if (!content) {
|
|
9072
|
+
jsonResponse(res, 400, { ok: false, error: "content is required" });
|
|
9073
|
+
return;
|
|
9074
|
+
}
|
|
9075
|
+
|
|
9076
|
+
const edited = tracker.editUserMessage(sessionId, {
|
|
9077
|
+
messageId: body?.messageId,
|
|
9078
|
+
timestamp: body?.timestamp,
|
|
9079
|
+
previousContent: body?.previousContent,
|
|
9080
|
+
content,
|
|
9081
|
+
});
|
|
9082
|
+
if (!edited?.ok) {
|
|
9083
|
+
const status = edited?.error === "Message not found" ? 404 : 400;
|
|
9084
|
+
jsonResponse(res, status, { ok: false, error: edited?.error || "edit failed" });
|
|
9085
|
+
return;
|
|
9086
|
+
}
|
|
9087
|
+
|
|
9088
|
+
jsonResponse(res, 200, { ok: true, message: edited.message });
|
|
9089
|
+
broadcastUiEvent(["sessions"], "invalidate", {
|
|
9090
|
+
reason: "session-message-edited",
|
|
9091
|
+
sessionId,
|
|
9092
|
+
});
|
|
9093
|
+
} catch (err) {
|
|
9094
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9095
|
+
}
|
|
9096
|
+
return;
|
|
9097
|
+
}
|
|
9098
|
+
|
|
8896
9099
|
if (action === "archive" && req.method === "POST") {
|
|
8897
9100
|
try {
|
|
8898
9101
|
const tracker = getSessionTracker();
|
|
@@ -9010,6 +9213,25 @@ async function handleApi(req, res, url) {
|
|
|
9010
9213
|
|
|
9011
9214
|
// ── Voice API Routes ──────────────────────────────────────────────────────
|
|
9012
9215
|
|
|
9216
|
+
// GET /api/voice/sdk-config — SDK-first configuration for client
|
|
9217
|
+
if (path === "/api/voice/sdk-config" && req.method === "GET") {
|
|
9218
|
+
try {
|
|
9219
|
+
const { getVoiceConfig } = await import("./voice-relay.mjs");
|
|
9220
|
+
const { getClientSdkConfig } = await import("./voice-agents-sdk.mjs");
|
|
9221
|
+
const voiceConfig = getVoiceConfig();
|
|
9222
|
+
const sdkConfig = await getClientSdkConfig(voiceConfig);
|
|
9223
|
+
jsonResponse(res, 200, sdkConfig);
|
|
9224
|
+
} catch (err) {
|
|
9225
|
+
jsonResponse(res, 200, {
|
|
9226
|
+
useSdk: false,
|
|
9227
|
+
provider: "fallback",
|
|
9228
|
+
fallbackReason: err.message,
|
|
9229
|
+
tier: 2,
|
|
9230
|
+
});
|
|
9231
|
+
}
|
|
9232
|
+
return;
|
|
9233
|
+
}
|
|
9234
|
+
|
|
9013
9235
|
// GET /api/voice/config
|
|
9014
9236
|
if (path === "/api/voice/config" && req.method === "GET") {
|
|
9015
9237
|
try {
|
|
@@ -9022,6 +9244,7 @@ async function handleApi(req, res, url) {
|
|
|
9022
9244
|
available: availability.available,
|
|
9023
9245
|
tier: availability.tier,
|
|
9024
9246
|
provider: availability.provider,
|
|
9247
|
+
providerChain: config.providerChainWithFallbacks || [config.provider],
|
|
9025
9248
|
reason: availability.reason || "",
|
|
9026
9249
|
voiceId: config.voiceId,
|
|
9027
9250
|
turnDetection: config.turnDetection,
|
|
@@ -9033,6 +9256,8 @@ async function handleApi(req, res, url) {
|
|
|
9033
9256
|
defaultIntervalMs: DEFAULT_VISION_ANALYSIS_INTERVAL_MS,
|
|
9034
9257
|
},
|
|
9035
9258
|
fallbackMode: config.fallbackMode,
|
|
9259
|
+
failover: config.failover || null,
|
|
9260
|
+
diagnostics: Array.isArray(config.diagnostics) ? config.diagnostics : [],
|
|
9036
9261
|
connectionInfo,
|
|
9037
9262
|
});
|
|
9038
9263
|
} catch (err) {
|
|
@@ -9051,13 +9276,24 @@ async function handleApi(req, res, url) {
|
|
|
9051
9276
|
mode: String(body?.mode || "").trim() || undefined,
|
|
9052
9277
|
model: String(body?.model || "").trim() || undefined,
|
|
9053
9278
|
};
|
|
9054
|
-
const { createEphemeralToken, getVoiceToolDefinitions } = await import("./voice-relay.mjs");
|
|
9279
|
+
const { createEphemeralToken, getVoiceToolDefinitions, getVoiceConfig } = await import("./voice-relay.mjs");
|
|
9055
9280
|
const delegateOnly =
|
|
9056
9281
|
body?.delegateOnly === true ||
|
|
9057
9282
|
(body?.delegateOnly !== false && Boolean(callContext.sessionId));
|
|
9058
9283
|
const tools = await getVoiceToolDefinitions({ delegateOnly });
|
|
9059
9284
|
const tokenData = await createEphemeralToken(tools, callContext);
|
|
9060
9285
|
|
|
9286
|
+
// When client requests sdkMode, include extra fields for @openai/agents SDK
|
|
9287
|
+
if (body?.sdkMode === true) {
|
|
9288
|
+
const voiceCfg = getVoiceConfig();
|
|
9289
|
+
tokenData.instructions = voiceCfg.instructions || undefined;
|
|
9290
|
+
tokenData.tools = tools;
|
|
9291
|
+
if (tokenData.provider === "azure") {
|
|
9292
|
+
tokenData.azureEndpoint = voiceCfg.azureEndpoint || undefined;
|
|
9293
|
+
tokenData.azureDeployment = voiceCfg.azureDeployment || undefined;
|
|
9294
|
+
}
|
|
9295
|
+
}
|
|
9296
|
+
|
|
9061
9297
|
jsonResponse(res, 200, tokenData);
|
|
9062
9298
|
} catch (err) {
|
|
9063
9299
|
jsonResponse(res, 500, { error: err.message });
|
|
@@ -9328,6 +9564,94 @@ async function handleApi(req, res, url) {
|
|
|
9328
9564
|
return;
|
|
9329
9565
|
}
|
|
9330
9566
|
|
|
9567
|
+
// POST /api/voice/dispatch — Execute a voice action intent (direct JavaScript, no MCP)
|
|
9568
|
+
if (path === "/api/voice/dispatch" && req.method === "POST") {
|
|
9569
|
+
try {
|
|
9570
|
+
const body = await readJsonBody(req);
|
|
9571
|
+
const action = String(body?.action || "").trim();
|
|
9572
|
+
if (!action) {
|
|
9573
|
+
jsonResponse(res, 400, { ok: false, error: "action is required" });
|
|
9574
|
+
return;
|
|
9575
|
+
}
|
|
9576
|
+
const context = {
|
|
9577
|
+
sessionId: String(body?.sessionId || "").trim() || undefined,
|
|
9578
|
+
executor: String(body?.executor || "").trim() || undefined,
|
|
9579
|
+
mode: String(body?.mode || "").trim() || undefined,
|
|
9580
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9581
|
+
};
|
|
9582
|
+
const { dispatchVoiceActionIntent } = await import("./voice-relay.mjs");
|
|
9583
|
+
const result = await dispatchVoiceActionIntent(
|
|
9584
|
+
{ action, params: body?.params || {}, id: body?.id || undefined },
|
|
9585
|
+
context,
|
|
9586
|
+
);
|
|
9587
|
+
jsonResponse(res, 200, result);
|
|
9588
|
+
} catch (err) {
|
|
9589
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9590
|
+
}
|
|
9591
|
+
return;
|
|
9592
|
+
}
|
|
9593
|
+
|
|
9594
|
+
// POST /api/voice/dispatch-batch — Execute multiple voice action intents
|
|
9595
|
+
if (path === "/api/voice/dispatch-batch" && req.method === "POST") {
|
|
9596
|
+
try {
|
|
9597
|
+
const body = await readJsonBody(req);
|
|
9598
|
+
const intents = Array.isArray(body?.actions) ? body.actions : [];
|
|
9599
|
+
if (!intents.length) {
|
|
9600
|
+
jsonResponse(res, 400, { ok: false, error: "actions array is required" });
|
|
9601
|
+
return;
|
|
9602
|
+
}
|
|
9603
|
+
const context = {
|
|
9604
|
+
sessionId: String(body?.sessionId || "").trim() || undefined,
|
|
9605
|
+
executor: String(body?.executor || "").trim() || undefined,
|
|
9606
|
+
mode: String(body?.mode || "").trim() || undefined,
|
|
9607
|
+
model: String(body?.model || "").trim() || undefined,
|
|
9608
|
+
};
|
|
9609
|
+
const { dispatchVoiceActionIntents } = await import("./voice-relay.mjs");
|
|
9610
|
+
const results = await dispatchVoiceActionIntents(intents, context);
|
|
9611
|
+
jsonResponse(res, 200, { ok: true, results });
|
|
9612
|
+
} catch (err) {
|
|
9613
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9614
|
+
}
|
|
9615
|
+
return;
|
|
9616
|
+
}
|
|
9617
|
+
|
|
9618
|
+
// GET /api/voice/actions — List all available voice actions
|
|
9619
|
+
if (path === "/api/voice/actions" && req.method === "GET") {
|
|
9620
|
+
try {
|
|
9621
|
+
const { listVoiceActions } = await import("./voice-relay.mjs");
|
|
9622
|
+
const actions = await listVoiceActions();
|
|
9623
|
+
jsonResponse(res, 200, { ok: true, actions });
|
|
9624
|
+
} catch (err) {
|
|
9625
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9626
|
+
}
|
|
9627
|
+
return;
|
|
9628
|
+
}
|
|
9629
|
+
|
|
9630
|
+
// GET /api/voice/prompt — Get the full voice agent prompt with action manifest
|
|
9631
|
+
if (path === "/api/voice/prompt" && req.method === "GET") {
|
|
9632
|
+
try {
|
|
9633
|
+
const compact = url.searchParams?.get("compact") === "true";
|
|
9634
|
+
const { buildVoiceAgentPrompt } = await import("./voice-relay.mjs");
|
|
9635
|
+
const prompt = await buildVoiceAgentPrompt({ compact });
|
|
9636
|
+
jsonResponse(res, 200, { ok: true, prompt });
|
|
9637
|
+
} catch (err) {
|
|
9638
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9639
|
+
}
|
|
9640
|
+
return;
|
|
9641
|
+
}
|
|
9642
|
+
|
|
9643
|
+
// GET /api/voice/action-manifest — Get the action manifest for prompt injection
|
|
9644
|
+
if (path === "/api/voice/action-manifest" && req.method === "GET") {
|
|
9645
|
+
try {
|
|
9646
|
+
const { getVoiceActionManifest } = await import("./voice-relay.mjs");
|
|
9647
|
+
const manifest = await getVoiceActionManifest();
|
|
9648
|
+
jsonResponse(res, 200, { ok: true, manifest });
|
|
9649
|
+
} catch (err) {
|
|
9650
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
9651
|
+
}
|
|
9652
|
+
return;
|
|
9653
|
+
}
|
|
9654
|
+
|
|
9331
9655
|
jsonResponse(res, 404, { ok: false, error: "Unknown API endpoint" });
|
|
9332
9656
|
}
|
|
9333
9657
|
|
|
@@ -9421,14 +9745,18 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
9421
9745
|
shouldReusePersistedPort
|
|
9422
9746
|
? persistedPort
|
|
9423
9747
|
: configuredPort;
|
|
9748
|
+
const hasExplicitEnvPort =
|
|
9749
|
+
Number.isFinite(Number(process.env.TELEGRAM_UI_PORT || "")) &&
|
|
9750
|
+
Number(process.env.TELEGRAM_UI_PORT || "") > 0;
|
|
9424
9751
|
const portSource =
|
|
9425
9752
|
shouldReusePersistedPort
|
|
9426
9753
|
? "cache.ui-last-port"
|
|
9427
9754
|
: options.port != null
|
|
9428
9755
|
? "options.port"
|
|
9429
|
-
:
|
|
9756
|
+
: hasExplicitEnvPort
|
|
9430
9757
|
? "env.TELEGRAM_UI_PORT"
|
|
9431
9758
|
: `default(${DEFAULT_TELEGRAM_UI_PORT})`;
|
|
9759
|
+
const usingFallbackDefaultPort = portSource.startsWith("default(");
|
|
9432
9760
|
|
|
9433
9761
|
if (!Number.isFinite(port) || port < 0) {
|
|
9434
9762
|
console.warn(
|
|
@@ -9500,6 +9828,37 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
9500
9828
|
}
|
|
9501
9829
|
}
|
|
9502
9830
|
|
|
9831
|
+
// Desktop API key exchange: ?desktopKey=<key> → set session cookie and redirect to clean URL
|
|
9832
|
+
// Used by the Electron desktop app to bootstrap authenticated WebView sessions
|
|
9833
|
+
// without depending on the TTL-based session token.
|
|
9834
|
+
const qDesktopKey = url.searchParams.get("desktopKey");
|
|
9835
|
+
if (qDesktopKey) {
|
|
9836
|
+
const expectedDesktopKey = (process.env.BOSUN_DESKTOP_API_KEY || "").trim();
|
|
9837
|
+
if (expectedDesktopKey) {
|
|
9838
|
+
try {
|
|
9839
|
+
const a = Buffer.from(qDesktopKey);
|
|
9840
|
+
const b = Buffer.from(expectedDesktopKey);
|
|
9841
|
+
if (a.length === b.length && timingSafeEqual(a, b)) {
|
|
9842
|
+
const cleanUrl = new URL(url.toString());
|
|
9843
|
+
cleanUrl.searchParams.delete("desktopKey");
|
|
9844
|
+
const redirectPath =
|
|
9845
|
+
cleanUrl.pathname +
|
|
9846
|
+
(cleanUrl.searchParams.toString()
|
|
9847
|
+
? `?${cleanUrl.searchParams.toString()}`
|
|
9848
|
+
: "");
|
|
9849
|
+
res.writeHead(302, {
|
|
9850
|
+
"Set-Cookie": buildSessionCookieHeader(),
|
|
9851
|
+
Location: redirectPath || "/",
|
|
9852
|
+
});
|
|
9853
|
+
res.end();
|
|
9854
|
+
return;
|
|
9855
|
+
}
|
|
9856
|
+
} catch {
|
|
9857
|
+
/* malformed key — fall through to normal auth */
|
|
9858
|
+
}
|
|
9859
|
+
}
|
|
9860
|
+
}
|
|
9861
|
+
|
|
9503
9862
|
if (url.pathname === webhookPath) {
|
|
9504
9863
|
await handleGitHubProjectWebhook(req, res);
|
|
9505
9864
|
return;
|
|
@@ -9745,7 +10104,8 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
9745
10104
|
// Reuse a recent session token when possible so browser sessions survive restarts.
|
|
9746
10105
|
ensureSessionToken();
|
|
9747
10106
|
|
|
9748
|
-
const
|
|
10107
|
+
const host = options.host || DEFAULT_HOST;
|
|
10108
|
+
const maxPortFallbackAttempts = usingFallbackDefaultPort ? 20 : 0;
|
|
9749
10109
|
const listenOnce = (targetPort) =>
|
|
9750
10110
|
new Promise((resolveReady, rejectReady) => {
|
|
9751
10111
|
const onError = (err) => {
|
|
@@ -9758,20 +10118,38 @@ export async function startTelegramUiServer(options = {}) {
|
|
|
9758
10118
|
};
|
|
9759
10119
|
uiServer.once("error", onError);
|
|
9760
10120
|
uiServer.once("listening", onListening);
|
|
9761
|
-
uiServer.listen(targetPort,
|
|
10121
|
+
uiServer.listen(targetPort, host);
|
|
9762
10122
|
});
|
|
10123
|
+
let listenPort = port;
|
|
10124
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
10125
|
+
try {
|
|
10126
|
+
await listenOnce(listenPort);
|
|
10127
|
+
break;
|
|
10128
|
+
} catch (err) {
|
|
10129
|
+
const isAddrInUse = err?.code === "EADDRINUSE";
|
|
10130
|
+
const canRetryPortIncrement =
|
|
10131
|
+
isAddrInUse &&
|
|
10132
|
+
attempt < maxPortFallbackAttempts &&
|
|
10133
|
+
listenPort > 0;
|
|
10134
|
+
if (canRetryPortIncrement) {
|
|
10135
|
+
const nextPort = listenPort + 1;
|
|
10136
|
+
console.warn(
|
|
10137
|
+
`[telegram-ui] port ${listenPort} in use; retrying on ${nextPort} (attempt ${attempt + 1}/${maxPortFallbackAttempts})`,
|
|
10138
|
+
);
|
|
10139
|
+
listenPort = nextPort;
|
|
10140
|
+
continue;
|
|
10141
|
+
}
|
|
9763
10142
|
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
9769
|
-
|
|
9770
|
-
|
|
9771
|
-
|
|
9772
|
-
|
|
9773
|
-
|
|
9774
|
-
await listenOnce(0);
|
|
10143
|
+
const code = String(err?.code || "").toUpperCase();
|
|
10144
|
+
const canRetryWithEphemeral =
|
|
10145
|
+
allowEphemeralPort && listenPort > 0 && (code === "EADDRINUSE" || code === "EACCES");
|
|
10146
|
+
if (!canRetryWithEphemeral) throw err;
|
|
10147
|
+
console.warn(
|
|
10148
|
+
`[telegram-ui] failed to bind ${host}:${listenPort} (${code || "unknown"}); retrying with ephemeral port`,
|
|
10149
|
+
);
|
|
10150
|
+
await listenOnce(0);
|
|
10151
|
+
break;
|
|
10152
|
+
}
|
|
9775
10153
|
}
|
|
9776
10154
|
} catch (err) {
|
|
9777
10155
|
releaseUiInstanceLock();
|
package/update-check.mjs
CHANGED
|
@@ -529,6 +529,12 @@ let parentPid = null;
|
|
|
529
529
|
let parentCheckInterval = null;
|
|
530
530
|
let cleanupHandlersRegistered = false;
|
|
531
531
|
|
|
532
|
+
function isSuppressedStreamNoiseError(err) {
|
|
533
|
+
const msg = String(err?.message || err || "");
|
|
534
|
+
if (!msg) return false;
|
|
535
|
+
return msg.includes("setRawMode EIO") || msg.includes("read EIO");
|
|
536
|
+
}
|
|
537
|
+
|
|
532
538
|
/**
|
|
533
539
|
* Start a background polling loop that checks for updates every `intervalMs`
|
|
534
540
|
* (default 10 min). When a newer version is found, it:
|
|
@@ -800,17 +806,17 @@ function registerCleanupHandlers() {
|
|
|
800
806
|
stopAutoUpdateLoop();
|
|
801
807
|
});
|
|
802
808
|
|
|
803
|
-
// Handle uncaught exceptions (last resort)
|
|
804
|
-
|
|
809
|
+
// Handle uncaught exceptions (last resort). Do not manually re-emit previous
|
|
810
|
+
// listeners: Node invokes all uncaughtException handlers automatically.
|
|
805
811
|
process.on("uncaughtException", (err) => {
|
|
812
|
+
if (isSuppressedStreamNoiseError(err)) {
|
|
813
|
+
console.log(
|
|
814
|
+
`[auto-update] suppressed stream noise (uncaughtException): ${err?.message || err}`,
|
|
815
|
+
);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
806
818
|
console.error(`[auto-update] Uncaught exception, cleaning up:`, err);
|
|
807
819
|
stopAutoUpdateLoop();
|
|
808
|
-
// Re-emit for other handlers
|
|
809
|
-
if (originalUncaughtException.length > 0) {
|
|
810
|
-
for (const handler of originalUncaughtException) {
|
|
811
|
-
handler(err);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
820
|
});
|
|
815
821
|
}
|
|
816
822
|
|
|
@@ -829,22 +835,44 @@ function printUpdateNotice(current, latest) {
|
|
|
829
835
|
|
|
830
836
|
function promptConfirm(question) {
|
|
831
837
|
return new Promise((res) => {
|
|
838
|
+
let settled = false;
|
|
839
|
+
const settle = (value) => {
|
|
840
|
+
if (settled) return;
|
|
841
|
+
settled = true;
|
|
842
|
+
res(Boolean(value));
|
|
843
|
+
};
|
|
832
844
|
const rl = createInterface({
|
|
833
845
|
input: process.stdin,
|
|
834
846
|
output: process.stdout,
|
|
835
|
-
|
|
847
|
+
// Keep readline out of raw-mode transitions; they can throw EIO during
|
|
848
|
+
// terminal teardown in daemonized/non-interactive contexts.
|
|
849
|
+
terminal: false,
|
|
850
|
+
});
|
|
851
|
+
rl.on("error", (err) => {
|
|
852
|
+
if (isSuppressedStreamNoiseError(err)) {
|
|
853
|
+
console.log(
|
|
854
|
+
`[auto-update] suppressed stream noise (readline): ${err?.message || err}`,
|
|
855
|
+
);
|
|
856
|
+
settle(false);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
console.warn(`[auto-update] prompt failed: ${err?.message || err}`);
|
|
860
|
+
settle(false);
|
|
836
861
|
});
|
|
837
862
|
rl.question(question, (answer) => {
|
|
838
863
|
try {
|
|
839
864
|
rl.close();
|
|
840
865
|
} catch (err) {
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
866
|
+
if (isSuppressedStreamNoiseError(err)) {
|
|
867
|
+
console.log(
|
|
868
|
+
`[auto-update] suppressed stream noise (readline close): ${err?.message || err}`,
|
|
869
|
+
);
|
|
870
|
+
} else {
|
|
871
|
+
console.warn(`[auto-update] prompt close failed: ${err?.message || err}`);
|
|
844
872
|
}
|
|
845
873
|
}
|
|
846
874
|
const a = answer.trim().toLowerCase();
|
|
847
|
-
|
|
875
|
+
settle(!a || a === "y" || a === "yes");
|
|
848
876
|
});
|
|
849
877
|
});
|
|
850
878
|
}
|