bosun 0.28.0 → 0.28.2

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
@@ -155,6 +169,13 @@ TELEGRAM_MINIAPP_ENABLED=false
155
169
  # Priority threshold for immediate delivery: 1=critical only, 2=critical+errors (default: 1)
156
170
  # TELEGRAM_IMMEDIATE_PRIORITY=1
157
171
 
172
+ # ─── Auto-Delete Old Messages ──────────────────────────────────────────────────────────
173
+ # Automatically delete bot messages older than N days to keep chat tidy.
174
+ # Set to 0 to disable. Default: 3 days.
175
+ # Note: Telegram’s API may silently skip messages older than 48 h in private
176
+ # chats — those will just remain; no error is raised.
177
+ # TELEGRAM_HISTORY_RETENTION_DAYS=3
178
+
158
179
  # ─── Presence & Multi-Instance Coordination ──────────────────────────────────
159
180
  # Presence heartbeat allows discovering multiple bosun instances.
160
181
  # Heartbeat interval in seconds (default: 60)
package/README.md CHANGED
@@ -1,12 +1,23 @@
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
 
5
- [Website](https://bosun.virtengine.com) · [Docs](https://bosun.virtengine.com/docs/) · [GitHub](https://github.com/virtengine/bosun?tab=readme-ov-file#bosun) · [npm](https://www.npmjs.com/package/bosun) · [Issues](https://github.com/virtengine/bosun/issues)
8
+ <p align="center">
9
+ <a href="https://bosun.virtengine.com">Website</a> · <a href="https://bosun.virtengine.com/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
10
+ </p>
6
11
 
7
- [![CI](https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/virtengine/bosun/actions/workflows/ci.yaml)
8
- [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
9
- [![npm](https://img.shields.io/npm/v/bosun.svg)](https://www.npmjs.com/package/bosun)
12
+ <p align="center">
13
+ <img src="site/social-banner.png" alt="bosun — AI agent supervisor" width="100%" />
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://github.com/virtengine/bosun/actions/workflows/ci.yaml"><img src="https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI" /></a>
18
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License" /></a>
19
+ <a href="https://www.npmjs.com/package/bosun"><img src="https://img.shields.io/npm/v/bosun.svg" alt="npm" /></a>
20
+ </p>
10
21
 
11
22
  ---
12
23
 
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 ─────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync, spawn } from "node:child_process";
@@ -7,6 +7,28 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const desktopDir = resolve(__dirname);
8
8
  const binName = process.platform === "win32" ? "electron.cmd" : "electron";
9
9
  const electronBin = resolve(desktopDir, "node_modules", ".bin", binName);
10
+ const chromeSandbox = resolve(
11
+ desktopDir,
12
+ "node_modules",
13
+ "electron",
14
+ "dist",
15
+ "chrome-sandbox",
16
+ );
17
+
18
+ function shouldDisableSandbox() {
19
+ if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
20
+ if (process.platform !== "linux") return false;
21
+ if (!existsSync(chromeSandbox)) return true;
22
+ try {
23
+ const stats = statSync(chromeSandbox);
24
+ const mode = stats.mode & 0o7777;
25
+ const isRootOwned = stats.uid === 0;
26
+ const isSetuid = mode === 0o4755;
27
+ return !(isRootOwned && isSetuid);
28
+ } catch {
29
+ return true;
30
+ }
31
+ }
10
32
 
11
33
  function ensureElectronInstalled() {
12
34
  if (existsSync(electronBin)) return true;
@@ -28,11 +50,18 @@ function launch() {
28
50
  process.exit(1);
29
51
  }
30
52
 
31
- const child = spawn(electronBin, [desktopDir], {
53
+ const disableSandbox = shouldDisableSandbox();
54
+ const args = [desktopDir];
55
+ if (disableSandbox) {
56
+ args.push("--no-sandbox", "--disable-gpu-sandbox");
57
+ }
58
+
59
+ const child = spawn(electronBin, args, {
32
60
  stdio: "inherit",
33
61
  env: {
34
62
  ...process.env,
35
63
  BOSUN_DESKTOP: "1",
64
+ ...(disableSandbox ? { ELECTRON_DISABLE_SANDBOX: "1" } : {}),
36
65
  },
37
66
  });
38
67