bosun 0.40.9 → 0.40.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -541,6 +541,9 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
541
541
  # BOSUN_TASK_CONTEXT_MAX_COMMENTS=8
542
542
  # BOSUN_TASK_CONTEXT_MAX_COMMENT_CHARS=1200
543
543
  # BOSUN_TASK_CONTEXT_MAX_ATTACHMENTS=20
544
+ # Immediate cap for known high-volume git outputs in context cache.
545
+ # Set to 0 to disable the cap entirely (default: 8000).
546
+ # BOSUN_GIT_OUTPUT_MAX_CHARS=8000
544
547
  # Max upload size for task/chat attachments (MB)
545
548
  # BOSUN_ATTACHMENT_MAX_MB=25
546
549
 
package/README.md CHANGED
@@ -149,26 +149,6 @@ Bosun enforces a strict quality pipeline in both local hooks and CI:
149
149
  - **Demo load smoke test** runs in `npm test` and blocks push if `site/index.html` or `site/ui/demo.html` fails to load required assets.
150
150
  - **Prepublish checks** validate package contents and release readiness.
151
151
 
152
- ### Codebase annotation audit
153
-
154
- Use `bosun audit` to generate and validate repo-level annotations that help future agents navigate the codebase without extra runtime context:
155
-
156
- ```bash
157
- # Coverage report for supported source files
158
- bosun audit scan
159
-
160
- # Add missing file summaries and risky-function warnings
161
- bosun audit generate
162
- bosun audit warn
163
-
164
- # Rebuild lean manifests and the file responsibility index
165
- bosun audit manifest
166
- bosun audit index
167
- bosun audit trim
168
-
169
- # CI-safe conformity gate
170
- bosun audit --ci
171
- ```
172
152
 
173
153
  Notes:
174
154
 
@@ -33,6 +33,7 @@ import {
33
33
  selectCoordinator,
34
34
  getPresenceState,
35
35
  } from "../infra/presence.mjs";
36
+ import { sanitizeGitEnv } from "../git/git-safety.mjs";
36
37
 
37
38
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
39
 
