bosun 0.34.2 → 0.34.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/telegram-bot.mjs CHANGED
@@ -118,6 +118,37 @@ import {
118
118
  } from "./presence.mjs";
119
119
 
120
120
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
121
+
122
+ function isWslInteropRuntime() {
123
+ return Boolean(
124
+ process.env.WSL_DISTRO_NAME
125
+ || process.env.WSL_INTEROP
126
+ || (process.platform === "win32"
127
+ && String(process.env.HOME || "")
128
+ .trim()
129
+ .startsWith("/home/")),
130
+ );
131
+ }
132
+
133
+ function resolveTelegramConfigDir() {
134
+ if (process.env.BOSUN_HOME) return resolve(process.env.BOSUN_HOME);
135
+ if (process.env.BOSUN_DIR) return resolve(process.env.BOSUN_DIR);
136
+
137
+ const preferWindowsDirs = process.platform === "win32" && !isWslInteropRuntime();
138
+ const baseDir = preferWindowsDirs
139
+ ? process.env.APPDATA
140
+ || process.env.LOCALAPPDATA
141
+ || process.env.USERPROFILE
142
+ || process.env.HOME
143
+ || homedir()
144
+ : process.env.HOME
145
+ || process.env.XDG_CONFIG_HOME
146
+ || process.env.USERPROFILE
147
+ || process.env.APPDATA
148
+ || process.env.LOCALAPPDATA
149
+ || homedir();
150
+ return resolve(baseDir, "bosun");
151
+ }
121
152
  const repoRoot = resolveRepoRoot();
122
153
  const BosunDir = __dirname;
123
154
  const statusPath = resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
@@ -329,6 +360,23 @@ function delayMs(ms) {
329
360
  return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
330
361
  }
331
362
 
363
+ const SAFE_DETACH_PREFIX = "[telegram-bot] async-detach";
364
+
365
+ function safeDetach(label, taskOrPromise) {
366
+ const tag = label
367
+ ? `${SAFE_DETACH_PREFIX}:${String(label)}`
368
+ : SAFE_DETACH_PREFIX;
369
+ try {
370
+ const result =
371
+ typeof taskOrPromise === "function" ? taskOrPromise() : taskOrPromise;
372
+ Promise.resolve(result).catch((err) => {
373
+ console.warn(`${tag} failed: ${err?.message || err}`);
374
+ });
375
+ } catch (err) {
376
+ console.warn(`${tag} failed: ${err?.message || err}`);
377
+ }
378
+ }
379
+
332
380
  function shouldUseCurlPrimary() {
333
381
  return (
334
382
  TELEGRAM_CURL_FALLBACK &&
@@ -1088,6 +1136,25 @@ async function bumpStickyMenu(chatId) {
1088
1136
  });
1089
1137
  }
1090
1138
 
1139
+ async function refreshStickyMenu(chatId, screenId = "home", params = {}) {
1140
+ const state = stickyMenuState.get(chatId);
1141
+ if (state?.messageId) {
1142
+ try {
1143
+ await deleteDirect(chatId, state.messageId);
1144
+ } catch {
1145
+ /* best effort */
1146
+ }
1147
+ }
1148
+ const timer = stickyMenuTimers.get(chatId);
1149
+ if (timer) {
1150
+ clearTimeout(timer);
1151
+ stickyMenuTimers.delete(chatId);
1152
+ }
1153
+ stickyMenuState.delete(chatId);
1154
+ clearPendingUiInput(chatId);
1155
+ await showUiScreen(chatId, null, screenId, params, { sticky: true });
1156
+ }
1157
+
1091
1158
  // ── Telegram API Helpers ─────────────────────────────────────────────────────
1092
1159
 
