bosun 0.28.0 → 0.28.1

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
@@ -77,7 +77,21 @@ TELEGRAM_MINIAPP_ENABLED=false
77
77
  # Full public URL override (takes precedence over host/port auto-detection).
78
78
  # Use when you have a reverse proxy or tunnel with HTTPS.
79
79
  # TELEGRAM_UI_BASE_URL=https://your-public-ui.example.com
80
- # Skip Telegram initData authentication (for local browser testing only).
80
+ # ╔══════════════════════════════════════════════════════════════════════╗
81
+ # ║ ⛔ DANGER — SECURITY CRITICAL ║
82
+ # ║ ║
83
+ # ║ Setting ALLOW_UNSAFE=true disables ALL authentication on the UI. ║
84
+ # ║ Anyone who discovers your URL can: ║
85
+ # ║ • Read/modify your tasks and settings ║
86
+ # ║ • Send commands to agents that execute code on YOUR machine ║
87
+ # ║ • Access secrets, API keys, and environment variables ║
88
+ # ║ ║
89
+ # ║ Combined with TELEGRAM_UI_TUNNEL=auto (Cloudflare tunnel), your UI ║
90
+ # ║ gets a PUBLIC internet URL — meaning ANYONE ON THE INTERNET can ║
91
+ # ║ find and control your machine. ║
92
+ # ║ ║
93
+ # ║ ONLY enable this for localhost-only debugging with tunnel DISABLED. ║
94
+ # ╚══════════════════════════════════════════════════════════════════════╝
81
95
  # TELEGRAM_UI_ALLOW_UNSAFE=false
82
96
  # Max age in seconds for initData auth tokens (default: 86400 = 24h)
83
97
  # TELEGRAM_UI_AUTH_MAX_AGE_SEC=86400
package/README.md CHANGED
@@ -1,4 +1,7 @@
1
- # ![virtengine bosun ai agent](site/logo.png) bosun
1
+ <p align="center">
2
+ <img src="site/logo.png" alt="virtengine bosun ai agent" width="150" />
3
+ </p>
4
+ <h1 align="center">bosun</h1>
2
5
 
3
6
  Bosun is a production-grade supervisor for AI coding agents. It routes tasks across executors, automates PR lifecycles, and keeps operators in control through Telegram, the Mini App dashboard, and optional WhatsApp notifications.
4
7
 
package/agent-pool.mjs CHANGED
@@ -71,6 +71,20 @@ function envFlagEnabled(value) {
71
71
  return ["1", "true", "yes", "on", "y"].includes(raw);
72
72
  }
73
73
 