@@ -57,11 +58,7 @@ function emitFleetEvent(eventType, eventData = {}, opts = {}) {
57
58
  // ── Repo Fingerprinting ──────────────────────────────────────────────────────
58
59
 
59
60
  function buildGitEnv() {
60
- const env = { ...process.env };
61
- delete env.GIT_DIR;
62
- delete env.GIT_WORK_TREE;
63
- delete env.GIT_INDEX_FILE;
64
- return env;
61
+ return sanitizeGitEnv();
65
62
  }
66
63
 
67
64
  /**
@@ -857,4 +854,3 @@ export function formatFleetSummary() {
857
854
 
858
855
  return lines.join("\n");
859
856
  }
860
-
@@ -1,14 +1,98 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
 
3
+ const STRIPPED_GIT_ENV_KEYS = [
4
+ "GIT_DIR",
5
+ "GIT_WORK_TREE",
6
+ "GIT_COMMON_DIR",
7
+ "GIT_INDEX_FILE",
8
+ "GIT_OBJECT_DIRECTORY",
9
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
10
+ "GIT_NAMESPACE",
11
+ "GIT_PREFIX",
12
+ "GIT_SUPER_PREFIX",
13
+ ];
14
+
15
+ const BLOCKED_TEST_GIT_IDENTITIES = new Set([
16
+ "test@example.com",
17
+ "bosun-tests@example.com",
18
+ ]);
19
+
20
+ const TEST_FIXTURE_SENTINEL_PATHS = new Set([
21
+ ".github/agents/TaskPlanner.agent.md",
22
+ ]);
23
+
3
24
  function runGit(args, cwd, timeout = 15_000) {
4
25
  return spawnSync("git", args, {
5
26
  cwd,
6
27
  encoding: "utf8",
7
28
  timeout,
8
29
  shell: false,
30
+ env: sanitizeGitEnv(),
9
31
  });
10
32
  }
11
33
 
34
+ export function sanitizeGitEnv(baseEnv = process.env, extraEnv = {}) {
35
+ const env = { ...baseEnv };
36
+ for (const key of STRIPPED_GIT_ENV_KEYS) {
37
+ delete env[key];
38
+ }
39
+ return { ...env, ...extraEnv };
40
+ }
41
+
42
+ function getGitConfig(cwd, key) {
43
+ const result = runGit(["config", "--get", key], cwd, 5_000);
44
+ if (result.status !== 0) return "";
45
+ return String(result.stdout || "").trim();
46
+ }
47
+
48
+ function listTrackedFiles(cwd, ref = "HEAD") {
49
+ const result = runGit(["ls-tree", "-r", "--name-only", ref], cwd, 30_000);
50
+ if (result.status !== 0) return null;
51
+ const out = String(result.stdout || "").trim();
52
+ return out ? out.split("\n").filter(Boolean) : [];
53
+ }
54
+
55
+ function collectBlockedIdentitySignals(cwd) {
56
+ const signals = [];
57
+ const envChecks = [
58
+ ["GIT_AUTHOR_EMAIL", process.env.GIT_AUTHOR_EMAIL],
59
+ ["GIT_COMMITTER_EMAIL", process.env.GIT_COMMITTER_EMAIL],
60
+ ["VE_GIT_AUTHOR_EMAIL", process.env.VE_GIT_AUTHOR_EMAIL],
61
+ ];
62
+ for (const [key, value] of envChecks) {
63
+ const email = String(value || "").trim().toLowerCase();
64
+ if (BLOCKED_TEST_GIT_IDENTITIES.has(email)) {
65
+ signals.push(`${key}=${email}`);
66
+ }
67
+ }
68
+
69
+ const configChecks = [
70
+ ["git config user.email", getGitConfig(cwd, "user.email")],
71
+ ["git config author.email", getGitConfig(cwd, "author.email")],
72
+ ["git config committer.email", getGitConfig(cwd, "committer.email")],
73
+ ];
74
+ for (const [label, value] of configChecks) {
75
+ const email = String(value || "").trim().toLowerCase();
76
+ if (BLOCKED_TEST_GIT_IDENTITIES.has(email)) {
77
+ signals.push(`${label}=${email}`);
78
+ }
79
+ }
80
+
81
+ return signals;
82
+ }
83
+
84
+ function detectKnownFixtureSignature(cwd) {
85
+ const trackedFiles = listTrackedFiles(cwd, "HEAD");
86
+ if (!trackedFiles) return null;
87
+ const sentinelHits = trackedFiles.filter((file) => TEST_FIXTURE_SENTINEL_PATHS.has(file));
88
+ if (sentinelHits.length === 0) return null;
89
+ if (trackedFiles.length > 10) return null;
90
+ return {
91
+ trackedFiles: trackedFiles.length,
92
+ sentinels: sentinelHits,
93
+ };
94
+ }
95
+
12
96
  function countTrackedFiles(cwd, ref) {
13
97
  const result = runGit(["ls-tree", "-r", "--name-only", ref], cwd, 30_000);
14
98
  if (result.status !== 0) return null;
@@ -129,6 +213,18 @@ export function evaluateBranchSafetyForPush(worktreePath, opts = {}) {
129
213
  }
130
214
 
131
215
  const reasons = [];
216
+ const blockedIdentitySignals = collectBlockedIdentitySignals(worktreePath);
217
+ if (blockedIdentitySignals.length > 0) {
218
+ reasons.push(`blocked test git identity detected (${blockedIdentitySignals.join(", ")})`);
219
+ }
220
+
221
+ const fixtureSignature = detectKnownFixtureSignature(worktreePath);
222
+ if (fixtureSignature) {
223
+ reasons.push(
224
+ `HEAD matches known test fixture signature (${fixtureSignature.sentinels.join(", ")} in ${fixtureSignature.trackedFiles} tracked files)`,
225
+ );
226
+ }
227
+
132
228
  if (baseFiles >= 500 && headFiles <= Math.max(25, Math.floor(baseFiles * 0.15))) {
133
229
  reasons.push(`HEAD tracks only ${headFiles}/${baseFiles} files vs ${remoteRef}`);
134
230
  }
@@ -25,6 +25,7 @@ import { resolveCodexProfileRuntime } from "../shell/codex-model-profiles.mjs";
25
25
  import {
26
26
  evaluateBranchSafetyForPush,
27
27
  normalizeBaseBranch,
28
+ sanitizeGitEnv,
28
29
  } from "./git-safety.mjs";
29
30
 
30
31
  // ── Configuration ────────────────────────────────────────────────────────────
@@ -73,6 +74,7 @@ function gitExec(args, cwd, timeoutMs = 30_000) {
73
74
  stdio: ["ignore", "pipe", "pipe"],
74
75
  shell: false,
75
76
  timeout: timeoutMs,
77
+ env: sanitizeGitEnv(),
76
78
  });
77
79
 
78
80
  let stdout = "";
package/infra/monitor.mjs CHANGED
@@ -14742,7 +14742,10 @@ injectMonitorFunctions({
14742
14742
  },
14743
14743
  triggerTaskPlanner,
14744
14744
  });
14745
- if (telegramBotEnabled) {
14745
+ const portalWantsStart =
14746
+ ["1", "true", "yes"].includes(String(process.env.TELEGRAM_MINIAPP_ENABLED || "").toLowerCase()) ||
14747
+ Number(process.env.TELEGRAM_UI_PORT || "0") > 0;
14748
+ if (telegramBotEnabled || portalWantsStart) {
14746
14749
  runDetached("telegram-bot:start-startup", () =>
14747
14750
  startTelegramBot(getTelegramBotStartOptions()));
14748
14751
 
@@ -734,6 +734,11 @@ function Ensure-GitIdentity {
734
734
  $email = Get-EnvFallback -Name "VE_GIT_AUTHOR_EMAIL"
735
735
  if (-not $email) { $email = Get-EnvFallback -Name "GIT_AUTHOR_EMAIL" }
736
736
 
737
+ $blockedEmails = @("test@example.com", "bosun-tests@example.com")
738
+ if ($email -and ($blockedEmails -contains $email.ToLowerInvariant())) {
739
+ throw "Refusing to configure test git identity in live orchestrator environment: $email"
740
+ }
741
+
737
742
  if ($name) {
738
743
  try { git config user.name $name | Out-Null } catch { }
739
744
  Set-EnvValue -Name "GIT_AUTHOR_NAME" -Value $name
@@ -17,11 +17,15 @@
17
17
  * - Only resolves for successfully completed tasks
18
18
  */
19
19
 
20
- import { spawn, execSync } from "node:child_process";
20
+ import { spawn, spawnSync, execSync } from "node:child_process";
21
21
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
22
22
  import { resolve } from "node:path";
23
23
  import { fileURLToPath } from "url";
24
24
  import { getWorktreeManager } from "../workspace/worktree-manager.mjs";
25
+ import {
26
+ evaluateBranchSafetyForPush,
27
+ sanitizeGitEnv,
28
+ } from "../git/git-safety.mjs";
25
29
 
26
30
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
27
31
 
@@ -65,6 +69,31 @@ function extractBranch(logLine) {
65
69
  return branchMatch ? branchMatch[0] : null;
66
70
  }
67
71
 
72
+ function runGit(args, cwd, opts = {}) {
73
+ const result = spawnSync("git", args, {
74
+ cwd,
75
+ encoding: "utf8",
76
+ stdio: ["ignore", "pipe", "pipe"],
77
+ shell: false,
78
+ timeout: opts.timeout ?? 30_000,
79
+ env: sanitizeGitEnv(process.env, opts.env || {}),
80
+ });
81
+ if (result.status === 0) {
82
+ return String(result.stdout || "").trim();
83
+ }
84
+ const stderr = String(result.stderr || result.stdout || result.error?.message || "git command failed").trim();
85
+ throw new Error(stderr || "git command failed");
86
+ }
87
+
88
+ function assertPushSafe(worktreePath, branch) {
89
+ const safety = evaluateBranchSafetyForPush(worktreePath, { baseBranch: "main" });
90
+ if (!safety.safe) {
91
+ throw new Error(
92
+ `refusing git mutation on ${branch}: ${safety.reason || "branch safety check failed"}`,
93
+ );
94
+ }
95
+ }
96
+
68
97
  // ── State Management ─────────────────────────────────────────────────────────
69
98
 
70
99
  class ResolutionState {
@@ -196,26 +225,22 @@ class UncommittedChangesResolver {
196
225
  }
197
226
 
198
227
  try {
228
+ assertPushSafe(worktreePath, branch);
229
+
199
230
  // Add uncommitted files
200
- execSync("git add .", {
201
- cwd: worktreePath,
202
- stdio: "pipe",
203
- env: { ...process.env, GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
231
+ runGit(["add", "."], worktreePath, {
232
+ env: { GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
204
233
  });
205
234
 
206
235
  // Commit changes
207
236
  const commitMsg = "chore(bosun): add uncommitted changes";
208
- execSync(`git commit -m "${commitMsg}" --no-edit`, {
209
- cwd: worktreePath,
210
- stdio: "pipe",
211
- env: { ...process.env, GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
237
+ runGit(["commit", "-m", commitMsg, "--no-edit"], worktreePath, {
238
+ env: { GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
212
239
  });
213
240
 
214
241
  // Push to remote
215
- execSync(`git push origin ${branch}`, {
216
- cwd: worktreePath,
217
- stdio: "pipe",
218
- });
242
+ assertPushSafe(worktreePath, branch);
243
+ runGit(["push", "origin", branch], worktreePath);
219
244
 
220
245
  console.log(
221
246
  `[vk-error-resolver] ✓ Resolved uncommitted changes on ${branch}`,
@@ -255,28 +280,19 @@ class PushFailureResolver {
255
280
 
256
281
  try {
257
282
  // Fetch latest from remote
258
- execSync(`git fetch origin ${branch}`, {
259
- cwd: worktreePath,
260
- stdio: "pipe",
261
- });
283
+ assertPushSafe(worktreePath, branch);
284
+ runGit(["fetch", "origin", branch], worktreePath);
262
285
 
263
286
  // Check if behind
264
- const behind = execSync(`git rev-list --count HEAD..origin/${branch}`, {
265
- cwd: worktreePath,
266
- encoding: "utf8",
267
- }).trim();
287
+ const behind = runGit(["rev-list", "--count", `HEAD..origin/${branch}`], worktreePath);
268
288
 
269
289
  if (parseInt(behind) > 0) {
270
290
  // Rebase and retry push
271
- execSync(`git rebase origin/${branch}`, {
272
- cwd: worktreePath,
273
- stdio: "pipe",
274
- env: { ...process.env, GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
275
- });
276
- execSync(`git push origin ${branch} --force-with-lease`, {
277
- cwd: worktreePath,
278
- stdio: "pipe",
291
+ runGit(["rebase", `origin/${branch}`], worktreePath, {
292
+ env: { GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
279
293
  });
294
+ assertPushSafe(worktreePath, branch);
295
+ runGit(["push", "origin", branch, "--force-with-lease"], worktreePath);
280
296
 
281
297
  console.log(
282
298
  `[vk-error-resolver] ✓ Resolved push failure on ${branch} (rebased)`,
@@ -286,10 +302,8 @@ class PushFailureResolver {
286
302
  }
287
303
 
288
304
  // Try force push as last resort
289
- execSync(`git push origin ${branch} --force-with-lease`, {
290
- cwd: worktreePath,
291
- stdio: "pipe",
292
- });
305
+ assertPushSafe(worktreePath, branch);
306
+ runGit(["push", "origin", branch, "--force-with-lease"], worktreePath);
293
307
 
294
308
  console.log(
295
309
  `[vk-error-resolver] ✓ Resolved push failure on ${branch} (force-pushed)`,
@@ -345,22 +359,12 @@ class CIRetriggerResolver {
345
359
  }
346
360
 
347
361
  try {
348
- execSync(
349
- 'git commit --allow-empty -m "chore: trigger CI" --no-edit',
350
- {
351
- cwd: worktreePath,
352
- stdio: "pipe",
353
- env: {
354
- ...process.env,
355
- GIT_EDITOR: ":",
356
- GIT_MERGE_AUTOEDIT: "no",
357
- },
358
- },
359
- );
360
- execSync(`git push origin ${branch}`, {
361
- cwd: worktreePath,
362
- stdio: "pipe",
362
+ assertPushSafe(worktreePath, branch);
363
+ runGit(["commit", "--allow-empty", "-m", "chore: trigger CI", "--no-edit"], worktreePath, {
364
+ env: { GIT_EDITOR: ":", GIT_MERGE_AUTOEDIT: "no" },
363
365
  });
366
+ assertPushSafe(worktreePath, branch);
367
+ runGit(["push", "origin", branch], worktreePath);
364
368
 
365
369
  console.log(`[vk-error-resolver] ✓ Triggered CI for PR #${prNumber}`);
366
370
  this.stateManager.clearSignature(signature);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.40.9",
3
+ "version": "0.40.10",
4
4
  "description": "Bosun Autonomous Engineering — manages AI agent executors with failover, extremely powerful workflow builder, and a massive amount of included default workflow templates for autonomous engineering, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -350,4 +350,3 @@
350
350
  "gaxios": "7.1.4"
351
351
  }
352
352
  }
353
-
@@ -12598,7 +12598,7 @@ async function handleApi(req, res, url) {
12598
12598
  const engine = wfCtx.engine;
12599
12599
  const rawLimit = Number(url.searchParams.get("limit"));
12600
12600
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
12601
- ? Math.min(rawLimit, 500)
12601
+ ? Math.min(rawLimit, 5000)
12602
12602
  : 200;
12603
12603
  const runs = engine.getRunHistory ? engine.getRunHistory(null, limit) : [];
12604
12604
  jsonResponse(res, 200, { ok: true, runs });
@@ -12772,7 +12772,7 @@ async function handleApi(req, res, url) {
12772
12772
  if (action === "runs") {
12773
12773
  const rawLimit = Number(url.searchParams.get("limit"));
12774
12774
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
12775
- ? Math.min(rawLimit, 500)
12775
+ ? Math.min(rawLimit, 5000)
12776
12776
  : 200;
12777
12777
  const runs = engine.getRunHistory ? engine.getRunHistory(workflowId, limit) : [];
12778
12778
  jsonResponse(res, 200, { ok: true, runs });
@@ -16514,3 +16514,4 @@ export { getLocalLanIp };
16514
16514
 
16515
16515
 
16516
16516
 
16517
+
package/setup.mjs CHANGED
@@ -980,6 +980,11 @@ function getGitHubAuthScopes(cwd) {
980
980
  encoding: "utf8",
981
981
  cwd: cwd || process.cwd(),
982
982
  stdio: ["ignore", "pipe", "pipe"],
983
+ timeout: 3000,
984
+ env: {
985
+ ...process.env,
986
+ GH_PROMPT_DISABLED: "1",
987
+ },
983
988
  });
984
989
  const line = String(output || "")
985
990
  .split(/\r?\n/)
@@ -83,7 +83,11 @@ import {
83
83
  formatComplexityDecision,
84
84
  normalizeExecutorKey,
85
85
  } from "./task-complexity.mjs";
86
- import { evaluateBranchSafetyForPush, normalizeBaseBranch } from "../git/git-safety.mjs";
86
+ import {
87
+ evaluateBranchSafetyForPush,
88
+ normalizeBaseBranch,
89
+ sanitizeGitEnv,
90
+ } from "../git/git-safety.mjs";
87
91
  import {
88
92
  loadHooks,
89
93
  registerBuiltinHooks,
@@ -960,6 +964,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
960
964
  cwd: repoRoot,
961
965
  encoding: "utf8",
962
966
  timeout: 5000,
967
+ env: sanitizeGitEnv(),
963
968
  });
964
969
  if (localCheck.status === 0) {
965
970
  return branch;
@@ -971,6 +976,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
971
976
  cwd: repoRoot,
972
977
  encoding: "utf8",
973
978
  timeout: 8000,
979
+ env: sanitizeGitEnv(),
974
980
  });
975
981
  remoteExists = remoteLocalCheck.status === 0;
976
982
  if (!remoteExists) {
@@ -979,11 +985,13 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
979
985
  cwd: repoRoot,
980
986
  encoding: "utf8",
981
987
  timeout: 15000,
988
+ env: sanitizeGitEnv(),
982
989
  });
983
990
  const refreshedCheck = spawnSync("git", ["show-ref", "--verify", remoteHeadRef], {
984
991
  cwd: repoRoot,
985
992
  encoding: "utf8",
986
993
  timeout: 8000,
994
+ env: sanitizeGitEnv(),
987
995
  });
988
996
  remoteExists = refreshedCheck.status === 0;
989
997
  } catch {
@@ -996,6 +1004,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
996
1004
  cwd: repoRoot,
997
1005
  encoding: "utf8",
998
1006
  timeout: 8000,
1007
+ env: sanitizeGitEnv(),
999
1008
  });
1000
1009
  return branch;
1001
1010
  }
@@ -1006,6 +1015,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
1006
1015
  cwd: repoRoot,
1007
1016
  encoding: "utf8",
1008
1017
  timeout: 15000,
1018
+ env: sanitizeGitEnv(),
1009
1019
  });
1010
1020
  } catch {
1011
1021
  /* best-effort */
@@ -1014,7 +1024,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
1014
1024
  const createRes = spawnSync(
1015
1025
  "git",
1016
1026
  ["branch", branch, fallbackNorm.remoteRef],
1017
- { cwd: repoRoot, encoding: "utf8", timeout: 8000 },
1027
+ { cwd: repoRoot, encoding: "utf8", timeout: 8000, env: sanitizeGitEnv() },
1018
1028
  );
1019
1029
  if (createRes.status !== 0) {
1020
1030
  const stderr = (createRes.stderr || "").trim();
@@ -1028,6 +1038,7 @@ function ensureBaseBranchAvailable(repoRoot, baseBranch, defaultTargetBranch) {
1028
1038
  cwd: repoRoot,
1029
1039
  encoding: "utf8",
1030
1040
  timeout: 30_000,
1041
+ env: sanitizeGitEnv(),
1031
1042
  });
1032
1043
  return branch;
1033
1044
  }
@@ -11136,34 +11136,16 @@ function stopBatchFlushLoop() {
11136
11136
  */
11137
11137
  export async function startTelegramBot(options = {}) {
11138
11138
  refreshTelegramConfigFromEnv();
11139
- if (!telegramToken || !telegramChatId) {
11140
- console.warn(
11141
- "[telegram-bot] disabled (missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID)",
11142
- );
11143
- return;
11144
- }
11145
-
11146
- // Initialize the primary agent context
11147
- await initPrimaryAgent();
11148
-
11149
- // Probe Telegram API connectivity before startup registration
11150
- const reachable = await probeTelegramConnectivity();
11151
- if (reachable) {
11152
- await registerBotCommands();
11153
- } else {
11154
- console.warn(
11155
- "[telegram-bot] Telegram API unreachable at startup — command registration deferred",
11156
- );
11157
- }
11158
11139
 
11159
- // Start Telegram UI server (Mini App) when configured.
11140
+ // Start Telegram UI server (Mini App / Portal) when configured.
11160
11141
  // Portal startup is independent of Telegram polling state — it must always
11161
11142
  // run when TELEGRAM_UI_PORT or TELEGRAM_MINIAPP_ENABLED is set, even when
11162
- // polling is suppressed by a 409 conflict cooldown or another poll owner.
11143
+ // no Telegram bot token is configured (local-only portal mode).
11163
11144
  const miniAppEnabled = ["1", "true", "yes"].includes(
11164
11145
  String(process.env.TELEGRAM_MINIAPP_ENABLED || "").toLowerCase(),
11165
11146
  );
11166
11147
  const miniAppPort = Number(process.env.TELEGRAM_UI_PORT || "0");
11148
+ const hasTelegram = !!(telegramToken && telegramChatId);
11167
11149
 
11168
11150
  if (miniAppEnabled || miniAppPort > 0) {
11169
11151
  const restartReason = String(
@@ -11198,6 +11180,35 @@ export async function startTelegramBot(options = {}) {
11198
11180
  },
11199
11181
  });
11200
11182
  syncUiUrlsFromServer();
11183
+ } catch (err) {
11184
+ console.warn(`[telegram-bot] UI server start failed: ${err.message}`);
11185
+ }
11186
+ }
11187
+
11188
+ if (!hasTelegram) {
11189
+ console.warn(
11190
+ "[telegram-bot] Telegram polling disabled (missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID)" +
11191
+ (miniAppEnabled || miniAppPort > 0 ? " — portal UI is still active" : ""),
11192
+ );
11193
+ return;
11194
+ }
11195
+
11196
+ // Initialize the primary agent context
11197
+ await initPrimaryAgent();
11198
+
11199
+ // Probe Telegram API connectivity before startup registration
11200
+ const reachable = await probeTelegramConnectivity();
11201
+ if (reachable) {
11202
+ await registerBotCommands();
11203
+ } else {
11204
+ console.warn(
11205
+ "[telegram-bot] Telegram API unreachable at startup — command registration deferred",
11206
+ );
11207
+ }
11208
+
11209
+ // Wire up Telegram-specific UI integrations (menu button, firewall alerts)
11210
+ if (miniAppEnabled || miniAppPort > 0) {
11211
+ try {
11201
11212
  if (reachable && telegramWebAppUrl) {
11202
11213
  const updated = await setWebAppMenuButton(telegramWebAppUrl);
11203
11214
  if (updated) {
@@ -11269,7 +11280,7 @@ export async function startTelegramBot(options = {}) {
11269
11280
  }
11270
11281
  }
11271
11282
  } catch (err) {
11272
- console.warn(`[telegram-bot] UI server start failed: ${err.message}`);
11283
+ console.warn(`[telegram-bot] UI Telegram integration failed: ${err.message}`);
11273
11284
  if (reachable) {
11274
11285
  await clearWebAppMenuButton();
11275
11286
  lastMenuButtonUrl = null;