1093
1160
  /**
@@ -2257,7 +2324,7 @@ async function handleUpdate(update) {
2257
2324
  // Free-text agent task runs in a separate queue so polling isn't blocked.
2258
2325
  // If agent is already busy, handle immediately so follow-ups can be queued.
2259
2326
  if (isPrimaryBusy()) {
2260
- void handleFreeText(text, chatId);
2327
+ safeDetach("free-text", () => handleFreeText(text, chatId));
2261
2328
  return;
2262
2329
  }
2263
2330
  enqueueAgentTask(() => handleFreeText(text, chatId));
@@ -2432,7 +2499,7 @@ async function cmdWorkspace(chatId, text) {
2432
2499
  .trim();
2433
2500
  const [sub, ...rest] = raw ? raw.split(/\s+/) : [];
2434
2501
  const subcmd = String(sub || "list").toLowerCase();
2435
- const configDir = process.env.BOSUN_DIR || join(homedir(), "bosun");
2502
+ const configDir = resolveTelegramConfigDir();
2436
2503
 
2437
2504
  try {
2438
2505
  if (subcmd === "scan") {
@@ -3951,13 +4018,13 @@ function uiNavRow(parent) {
3951
4018
  if (!parent) {
3952
4019
  return [
3953
4020
  uiButton("🏠 Home", uiGoAction("home")),
3954
- uiButton(" Close", "cb:close_menu"),
4021
+ uiButton(" Close", "cb:close_menu"),
3955
4022
  ];
3956
4023
  }
3957
4024
  return [
3958
4025
  uiButton("⬅️ Back", uiGoAction(parent)),
3959
4026
  uiButton("🏠 Home", uiGoAction("home")),
3960
- uiButton(" Close", "cb:close_menu"),
4027
+ uiButton(" Close", "cb:close_menu"),
3961
4028
  ];
3962
4029
  }
3963
4030
 
@@ -4191,7 +4258,7 @@ Object.assign(UI_SCREENS, {
4191
4258
  text: "📱 Open Control Center",
4192
4259
  web_app: { url: telegramWebAppUrl },
4193
4260
  },
4194
- uiButton("", "cb:close_menu"),
4261
+ uiButton("", "cb:close_menu"),
4195
4262
  ]);
4196
4263
  if (telegramUiUrl) {
4197
4264
  rows.unshift([
@@ -4201,10 +4268,10 @@ Object.assign(UI_SCREENS, {
4201
4268
  } else if (telegramUiUrl) {
4202
4269
  rows.unshift([
4203
4270
  { text: "🌐 Open Control Center", url: getBrowserUiUrl() || telegramUiUrl },
4204
- uiButton("", "cb:close_menu"),
4271
+ uiButton("", "cb:close_menu"),
4205
4272
  ]);
4206
4273
  } else {
4207
- rows.unshift([uiButton(" Close Menu", "cb:close_menu")]);
4274
+ rows.unshift([uiButton(" Close Menu", "cb:close_menu")]);
4208
4275
  }
4209
4276
  return buildKeyboard(rows);
4210
4277
  },
@@ -5038,7 +5105,7 @@ Object.assign(UI_SCREENS, {
5038
5105
  body: () => "Choose a local workspace to set active for task routing.",
5039
5106
  keyboard: async (ctx) => {
5040
5107
  const page = parsePageParam(ctx.params?.page);
5041
- const configDir = process.env.BOSUN_DIR || join(homedir(), "bosun");
5108
+ const configDir = resolveTelegramConfigDir();
5042
5109
  const workspaces = listLocalWorkspaces(configDir);
5043
5110
  const active = getActiveLocalWorkspace(configDir);
5044
5111
  if (!workspaces.length) {
@@ -5302,7 +5369,7 @@ Object.assign(UI_SCREENS, {
5302
5369
  body: () => "Browse, manage, and interact with your managed workspaces.",
5303
5370
  keyboard: async (ctx) => {
5304
5371
  const page = parsePageParam(ctx.params?.page);
5305
- const configDir = process.env.BOSUN_DIR || join(homedir(), "bosun");
5372
+ const configDir = resolveTelegramConfigDir();
5306
5373
  const workspaces = listLocalWorkspaces(configDir);
5307
5374
  const active = getActiveLocalWorkspace(configDir);
5308
5375
  if (!workspaces.length) {
@@ -6060,7 +6127,7 @@ async function cmdApp(chatId) {
6060
6127
  async function cmdMenu(chatId) {
6061
6128
  syncUiUrlsFromServer();
6062
6129
  if (telegramApiReachable !== false) {
6063
- void refreshMenuButton();
6130
+ safeDetach("menu-button-refresh", refreshMenuButton);
6064
6131
  }
6065
6132
  clearPendingUiInput(chatId);
6066
6133
  await showUiScreen(chatId, null, "home", {}, { sticky: true });
@@ -6774,10 +6841,10 @@ async function cmdStartTask(chatId, args) {
6774
6841
  );
6775
6842
  return;
6776
6843
  }
6777
- void executor.executeTask(task, {
6844
+ safeDetach("manual-start", () => executor.executeTask(task, {
6778
6845
  sdk: sdk || undefined,
6779
6846
  model: model || undefined,
6780
- });
6847
+ }));
6781
6848
  await sendReply(
6782
6849
  chatId,
6783
6850
  `✅ Manual start queued for ${task.title || task.id}.` +
@@ -9089,7 +9156,7 @@ async function cmdBackground(chatId, args) {
9089
9156
  chatId,
9090
9157
  `🛰️ Background task queued: "${task.slice(0, 80)}${task.length > 80 ? "…" : ""}"`,
9091
9158
  );
9092
- void handleFreeText(task, chatId, { background: true, isolated: true });
9159
+ safeDetach("background-free-text", () => handleFreeText(task, chatId, { background: true, isolated: true }));
9093
9160
  return;
9094
9161
  }
9095
9162
 
@@ -9448,11 +9515,11 @@ async function handleFreeText(text, chatId, options = {}) {
9448
9515
  const elapsed = now - lastEditAt;
9449
9516
  if (elapsed >= EDIT_THROTTLE_MS) {
9450
9517
  editPending = true;
9451
- void doEdit();
9518
+ safeDetach("agent-edit", doEdit);
9452
9519
  } else {
9453
9520
  editPending = true;
9454
9521
  if (editTimer) clearTimeout(editTimer);
9455
- editTimer = setTimeout(() => void doEdit(), EDIT_THROTTLE_MS - elapsed);
9522
+ editTimer = setTimeout(() => safeDetach("agent-edit", doEdit), EDIT_THROTTLE_MS - elapsed);
9456
9523
  }
9457
9524
  };
9458
9525
 
@@ -9796,8 +9863,8 @@ function startPresenceLoop() {
9796
9863
  );
9797
9864
  }
9798
9865
  };
9799
- setTimeout(() => void sendPresence(), intervalMs);
9800
- setInterval(() => void sendPresence(), intervalMs);
9866
+ setTimeout(() => safeDetach("presence-heartbeat", sendPresence), intervalMs);
9867
+ setInterval(() => safeDetach("presence-heartbeat", sendPresence), intervalMs);
9801
9868
  }
9802
9869
 
9803
9870
  function hasPresenceChanged(prev, curr) {
@@ -10439,6 +10506,11 @@ export async function startTelegramBot() {
10439
10506
  if (miniAppEnabled || miniAppPort > 0) {
10440
10507
  try {
10441
10508
  await startTelegramUiServer({
10509
+ // Background monitor/bot runtime should not keep opening browser tabs.
10510
+ // Set BOSUN_UI_AUTO_OPEN_BROWSER=1 to opt-in.
10511
+ skipAutoOpen: !["1", "true", "yes", "on"].includes(
10512
+ String(process.env.BOSUN_UI_AUTO_OPEN_BROWSER || "").toLowerCase(),
10513
+ ),
10442
10514
  dependencies: {
10443
10515
  execPrimaryPrompt,
10444
10516
  getInternalExecutor: _getInternalExecutor,
@@ -10446,7 +10518,7 @@ export async function startTelegramBot() {
10446
10518
  getAgentEventBus: _getAgentEventBus,
10447
10519
  handleUiCommand: handleUiCommand,
10448
10520
  getSyncEngine: _getSyncEngine,
10449
- configDir: process.env.BOSUN_DIR || join(homedir(), "bosun"),
10521
+ configDir: resolveTelegramConfigDir(),
10450
10522
  onProjectSyncAlert: async (alert) => {
10451
10523
  if (!_sendTelegramMessage) return;
10452
10524
  const text = String(alert?.message || "Project sync alert");
@@ -10467,16 +10539,16 @@ export async function startTelegramBot() {
10467
10539
 
10468
10540
  // Immediately sync the menu button, then periodically refresh
10469
10541
  if (reachable) {
10470
- void refreshMenuButton();
10542
+ safeDetach("menu-button-refresh", refreshMenuButton);
10471
10543
  }
10472
10544
  if (reachable && !menuButtonRefreshTimer) {
10473
- menuButtonRefreshTimer = setInterval(() => void refreshMenuButton(), MENU_BUTTON_REFRESH_MS);
10545
+ menuButtonRefreshTimer = setInterval(() => safeDetach("menu-button-refresh", refreshMenuButton), MENU_BUTTON_REFRESH_MS);
10474
10546
  }
10475
10547
 
10476
10548
  // React immediately when the tunnel URL changes (e.g. after restart)
10477
10549
  onTunnelUrlChange((url) => {
10478
10550
  console.log(`[telegram-bot] tunnel URL changed: ${url} — refreshing menu button`);
10479
- void refreshMenuButton();
10551
+ safeDetach("menu-button-refresh", refreshMenuButton);
10480
10552
  });
10481
10553
 
10482
10554
  // Notify about firewall issues if detected (24h cooldown)
@@ -10588,8 +10660,9 @@ export async function startTelegramBot() {
10588
10660
  } else {
10589
10661
  await sendDirect(
10590
10662
  telegramChatId,
10591
- `🤖 Bosun primary agent online (${getPrimaryAgentName()}).\n\nType /menu for the control center or send any message to chat with the agent.`,
10663
+ `🤖 Bosun primary agent online (${getPrimaryAgentName()}).\n\nType /menu for the control center or send any message to chat with the agent.\n\nRefreshing control center menu below…`,
10592
10664
  );
10665
+ await refreshStickyMenu(telegramChatId, "home", {});
10593
10666
 
10594
10667
  // ── SECURITY: Alert when ALLOW_UNSAFE is enabled (especially with tunnel) ──
10595
10668
  const _isUnsafe = ["1", "true", "yes"].includes(
@@ -10691,7 +10764,7 @@ export function stopTelegramBot(options = {}) {
10691
10764
  }
10692
10765
  stopBatchFlushLoop();
10693
10766
  }
10694
- void releaseTelegramPollLock();
10767
+ safeDetach("poll-lock-release", releaseTelegramPollLock);
10695
10768
  stopTelegramUiServer();
10696
10769
  if (menuButtonRefreshTimer) {
10697
10770
  clearInterval(menuButtonRefreshTimer);
@@ -131,6 +131,12 @@ export const SETTINGS_SCHEMA = [
131
131
 
132
132
  // ── GitHub / Git ─────────────────────────────────────────
133
133
  { key: "GITHUB_TOKEN", label: "GitHub Token", category: "github", type: "secret", sensitive: true, description: "Personal access token or fine-grained token for GitHub API. Required for GitHub kanban backend." },
134
+ { key: "BOSUN_GITHUB_CLIENT_ID", label: "GitHub OAuth Client ID", category: "github", type: "string", description: "OAuth client ID used for GitHub device flow sign-in in the Bosun portal." },
135
+ { key: "BOSUN_GITHUB_CLIENT_SECRET", label: "GitHub OAuth Client Secret", category: "github", type: "secret", sensitive: true, description: "OAuth client secret used for GitHub web callback exchange.", advanced: true },
136
+ { key: "BOSUN_GITHUB_APP_ID", label: "GitHub App ID", category: "github", type: "string", description: "Numeric Bosun GitHub App ID used for installation token auth.", advanced: true },
137
+ { key: "BOSUN_GITHUB_PRIVATE_KEY_PATH", label: "GitHub App Private Key", category: "github", type: "string", description: "Absolute path to the Bosun GitHub App .pem private key.", advanced: true },
138
+ { key: "BOSUN_GITHUB_WEBHOOK_SECRET", label: "GitHub App Webhook Secret", category: "github", type: "secret", sensitive: true, description: "Webhook HMAC secret for validating GitHub App webhook deliveries.", advanced: true },
139
+ { key: "BOSUN_GITHUB_USER_TOKEN", label: "GitHub User Token Override", category: "github", type: "secret", sensitive: true, description: "Optional direct OAuth user token override (skips auth-state lookup).", advanced: true },
134
140
  { key: "GITHUB_REPOSITORY", label: "Repository", category: "github", type: "string", description: "GitHub repository in owner/repo format. Auto-detected from git remote if not set.", validate: "^[\\w.-]+/[\\w.-]+$" },
135
141
  { key: "GITHUB_PROJECT_MODE", label: "Project Mode", category: "github", type: "select", defaultVal: "issues", options: ["issues", "kanban"], description: "Use GitHub Issues directly, or GitHub Projects v2 kanban board." },
136
142
  { key: "GITHUB_PROJECT_NUMBER", label: "Project Number", category: "github", type: "number", min: 1, description: "GitHub Projects v2 number. Required when project mode is 'kanban'." },
@@ -868,11 +868,12 @@ export function ControlTab() {
868
868
  <div class="card-subtitle mt-sm">Kanban</div>
869
869
  <${SegmentedControl}
870
870
  options=${[
871
+ { value: "internal", label: "Internal" },
871
872
  { value: "vk", label: "VK" },
872
873
  { value: "github", label: "GitHub" },
873
874
  { value: "jira", label: "Jira" },
874
875
  ]}
875
- value=${config?.kanbanBackend || "github"}
876
+ value=${config?.kanbanBackend || "internal"}
876
877
  onChange=${(v) => updateConfig("kanban", v)}
877
878
  />
878
879
  ${regions.length > 1 && html`
@@ -715,11 +715,29 @@ function ServerConfigMode() {
715
715
  method: "POST",
716
716
  body: JSON.stringify({ changes }),
717
717
  });
718
- } catch (primaryErr) {
719
- // Legacy/demo compatibility endpoint.
718
+ } catch (error_) {
719
+ const message = String(error_?.message || "");
720
+ const shouldTryLegacy =
721
+ /Request failed \((404|405|501)\)/.test(message)
722
+ || /Failed to fetch|NetworkError|Load failed/i.test(message);
723
+
724
+ if (!shouldTryLegacy) throw error_;
725
+
726
+ const legacyKeyMap = {
727
+ INTERNAL_EXECUTOR_SDK: "sdk",
728
+ KANBAN_BACKEND: "kanban",
729
+ EXECUTOR_REGIONS: "region",
730
+ };
731
+ const entries = Object.entries(changes);
732
+ if (entries.length !== 1) throw error_;
733
+
734
+ const [envKey, value] = entries[0];
735
+ const legacyKey = legacyKeyMap[envKey];
736
+ if (!legacyKey) throw error_;
737
+
720
738
  res = await apiFetch("/api/config/update", {
721
739
  method: "POST",
722
- body: JSON.stringify(changes),
740
+ body: JSON.stringify({ key: legacyKey, value }),
723
741
  });
724
742
  }
725
743
  if (res?.ok || (res && typeof res === "object" && !Array.isArray(res))) {
package/ui-server.mjs CHANGED
@@ -5,7 +5,7 @@ import { open, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
5
  import { createServer } from "node:http";
6
6
  import { get as httpsGet } from "node:https";
7
7
  import { createServer as createHttpsServer } from "node:https";
8
- import { networkInterfaces } from "node:os";
8
+ import { networkInterfaces, homedir } from "node:os";
9
9
  import { connect as netConnect } from "node:net";
10
10
  import { resolve, extname, dirname, basename, relative } from "node:path";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -27,6 +27,8 @@ function getLocalLanIp() {
27
27
  import { WebSocketServer } from "ws";
28
28
  import {
29
29
  getKanbanAdapter,
30
+ getKanbanBackendName,
31
+ setKanbanBackend,
30
32
  markTaskIgnored,
31
33
  unmarkTaskIgnored,
32
34
  } from "./kanban-adapter.mjs";
@@ -1249,9 +1251,38 @@ let uiDeps = {};
1249
1251
  * Ensures the directory exists.
1250
1252
  */
