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.
Files changed (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. 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 SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
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 SETTINGS_KNOWN_KEYS) {
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(`[telegram-ui] named tunnel credentials not found or invalid: ${normalizedCredsPath}`);
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 result = await execSdkCommand(command, args, adapter);
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, { ok: true, result: parsed, command, adapter: adapter || getPrimaryAgentName() });
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
- : process.env.TELEGRAM_UI_PORT
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 listenHost = options.host || DEFAULT_HOST;
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, listenHost);
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
- try {
9765
- await listenOnce(port);
9766
- } catch (err) {
9767
- const code = String(err?.code || "").toUpperCase();
9768
- const canRetryWithEphemeral =
9769
- allowEphemeralPort && port > 0 && (code === "EADDRINUSE" || code === "EACCES");
9770
- if (!canRetryWithEphemeral) throw err;
9771
- console.warn(
9772
- `[telegram-ui] failed to bind ${listenHost}:${port} (${code || "unknown"}); retrying with ephemeral port`,
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
- const originalUncaughtException = process.listeners("uncaughtException");
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
- terminal: process.stdin.isTTY && process.stdout.isTTY,
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
- const msg = err?.message || String(err || "");
842
- if (!msg.includes("setRawMode EIO")) {
843
- throw err;
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
- res(!a || a === "y" || a === "yes");
875
+ settle(!a || a === "y" || a === "yes");
848
876
  });
849
877
  });
850
878
  }