74
+ /**
75
+ * Extract a human-readable task heading from the prompt built by _buildTaskPrompt.
76
+ * The first line is "# TASKID — Task Title"; we return the title portion only.
77
+ * Falls back to the raw first line if no em-dash separator is found.
78
+ * @param {string} prompt
79
+ * @returns {string}
80
+ */
81
+ function extractTaskHeading(prompt) {
82
+ const firstLine = String(prompt || "").split(/\r?\n/)[0].replace(/^#+\s*/, "").trim();
83
+ const dashIdx = firstLine.indexOf(" \u2014 ");
84
+ const title = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
85
+ return title || "Execute Task";
86
+ }
87
+
74
88
  function shouldAutoApproveCopilotPermissions() {
75
89
  const raw = process.env.COPILOT_AUTO_APPROVE_PERMISSIONS;
76
90
  if (raw === undefined || raw === null || String(raw).trim() === "") {
@@ -870,7 +884,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
870
884
  }
871
885
 
872
886
  const formattedPrompt =
873
- `# YOUR TASK — EXECUTE NOW\n\n${prompt}\n\n---\n` +
887
+ `# ${extractTaskHeading(prompt)}\n\n${prompt}\n\n---\n` +
874
888
  'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
875
889
 
876
890
  const hasSend = typeof session.send === "function";
@@ -1184,7 +1198,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1184
1198
  const msgQueue = createMessageQueue();
1185
1199
 
1186
1200
  const formattedPrompt =
1187
- `# YOUR TASK — EXECUTE NOW\n\n${prompt}\n\n---\n` +
1201
+ `# ${extractTaskHeading(prompt)}\n\n${prompt}\n\n---\n` +
1188
1202
  'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
1189
1203
 
1190
1204
  msgQueue.push(makeUserMessage(formattedPrompt));
@@ -0,0 +1,419 @@
1
+ /**
2
+ * Agent Work Stream Analyzer
3
+ *
4
+ * Tails agent-work-stream.jsonl in real-time, detects patterns, and emits alerts
5
+ * for bosun to consume.
6
+ *
7
+ * Features:
8
+ * - Error loop detection (same error N+ times)
9
+ * - Tool loop detection (same tool called rapidly)
10
+ * - Stuck agent detection (no progress for X minutes)
11
+ * - Context window exhaustion prediction
12
+ * - Cost anomaly detection (unusually expensive sessions)
13
+ */
14
+
15
+ import { readFile, writeFile, appendFile, stat, watch } from "fs/promises";
16
+ import { createReadStream, existsSync } from "fs";
17
+ import { createInterface } from "readline";
18
+ import { resolve, dirname } from "path";
19
+ import { fileURLToPath } from "url";
20
+
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const repoRoot = resolve(__dirname, "../..");
24
+
25
+ // ── Configuration ───────────────────────────────────────────────────────────
26
+ const AGENT_WORK_STREAM = resolve(
27
+ repoRoot,
28
+ ".cache/agent-work-logs/agent-work-stream.jsonl",
29
+ );
30
+ const ALERTS_LOG = resolve(
31
+ repoRoot,
32
+ ".cache/agent-work-logs/agent-alerts.jsonl",
33
+ );
34
+
35
+ const ERROR_LOOP_THRESHOLD = Number(
36
+ process.env.AGENT_ERROR_LOOP_THRESHOLD || "4",
37
+ );
38
+ const ERROR_LOOP_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
39
+
40
+ const TOOL_LOOP_THRESHOLD = Number(
41
+ process.env.AGENT_TOOL_LOOP_THRESHOLD || "10",
42
+ );
43
+ const TOOL_LOOP_WINDOW_MS = 60 * 1000; // 1 minute
44
+
45
+ const STUCK_DETECTION_THRESHOLD_MS = Number(
46
+ process.env.AGENT_STUCK_THRESHOLD_MS || String(5 * 60 * 1000),
47
+ ); // 5 minutes
48
+
49
+ const COST_ANOMALY_THRESHOLD_USD = Number(
50
+ process.env.AGENT_COST_ANOMALY_THRESHOLD || "1.0",
51
+ );
52
+
53
+ // ── State Tracking ──────────────────────────────────────────────────────────
54
+
55
+ // Active session state: sessionId -> { ... }
56
+ const activeSessions = new Map();
57
+
58
+ // Alert cooldowns: "alert_type:attempt_id" -> timestamp
59
+ const alertCooldowns = new Map();
60
+ const ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between same alert
61
+
62
+ // ── Log Tailing ─────────────────────────────────────────────────────────────
63
+
64
+ let filePosition = 0;
65
+ let isRunning = false;
66
+
67
+ /**
68
+ * Start the analyzer loop
69
+ */
70
+ export async function startAnalyzer() {
71
+ if (isRunning) return;
72
+ isRunning = true;
73
+
74
+ console.log("[agent-work-analyzer] Starting...");
75
+
76
+ // Ensure alerts log exists
77
+ if (!existsSync(ALERTS_LOG)) {
78
+ await writeFile(ALERTS_LOG, "");
79
+ }
80
+
81
+ // Initial read of existing log
82
+ if (existsSync(AGENT_WORK_STREAM)) {
83
+ filePosition = await processLogFile(filePosition);
84
+ }
85
+
86
+ // Watch for changes
87
+ console.log(`[agent-work-analyzer] Watching: ${AGENT_WORK_STREAM}`);
88
+
89
+ const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
90
+
91
+ try {
92
+ for await (const event of watcher) {
93
+ if (event.eventType === "change") {
94
+ filePosition = await processLogFile(filePosition);
95
+ }
96
+ }
97
+ } catch (err) {
98
+ if (err.code !== "ENOENT") {
99
+ console.error(`[agent-work-analyzer] Watcher error: ${err.message}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Stop the analyzer
106
+ */
107
+ export function stopAnalyzer() {
108
+ isRunning = false;
109
+ console.log("[agent-work-analyzer] Stopped");
110
+ }
111
+
112
+ /**
113
+ * Process log file from given position
114
+ * @param {number} startPosition - Byte offset to start reading from
115
+ * @returns {Promise<number>} New file position
116
+ */
117
+ async function processLogFile(startPosition) {
118
+ try {
119
+ const stats = await stat(AGENT_WORK_STREAM);
120
+ if (stats.size <= startPosition) {
121
+ return startPosition; // No new data
122
+ }
123
+
124
+ const stream = createReadStream(AGENT_WORK_STREAM, {
125
+ start: startPosition,
126
+ encoding: "utf8",
127
+ });
128
+
129
+ const rl = createInterface({ input: stream });
130
+ let bytesRead = startPosition;
131
+
132
+ for await (const line of rl) {
133
+ bytesRead += Buffer.byteLength(line, "utf8") + 1; // +1 for newline
134
+
135
+ try {
136
+ const event = JSON.parse(line);
137
+ await analyzeEvent(event);
138
+ } catch (err) {
139
+ console.error(
140
+ `[agent-work-analyzer] Failed to parse log line: ${err.message}`,
141
+ );
142
+ }
143
+ }
144
+
145
+ return bytesRead;
146
+ } catch (err) {
147
+ if (err.code !== "ENOENT") {
148
+ console.error(`[agent-work-analyzer] Error reading log: ${err.message}`);
149
+ }
150
+ return startPosition;
151
+ }
152
+ }
153
+
154
+ // ── Event Analysis ──────────────────────────────────────────────────────────
155
+
156
+ /**
157
+ * Analyze a single log event
158
+ * @param {Object} event - Parsed JSONL event
159
+ */
160
+ async function analyzeEvent(event) {
161
+ const { attempt_id, event_type, timestamp, data } = event;
162
+
163
+ // Initialize session state if needed
164
+ if (!activeSessions.has(attempt_id)) {
165
+ activeSessions.set(attempt_id, {
166
+ attempt_id,
167
+ errors: [],
168
+ toolCalls: [],
169
+ lastActivity: timestamp,
170
+ startedAt: timestamp,
171
+ taskId: event.task_id,
172
+ executor: event.executor,
173
+ });
174
+ }
175
+
176
+ const session = activeSessions.get(attempt_id);
177
+ session.lastActivity = timestamp;
178
+
179
+ // Route to specific analyzers
180
+ switch (event_type) {
181
+ case "error":
182
+ await analyzeError(session, event);
183
+ break;
184
+ case "tool_call":
185
+ await analyzeToolCall(session, event);
186
+ break;
187
+ case "session_start":
188
+ await analyzeSessionStart(session, event);
189
+ break;
190
+ case "session_end":
191
+ await analyzeSessionEnd(session, event);
192
+ activeSessions.delete(attempt_id);
193
+ break;
194
+ }
195
+
196
+ // Continuous checks
197
+ await checkStuckAgent(session, event);
198
+ }
199
+
200
+ // ── Pattern Analyzers ───────────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Analyze error events for loops
204
+ */
205
+ async function analyzeError(session, event) {
206
+ const { error_fingerprint, error_message } = event.data;
207
+
208
+ session.errors.push({
209
+ fingerprint: error_fingerprint || "unknown",
210
+ message: error_message,
211
+ timestamp: event.timestamp,
212
+ });
213
+
214
+ // Check for error loops
215
+ const cutoff = Date.now() - ERROR_LOOP_WINDOW_MS;
216
+ const recentErrors = session.errors.filter(
217
+ (e) => new Date(e.timestamp).getTime() >= cutoff,
218
+ );
219
+
220
+ const errorCounts = {};
221
+ for (const err of recentErrors) {
222
+ errorCounts[err.fingerprint] = (errorCounts[err.fingerprint] || 0) + 1;
223
+ }
224
+
225
+ // Alert if same error repeats N+ times
226
+ for (const [fingerprint, count] of Object.entries(errorCounts)) {
227
+ if (count >= ERROR_LOOP_THRESHOLD) {
228
+ await emitAlert({
229
+ type: "error_loop",
230
+ attempt_id: session.attempt_id,
231
+ task_id: session.taskId,
232
+ executor: session.executor,
233
+ error_fingerprint: fingerprint,
234
+ occurrences: count,
235
+ sample_message:
236
+ recentErrors.find((e) => e.fingerprint === fingerprint)?.message ||
237
+ "",
238
+ recommendation: "trigger_ai_autofix",
239
+ severity: "high",
240
+ });
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Analyze tool call events for loops
247
+ */
248
+ async function analyzeToolCall(session, event) {
249
+ const { tool_name } = event.data;
250
+
251
+ session.toolCalls.push({
252
+ tool: tool_name,
253
+ timestamp: event.timestamp,
254
+ });
255
+
256
+ // Check for tool loops
257
+ const cutoff = Date.now() - TOOL_LOOP_WINDOW_MS;
258
+ const recentCalls = session.toolCalls.filter(
259
+ (c) => new Date(c.timestamp).getTime() >= cutoff,
260
+ );
261
+
262
+ const toolCounts = {};
263
+ for (const call of recentCalls) {
264
+ toolCounts[call.tool] = (toolCounts[call.tool] || 0) + 1;
265
+ }
266
+
267
+ // Alert if same tool called N+ times rapidly
268
+ for (const [tool, count] of Object.entries(toolCounts)) {
269
+ if (count >= TOOL_LOOP_THRESHOLD) {
270
+ await emitAlert({
271
+ type: "tool_loop",
272
+ attempt_id: session.attempt_id,
273
+ task_id: session.taskId,
274
+ executor: session.executor,
275
+ tool_name: tool,
276
+ occurrences: count,
277
+ window_ms: TOOL_LOOP_WINDOW_MS,
278
+ recommendation: "fresh_session",
279
+ severity: "medium",
280
+ });
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Analyze session start events
287
+ */
288
+ async function analyzeSessionStart(session, event) {
289
+ const { prompt_type, followup_reason } = event.data;
290
+
291
+ // Track session restarts
292
+ if (prompt_type === "followup" || prompt_type === "retry") {
293
+ session.restartCount = (session.restartCount || 0) + 1;
294
+
295
+ // Alert if too many restarts
296
+ if (session.restartCount >= 3) {
297
+ await emitAlert({
298
+ type: "excessive_restarts",
299
+ attempt_id: session.attempt_id,
300
+ task_id: session.taskId,
301
+ executor: session.executor,
302
+ restart_count: session.restartCount,
303
+ last_reason: followup_reason,
304
+ recommendation: "manual_review",
305
+ severity: "medium",
306
+ });
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Analyze session end events
313
+ */
314
+ async function analyzeSessionEnd(session, event) {
315
+ const { completion_status, duration_ms, cost_usd } = event.data;
316
+
317
+ // Cost anomaly detection
318
+ if (cost_usd && cost_usd > COST_ANOMALY_THRESHOLD_USD) {
319
+ await emitAlert({
320
+ type: "cost_anomaly",
321
+ attempt_id: session.attempt_id,
322
+ task_id: session.taskId,
323
+ executor: session.executor,
324
+ cost_usd,
325
+ duration_ms,
326
+ threshold_usd: COST_ANOMALY_THRESHOLD_USD,
327
+ recommendation: "review_prompt_efficiency",
328
+ severity: "low",
329
+ });
330
+ }
331
+
332
+ // Failed session with many errors
333
+ if (
334
+ completion_status === "failed" &&
335
+ session.errors.length >= ERROR_LOOP_THRESHOLD
336
+ ) {
337
+ await emitAlert({
338
+ type: "failed_session_high_errors",
339
+ attempt_id: session.attempt_id,
340
+ task_id: session.taskId,
341
+ executor: session.executor,
342
+ error_count: session.errors.length,
343
+ error_fingerprints: [
344
+ ...new Set(session.errors.map((e) => e.fingerprint)),
345
+ ],
346
+ recommendation: "analyze_root_cause",
347
+ severity: "high",
348
+ });
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Check if agent appears stuck (no activity for X minutes)
354
+ */
355
+ async function checkStuckAgent(session, event) {
356
+ const lastActivityTime = new Date(session.lastActivity).getTime();
357
+ const timeSinceActivity = Date.now() - lastActivityTime;
358
+
359
+ if (timeSinceActivity > STUCK_DETECTION_THRESHOLD_MS) {
360
+ await emitAlert({
361
+ type: "stuck_agent",
362
+ attempt_id: session.attempt_id,
363
+ task_id: session.taskId,
364
+ executor: session.executor,
365
+ idle_time_ms: timeSinceActivity,
366
+ threshold_ms: STUCK_DETECTION_THRESHOLD_MS,
367
+ recommendation: "check_agent_health",
368
+ severity: "medium",
369
+ });
370
+ }
371
+ }
372
+
373
+ // ── Alert System ────────────────────────────────────────────────────────────
374
+
375
+ /**
376
+ * Emit an alert to the alerts log
377
+ * @param {Object} alert - Alert data
378
+ */
379
+ async function emitAlert(alert) {
380
+ const alertKey = `${alert.type}:${alert.attempt_id}`;
381
+
382
+ // Check cooldown
383
+ const lastAlert = alertCooldowns.get(alertKey);
384
+ if (lastAlert && Date.now() - lastAlert < ALERT_COOLDOWN_MS) {
385
+ return; // Skip duplicate alerts
386
+ }
387
+
388
+ alertCooldowns.set(alertKey, Date.now());
389
+
390
+ const alertEntry = {
391
+ timestamp: new Date().toISOString(),
392
+ ...alert,
393
+ };
394
+
395
+ console.error(`[ALERT] ${alert.type}: ${alert.attempt_id}`);
396
+
397
+ // Append to alerts log
398
+ try {
399
+ await appendFile(ALERTS_LOG, JSON.stringify(alertEntry) + "\n");
400
+ } catch (err) {
401
+ console.error(`[agent-work-analyzer] Failed to write alert: ${err.message}`);
402
+ }
403
+ }
404
+
405
+ // ── Cleanup Old Sessions ────────────────────────────────────────────────────
406
+
407
+ setInterval(() => {
408
+ const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour
409
+
410
+ for (const [attemptId, session] of activeSessions.entries()) {
411
+ const lastActivityTime = new Date(session.lastActivity).getTime();
412
+ if (lastActivityTime < cutoff) {
413
+ activeSessions.delete(attemptId);
414
+ }
415
+ }
416
+ }, 10 * 60 * 1000); // Cleanup every 10 minutes
417
+
418
+ // ── Exports ─────────────────────────────────────────────────────────────────
419
+
package/claude-shell.mjs CHANGED
@@ -430,12 +430,22 @@ async function saveState() {
430
430
  }
431
431
  }
432
432
 
433
+ function extractTaskHeading(msg) {
434
+ // Prompt first line is "# TASKID — Task Title" (from _buildTaskPrompt).
435
+ // Return just the task title portion, or a short fallback.
436
+ const firstLine = msg.split(/\r?\n/)[0].replace(/^#+\s*/, '').trim();
437
+ const dashIdx = firstLine.indexOf(' \u2014 ');
438
+ const heading = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
439
+ return heading || 'Execute Task';
440
+ }
441
+
433
442
  function buildPrompt(userMessage, statusData) {
443
+ const title = extractTaskHeading(userMessage);
434
444
  if (!statusData) {
435
- return `# YOUR TASK — EXECUTE NOW\n\n${userMessage}\n\n---\nDo NOT respond with \"Ready\" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
445
+ return `# ${title}\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
436
446
  }
437
447
  const statusSnippet = JSON.stringify(statusData, null, 2).slice(0, 2000);
438
- return `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n# YOUR TASK — EXECUTE NOW\n\n${userMessage}\n\n---\nDo NOT respond with \"Ready\" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
448
+ return `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n# ${title}\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
439
449
  }
440
450
 
441
451
  // ── Main Execution ─────────────────────────────────────────────────────────
package/desktop/main.mjs CHANGED
@@ -3,6 +3,8 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { execFileSync, spawn } from "node:child_process";
6
+ import { request as httpRequest } from "node:http";
7
+ import { request as httpsRequest } from "node:https";
6
8
  import { homedir } from "node:os";
7
9
 
8
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -12,6 +14,7 @@ let shuttingDown = false;
12
14
  let uiServerStarted = false;
13
15
  let uiOrigin = null;
14
16
  let uiApi = null;
17
+ let runtimeConfigLoaded = false;
15
18
 
16
19
  const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
17
20
 
@@ -86,12 +89,77 @@ async function loadBosunModule(file) {
86
89
  return import(pathToFileURL(modulePath).href);
87
90
  }
88
91
 
92
+ async function loadRuntimeConfig() {
93
+ if (runtimeConfigLoaded) return;
94
+ try {
95
+ const config = await loadBosunModule("config.mjs");
96
+ if (typeof config?.loadConfig === "function") {
97
+ config.loadConfig(["node", "desktop"], { reloadEnv: true });
98
+ }
99
+ } catch (err) {
100
+ console.warn("[desktop] failed to load config env", err?.message || err);
101
+ }
102
+ runtimeConfigLoaded = true;
103
+ }
104
+
89
105
  async function loadUiServerModule() {
90
106
  if (uiApi) return uiApi;
91
107
  uiApi = await loadBosunModule("ui-server.mjs");
92
108
  return uiApi;
93
109
  }
94
110
 
111
+ function buildDaemonUiBaseUrl() {
112
+ const rawPort = Number(process.env.TELEGRAM_UI_PORT || "0");
113
+ if (!Number.isFinite(rawPort) || rawPort <= 0) return null;
114
+ const tlsDisabled = parseBoolEnv(process.env.TELEGRAM_UI_TLS_DISABLE, false);
115
+ const protocol = tlsDisabled ? "http" : "https";
116
+ const host =
117
+ process.env.TELEGRAM_UI_DESKTOP_HOST ||
118
+ process.env.TELEGRAM_UI_HOST ||
119
+ "127.0.0.1";
120
+ return `${protocol}://${host}:${rawPort}`;
121
+ }
122
+
123
+ async function probeUiServer(url) {
124
+ return new Promise((resolve) => {
125
+ try {
126
+ const isHttps = url.startsWith("https://");
127
+ const req = (isHttps ? httpsRequest : httpRequest)(
128
+ `${url}/api/status`,
129
+ {
130
+ method: "GET",
131
+ timeout: 1500,
132
+ rejectUnauthorized: false,
133
+ },
134
+ (res) => {
135
+ res.resume();
136
+ resolve(Boolean(res.statusCode && res.statusCode < 500));
137
+ },
138
+ );
139
+ req.on("error", () => resolve(false));
140
+ req.on("timeout", () => {
141
+ req.destroy();
142
+ resolve(false);
143
+ });
144
+ req.end();
145
+ } catch {
146
+ resolve(false);
147
+ }
148
+ });
149
+ }
150
+
151
+ async function resolveDaemonUiUrl() {
152
+ const useDaemon = parseBoolEnv(
153
+ process.env.BOSUN_DESKTOP_USE_DAEMON_UI,
154
+ true,
155
+ );
156
+ if (!useDaemon) return null;
157
+ const base = buildDaemonUiBaseUrl();
158
+ if (!base) return null;
159
+ const ok = await probeUiServer(base);
160
+ return ok ? base : null;
161
+ }
162
+
95
163
  async function ensureDaemonRunning() {
96
164
  const autoStart = parseBoolEnv(
97
165
  process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
@@ -148,6 +216,13 @@ async function startUiServer() {
148
216
  }
149
217
 
150
218
  async function buildUiUrl() {
219
+ await loadRuntimeConfig();
220
+ const daemonUrl = await resolveDaemonUiUrl();
221
+ if (daemonUrl) {
222
+ uiOrigin = new URL(daemonUrl).origin;
223
+ return daemonUrl;
224
+ }
225
+ await startUiServer();
151
226
  const api = await loadUiServerModule();
152
227
  const uiServerUrl = api.getTelegramUiUrl();
153
228
  if (!uiServerUrl) {
@@ -196,8 +271,8 @@ async function bootstrap() {
196
271
  try {
197
272
  app.setAppUserModelId("com.virtengine.bosun");
198
273
  process.chdir(resolveBosunRoot());
274
+ await loadRuntimeConfig();
199
275
  await ensureDaemonRunning();
200
- await startUiServer();
201
276
  await createMainWindow();
202
277
  await maybeAutoUpdate();
203
278
  } catch (error) {
@@ -231,8 +306,10 @@ async function shutdown(reason) {
231
306
  }
232
307
 
233
308
  try {
234
- const api = await loadUiServerModule();
235
- api.stopTelegramUiServer();
309
+ if (uiServerStarted) {
310
+ const api = await loadUiServerModule();
311
+ api.stopTelegramUiServer();
312
+ }
236
313
  } catch (error) {
237
314
  console.error("[desktop] failed to stop ui-server", error);
238
315
  }
@@ -243,7 +320,7 @@ async function shutdown(reason) {
243
320
  app.on("before-quit", () => {
244
321
  shuttingDown = true;
245
322
  try {
246
- if (uiApi?.stopTelegramUiServer) {
323
+ if (uiServerStarted && uiApi?.stopTelegramUiServer) {
247
324
  uiApi.stopTelegramUiServer();
248
325
  }
249
326
  } catch (error) {