1251
1253
  function resolveUiConfigDir() {
1254
+ if (process.env.BOSUN_CONFIG_PATH) {
1255
+ const fromConfigPath = dirname(resolve(process.env.BOSUN_CONFIG_PATH));
1256
+ try { mkdirSync(fromConfigPath, { recursive: true }); } catch { /* ok */ }
1257
+ if (!uiDeps.configDir) uiDeps.configDir = fromConfigPath;
1258
+ return fromConfigPath;
1259
+ }
1260
+ const isWslInteropRuntime = Boolean(
1261
+ process.env.WSL_DISTRO_NAME
1262
+ || process.env.WSL_INTEROP
1263
+ || (process.platform === "win32"
1264
+ && String(process.env.HOME || "")
1265
+ .trim()
1266
+ .startsWith("/home/")),
1267
+ );
1268
+ const preferWindowsDirs = process.platform === "win32" && !isWslInteropRuntime;
1269
+ const baseDir = preferWindowsDirs
1270
+ ? process.env.APPDATA
1271
+ || process.env.LOCALAPPDATA
1272
+ || process.env.USERPROFILE
1273
+ || process.env.HOME
1274
+ || homedir()
1275
+ : process.env.HOME
1276
+ || process.env.XDG_CONFIG_HOME
1277
+ || process.env.USERPROFILE
1278
+ || process.env.APPDATA
1279
+ || process.env.LOCALAPPDATA
1280
+ || homedir();
1281
+
1252
1282
  const dir = uiDeps.configDir
1283
+ || process.env.BOSUN_HOME
1253
1284
  || process.env.BOSUN_DIR
1254
- || resolve(process.env.HOME || process.env.USERPROFILE || "", "bosun");
1285
+ || resolve(baseDir, "bosun");
1255
1286
  if (dir) {
1256
1287
  try { mkdirSync(dir, { recursive: true }); } catch { /* ok */ }
1257
1288
  // Cache it so subsequent calls don't re-resolve
@@ -1308,6 +1339,8 @@ const SETTINGS_KNOWN_KEYS = [
1308
1339
  "BOSUN_PROMPT_PLANNER",
1309
1340
  "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_PROJECT_MODE",
1310
1341
  "GITHUB_PROJECT_NUMBER", "GITHUB_DEFAULT_ASSIGNEE", "GITHUB_AUTO_ASSIGN_CREATOR",
1342
+ "BOSUN_GITHUB_APP_ID", "BOSUN_GITHUB_PRIVATE_KEY_PATH", "BOSUN_GITHUB_CLIENT_ID", "BOSUN_GITHUB_CLIENT_SECRET",
1343
+ "BOSUN_GITHUB_WEBHOOK_SECRET", "BOSUN_GITHUB_USER_TOKEN",
1311
1344
  "GITHUB_PROJECT_WEBHOOK_PATH", "GITHUB_PROJECT_WEBHOOK_SECRET", "GITHUB_PROJECT_WEBHOOK_REQUIRE_SIGNATURE",
1312
1345
  "GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD", "GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD",
1313
1346
  "VK_TARGET_BRANCH", "CODEX_ANALYZE_MERGE_STRATEGY", "DEPENDABOT_AUTO_MERGE",
@@ -1342,6 +1375,7 @@ const SETTINGS_SENSITIVE_KEYS = new Set([
1342
1375
  "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "GITHUB_TOKEN",
1343
1376
  "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "CODEX_MODEL_PROFILE_XL_API_KEY", "CODEX_MODEL_PROFILE_M_API_KEY",
1344
1377
  "ANTHROPIC_API_KEY", "COPILOT_CLI_TOKEN", "GITHUB_PROJECT_WEBHOOK_SECRET",
1378
+ "BOSUN_GITHUB_CLIENT_SECRET", "BOSUN_GITHUB_WEBHOOK_SECRET", "BOSUN_GITHUB_USER_TOKEN",
1345
1379
  "CLOUDFLARE_TUNNEL_CREDENTIALS",
1346
1380
  ]);
1347
1381
 
@@ -1504,11 +1538,7 @@ function validateConfigSchemaChanges(changes) {
1504
1538
  fieldErrors[envKey] = err.message || "Invalid value";
1505
1539
  }
1506
1540
  }
1507
- if (Object.keys(fieldErrors).length === 0) {
1508
- for (const envKey of pathMap.values()) {
1509
- fieldErrors[envKey] = "Invalid value (config schema)";
1510
- }
1511
- }
1541
+ if (Object.keys(fieldErrors).length === 0) return {};
1512
1542
  return fieldErrors;
1513
1543
  } catch {
1514
1544
  return {};
@@ -5758,6 +5788,16 @@ async function handleApi(req, res, url) {
5758
5788
  const regionEnv = (process.env.EXECUTOR_REGIONS || "").trim();
5759
5789
  const regions = regionEnv ? regionEnv.split(",").map((r) => r.trim()).filter(Boolean) : ["auto"];
5760
5790
  const pkg = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8"));
5791
+ let runtimeKanbanBackend = "internal";
5792
+ try {
5793
+ runtimeKanbanBackend = String(
5794
+ getKanbanBackendName() || process.env.KANBAN_BACKEND || "internal",
5795
+ ).trim().toLowerCase();
5796
+ } catch {
5797
+ runtimeKanbanBackend = String(
5798
+ process.env.KANBAN_BACKEND || "internal",
5799
+ ).trim().toLowerCase();
5800
+ }
5761
5801
  jsonResponse(res, 200, {
5762
5802
  ok: true,
5763
5803
  version: pkg.version,
@@ -5769,7 +5809,7 @@ async function handleApi(req, res, url) {
5769
5809
  wsEnabled: true,
5770
5810
  authRequired: !isAllowUnsafe(),
5771
5811
  sdk: process.env.EXECUTOR_SDK || "auto",
5772
- kanbanBackend: process.env.KANBAN_BACKEND || "github",
5812
+ kanbanBackend: runtimeKanbanBackend,
5773
5813
  regions,
5774
5814
  });
5775
5815
  return;
@@ -5790,6 +5830,13 @@ async function handleApi(req, res, url) {
5790
5830
  return;
5791
5831
  }
5792
5832
  process.env[envKey] = value;
5833
+ if (envKey === "KANBAN_BACKEND") {
5834
+ try {
5835
+ setKanbanBackend(String(value).trim().toLowerCase());
5836
+ } catch (err) {
5837
+ console.warn(`[config] failed to switch kanban backend: ${err.message}`);
5838
+ }
5839
+ }
5793
5840
  // Also send chat command for backward compat
5794
5841
  const cmdMap = { sdk: `/sdk ${value}`, kanban: `/kanban ${value}`, region: `/region ${value}` };
5795
5842
  const handler = uiDeps.handleUiCommand;
@@ -5895,6 +5942,15 @@ async function handleApi(req, res, url) {
5895
5942
  process.env[key] = strVal;
5896
5943
  strChanges[key] = strVal;
5897
5944
  }
5945
+ if (Object.prototype.hasOwnProperty.call(strChanges, "KANBAN_BACKEND")) {
5946
+ try {
5947
+ setKanbanBackend(
5948
+ String(strChanges.KANBAN_BACKEND).trim().toLowerCase(),
5949
+ );
5950
+ } catch (err) {
5951
+ console.warn(`[settings] failed to switch kanban backend: ${err.message}`);
5952
+ }
5953
+ }
5898
5954
  // Write to .env file
5899
5955
  const updated = updateEnvFile(strChanges);
5900
5956
  const configUpdate = updateConfigFile(changes);
@@ -6136,7 +6192,7 @@ async function handleApi(req, res, url) {
6136
6192
  jsonResponse(res, 404, { ok: false, error: "Task not found." });
6137
6193
  return;
6138
6194
  }
6139
- executor.executeTask(task).catch((error) => {
6195
+ executor.executeTask(task, { force: true }).catch((error) => {
6140
6196
  console.warn(
6141
6197
  `[telegram-ui] dispatch failed for ${taskId}: ${error.message}`,
6142
6198
  );
@@ -6782,7 +6838,43 @@ export async function startTelegramUiServer(options = {}) {
6782
6838
 
6783
6839
  const rawPort = options.port ?? getDefaultPort();
6784
6840
  const port = Number(rawPort);
6785
- if (!Number.isFinite(port) || port < 0) return null;
6841
+ const isTestRun =
6842
+ Boolean(process.env.VITEST) ||
6843
+ process.env.NODE_ENV === "test" ||
6844
+ Boolean(process.env.JEST_WORKER_ID);
6845
+ const allowEphemeralPort =
6846
+ options.allowEphemeralPort === true ||
6847
+ process.env.BOSUN_UI_ALLOW_EPHEMERAL_PORT === "1" ||
6848
+ isTestRun;
6849
+ const portSource =
6850
+ options.port != null
6851
+ ? "options.port"
6852
+ : process.env.TELEGRAM_UI_PORT
6853
+ ? "env.TELEGRAM_UI_PORT"
6854
+ : "default(0)";
6855
+
6856
+ if (!Number.isFinite(port) || port < 0) {
6857
+ console.warn(
6858
+ `[telegram-ui] invalid ui port: raw=${String(rawPort)} source=${portSource}`,
6859
+ );
6860
+ return null;
6861
+ }
6862
+ if (port === 0 && !allowEphemeralPort) {
6863
+ console.warn(
6864
+ `[telegram-ui] refusing ephemeral ui port (resolved 0 from ${portSource}); ` +
6865
+ `set TELEGRAM_UI_PORT (for example 4400) or set BOSUN_UI_ALLOW_EPHEMERAL_PORT=1`,
6866
+ );
6867
+ return null;
6868
+ }
6869
+
6870
+ const autoOpenEnabled = ["1", "true", "yes", "on"].includes(
6871
+ String(process.env.BOSUN_UI_AUTO_OPEN_BROWSER || "")
6872
+ .trim()
6873
+ .toLowerCase(),
6874
+ );
6875
+ console.log(
6876
+ `[telegram-ui] startup config: port=${port} source=${portSource} autoOpen=${autoOpenEnabled ? "enabled" : "disabled"}`,
6877
+ );
6786
6878
 
6787
6879
  injectUiDependencies(options.dependencies || {});
6788
6880
 
@@ -7042,12 +7134,14 @@ export async function startTelegramUiServer(options = {}) {
7042
7134
  // - skip when caller passes skipAutoOpen
7043
7135
  // - skip during Vitest / Jest test runs (avoids opening 20+ tabs during `npm test`)
7044
7136
  // - only open ONCE per process (singleton guard — prevents loops on server restart)
7045
- const isTestRun = process.env.VITEST || process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID;
7137
+ const isTestRunRuntime =
7138
+ process.env.VITEST || process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID;
7046
7139
  if (
7140
+ autoOpenEnabled &&
7047
7141
  process.env.BOSUN_DESKTOP !== "1" &&
7048
7142
  !options.skipAutoOpen &&
7049
7143
  !_browserOpened &&
7050
- !isTestRun
7144
+ !isTestRunRuntime
7051
7145
  ) {
7052
7146
  _browserOpened = true;
7053
7147
  const openUrl = `${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`;
@@ -26,6 +26,76 @@ import { randomUUID } from "node:crypto";
26
26
  const TAG = "[workflow-nodes]";
27
27
  const PORTABLE_WORKTREE_COUNT_COMMAND = "node -e \"const cp=require('node:child_process');const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
28
28
  const PORTABLE_PRUNE_AND_COUNT_WORKTREES_COMMAND = "node -e \"const cp=require('node:child_process');cp.execSync('git worktree prune',{stdio:'ignore'});const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
29
+ const WORKFLOW_AGENT_HEARTBEAT_MS = (() => {
30
+ const raw = Number(process.env.WORKFLOW_AGENT_HEARTBEAT_MS || 30000);
31
+ if (!Number.isFinite(raw)) return 30000;
32
+ return Math.max(5000, Math.min(120000, Math.trunc(raw)));
33
+ })();
34
+
35
+ function trimLogText(value, max = 180) {
36
+ const text = String(value || "").replace(/\s+/g, " ").trim();
37
+ if (!text) return "";
38
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
39
+ }
40
+
41
+ function summarizeAgentStreamEvent(event) {
42
+ const type = String(event?.type || "").trim();
43
+ if (!type) return "";
44
+
45
+ if (
46
+ type === "response.output_text.delta" ||
47
+ type === "response.output_text.done" ||
48
+ type === "item.updated"
49
+ ) {
50
+ return "";
51
+ }
52
+
53
+ if (type === "tool_call") {
54
+ return `Tool call: ${event?.tool_name || event?.data?.tool_name || "unknown"}`;
55
+ }
56
+
57
+ if (type === "tool_result") {
58
+ const name = event?.tool_name || event?.data?.tool_name || "unknown";
59
+ return `Tool result: ${name}`;
60
+ }
61
+
62
+ if (type === "error") {
63
+ return `Agent error: ${trimLogText(event?.error || event?.message || "unknown error", 220)}`;
64
+ }
65
+
66
+ const messageText = trimLogText(
67
+ event?.message?.content ||
68
+ event?.message?.text ||
69
+ event?.content ||
70
+ event?.text ||
71
+ event?.data?.content ||
72
+ event?.data?.text ||
73
+ "",
74
+ 220,
75
+ );
76
+
77
+ if (messageText) {
78
+ if (
79
+ type === "agent_message" ||
80
+ type === "assistant_message" ||
81
+ type === "message" ||
82
+ type === "item.completed"
83
+ ) {
84
+ return `Agent: ${messageText}`;
85
+ }
86
+ return `${type}: ${messageText}`;
87
+ }
88
+
89
+ if (
90
+ type === "turn.complete" ||
91
+ type === "session.completed" ||
92
+ type === "response.completed"
93
+ ) {
94
+ return `Agent event: ${type}`;
95
+ }
96
+
97
+ return "";
98
+ }
29
99
 
30
100
  function normalizeLegacyWorkflowCommand(command) {
31
101
  let normalized = String(command || "");
@@ -460,8 +530,45 @@ registerNodeType("action.run_agent", {
460
530
  // Use the engine's service injection to call agent pool
461
531
  const agentPool = engine.services?.agentPool;
462
532
  if (agentPool?.launchEphemeralThread) {
463
- const result = await agentPool.launchEphemeralThread(finalPrompt, cwd, timeoutMs);
464
- ctx.log(node.id, `Agent completed: success=${result.success}`);
533
+ let streamEventCount = 0;
534
+ let lastStreamLog = "";
535
+ const startedAt = Date.now();
536
+ const launchExtra = {};
537
+ if (sdk && sdk !== "auto") launchExtra.sdk = sdk;
538
+
539
+ launchExtra.onEvent = (event) => {
540
+ try {
541
+ const line = summarizeAgentStreamEvent(event);
542
+ if (!line || line === lastStreamLog) return;
543
+ lastStreamLog = line;
544
+ streamEventCount += 1;
545
+ ctx.log(node.id, line);
546
+ } catch {
547
+ // Stream callbacks must never crash workflow execution.
548
+ }
549
+ };
550
+
551
+ const heartbeat = setInterval(() => {
552
+ const elapsedSec = Math.max(1, Math.round((Date.now() - startedAt) / 1000));
553
+ ctx.log(node.id, `Agent still running (${elapsedSec}s elapsed)`);
554
+ }, WORKFLOW_AGENT_HEARTBEAT_MS);
555
+
556
+ let result;
557
+ try {
558
+ result = await agentPool.launchEphemeralThread(
559
+ finalPrompt,
560
+ cwd,
561
+ timeoutMs,
562
+ launchExtra,
563
+ );
564
+ } finally {
565
+ clearInterval(heartbeat);
566
+ }
567
+
568
+ ctx.log(
569
+ node.id,
570
+ `Agent completed: success=${result.success} streamEvents=${streamEventCount}`,
571
+ );
465
572
 
466
573
  // Propagate session/thread IDs for downstream chaining
467
574
  const threadId = result.threadId || result.sessionId || null;