bosun 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.env.example +918 -0
  2. package/LICENSE +190 -0
  3. package/README.md +98 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/bosun.config.example.json +115 -0
  13. package/bosun.schema.json +465 -0
  14. package/claude-shell.mjs +708 -0
  15. package/cli.mjs +1028 -0
  16. package/codex-config.mjs +1274 -0
  17. package/codex-model-profiles.mjs +135 -0
  18. package/codex-shell.mjs +762 -0
  19. package/compat.mjs +286 -0
  20. package/config-doctor.mjs +613 -0
  21. package/config.mjs +1724 -0
  22. package/conflict-resolver.mjs +248 -0
  23. package/container-runner.mjs +450 -0
  24. package/copilot-shell.mjs +827 -0
  25. package/daemon-restart-policy.mjs +56 -0
  26. package/diff-stats.mjs +282 -0
  27. package/error-detector.mjs +829 -0
  28. package/fetch-runtime.mjs +34 -0
  29. package/fleet-coordinator.mjs +838 -0
  30. package/get-telegram-chat-id.mjs +71 -0
  31. package/git-safety.mjs +170 -0
  32. package/github-reconciler.mjs +403 -0
  33. package/hook-profiles.mjs +651 -0
  34. package/kanban-adapter.mjs +4491 -0
  35. package/lib/logger.mjs +645 -0
  36. package/maintenance.mjs +828 -0
  37. package/merge-strategy.mjs +1171 -0
  38. package/monitor.mjs +12237 -0
  39. package/package.json +209 -0
  40. package/postinstall.mjs +187 -0
  41. package/pr-cleanup-daemon.mjs +978 -0
  42. package/preflight.mjs +408 -0
  43. package/prepublish-check.mjs +90 -0
  44. package/presence.mjs +328 -0
  45. package/primary-agent.mjs +290 -0
  46. package/publish.mjs +241 -0
  47. package/repo-root.mjs +29 -0
  48. package/restart-controller.mjs +100 -0
  49. package/review-agent.mjs +557 -0
  50. package/rotate-agent-logs.sh +133 -0
  51. package/sdk-conflict-resolver.mjs +973 -0
  52. package/session-tracker.mjs +880 -0
  53. package/setup.mjs +3946 -0
  54. package/shared-knowledge.mjs +410 -0
  55. package/shared-state-manager.mjs +841 -0
  56. package/shared-workspace-cli.mjs +199 -0
  57. package/shared-workspace-registry.mjs +537 -0
  58. package/shared-workspaces.json +18 -0
  59. package/startup-service.mjs +1070 -0
  60. package/sync-engine.mjs +1063 -0
  61. package/task-archiver.mjs +801 -0
  62. package/task-assessment.mjs +550 -0
  63. package/task-claims.mjs +924 -0
  64. package/task-complexity.mjs +581 -0
  65. package/task-executor.mjs +5111 -0
  66. package/task-store.mjs +753 -0
  67. package/telegram-bot.mjs +9683 -0
  68. package/telegram-sentinel.mjs +2010 -0
  69. package/ui/app.js +867 -0
  70. package/ui/app.legacy.js +1464 -0
  71. package/ui/app.monolith.js +2488 -0
  72. package/ui/components/charts.js +226 -0
  73. package/ui/components/chat-view.js +567 -0
  74. package/ui/components/command-palette.js +587 -0
  75. package/ui/components/diff-viewer.js +190 -0
  76. package/ui/components/forms.js +357 -0
  77. package/ui/components/kanban-board.js +451 -0
  78. package/ui/components/session-list.js +305 -0
  79. package/ui/components/shared.js +525 -0
  80. package/ui/demo.html +640 -0
  81. package/ui/index.html +70 -0
  82. package/ui/modules/api.js +297 -0
  83. package/ui/modules/icons.js +461 -0
  84. package/ui/modules/router.js +81 -0
  85. package/ui/modules/settings-schema.js +261 -0
  86. package/ui/modules/state.js +679 -0
  87. package/ui/modules/telegram.js +331 -0
  88. package/ui/modules/utils.js +270 -0
  89. package/ui/styles/animations.css +140 -0
  90. package/ui/styles/base.css +98 -0
  91. package/ui/styles/components.css +2032 -0
  92. package/ui/styles/kanban.css +286 -0
  93. package/ui/styles/layout.css +810 -0
  94. package/ui/styles/sessions.css +841 -0
  95. package/ui/styles/variables.css +188 -0
  96. package/ui/styles.css +141 -0
  97. package/ui/styles.monolith.css +1046 -0
  98. package/ui/tabs/agents.js +1417 -0
  99. package/ui/tabs/chat.js +75 -0
  100. package/ui/tabs/control.js +892 -0
  101. package/ui/tabs/dashboard.js +515 -0
  102. package/ui/tabs/infra.js +537 -0
  103. package/ui/tabs/logs.js +783 -0
  104. package/ui/tabs/settings.js +1509 -0
  105. package/ui/tabs/tasks.js +1385 -0
  106. package/ui-server.mjs +4084 -0
  107. package/update-check.mjs +471 -0
  108. package/utils.mjs +172 -0
  109. package/ve-kanban.mjs +654 -0
  110. package/ve-kanban.ps1 +1365 -0
  111. package/ve-kanban.sh +18 -0
  112. package/ve-orchestrator.mjs +340 -0
  113. package/ve-orchestrator.ps1 +6546 -0
  114. package/ve-orchestrator.sh +18 -0
  115. package/vibe-kanban-wrapper.mjs +41 -0
  116. package/vk-error-resolver.mjs +470 -0
  117. package/vk-log-stream.mjs +914 -0
  118. package/whatsapp-channel.mjs +520 -0
  119. package/workspace-monitor.mjs +581 -0
  120. package/workspace-reaper.mjs +405 -0
  121. package/workspace-registry.mjs +238 -0
  122. package/worktree-manager.mjs +1266 -0
package/cli.mjs ADDED
@@ -0,0 +1,1028 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * bosun — CLI Entry Point
5
+ *
6
+ * Usage:
7
+ * bosun # start with default config
8
+ * bosun --setup # run setup wizard
9
+ * bosun --args "-MaxParallel 6" # pass orchestrator args
10
+ * bosun --help # show help
11
+ *
12
+ * The CLI handles:
13
+ * 1. First-run detection → auto-launches setup wizard
14
+ * 2. Command routing (setup, help, version, main start)
15
+ * 3. Configuration loading from config.mjs
16
+ */
17
+
18
+ import { resolve, dirname } from "node:path";
19
+ import {
20
+ existsSync,
21
+ readFileSync,
22
+ writeFileSync,
23
+ unlinkSync,
24
+ mkdirSync,
25
+ } from "node:fs";
26
+ import { fileURLToPath } from "node:url";
27
+ import { fork, spawn } from "node:child_process";
28
+ import os from "node:os";
29
+ import { createDaemonCrashTracker } from "./daemon-restart-policy.mjs";
30
+ import {
31
+ applyAllCompatibility,
32
+ detectLegacySetup,
33
+ migrateFromLegacy,
34
+ } from "./compat.mjs";
35
+
36
+ const __dirname = dirname(fileURLToPath(import.meta.url));
37
+ const args = process.argv.slice(2);
38
+
39
+ function getArgValue(flag) {
40
+ const match = args.find((arg) => arg.startsWith(`${flag}=`));
41
+ if (match) {
42
+ return match.slice(flag.length + 1).trim();
43
+ }
44
+ const idx = args.indexOf(flag);
45
+ if (idx >= 0 && args[idx + 1] && !args[idx + 1].startsWith("--")) {
46
+ return args[idx + 1].trim();
47
+ }
48
+ return "";
49
+ }
50
+
51
+ // ── Version (read from package.json — single source of truth) ────────────────
52
+
53
+ const VERSION = JSON.parse(
54
+ readFileSync(resolve(__dirname, "package.json"), "utf8"),
55
+ ).version;
56
+
57
+ // ── Help ─────────────────────────────────────────────────────────────────────
58
+
59
+ function showHelp() {
60
+ console.log(`
61
+ bosun v${VERSION}
62
+ AI-powered orchestrator supervisor with executor failover, smart PR flow, and Telegram notifications.
63
+
64
+ USAGE
65
+ bosun [options]
66
+
67
+ COMMANDS
68
+ --setup Run the interactive setup wizard
69
+ --doctor Validate bosun .env/config setup
70
+ --help Show this help
71
+ --version Show version
72
+ --update Check for and install latest version
73
+ --no-update-check Skip automatic update check on startup
74
+ --no-auto-update Disable background auto-update polling
75
+ --daemon, -d Run as a background daemon (detached, with PID file)
76
+ --stop-daemon Stop a running daemon process
77
+ --daemon-status Check if daemon is running
78
+
79
+ ORCHESTRATOR
80
+ --script <path> Path to the orchestrator script
81
+ --args "<args>" Arguments passed to the script (default: "-MaxParallel 6")
82
+ --restart-delay <ms> Delay before restart (default: 10000)
83
+ --max-restarts <n> Max restarts, 0 = unlimited (default: 0)
84
+
85
+ LOGGING
86
+ --log-dir <path> Log directory (default: ./logs)
87
+ --echo-logs Echo raw orchestrator output to console (off by default)
88
+ --quiet, -q Only show warnings and errors in terminal
89
+ --verbose, -V Show debug-level messages in terminal
90
+ --trace Show all messages including trace-level
91
+ --log-level <level> Set explicit log level (trace|debug|info|warn|error|silent)
92
+
93
+ AI / CODEX
94
+ --no-codex Disable Codex SDK analysis
95
+ --no-autofix Disable automatic error fixing
96
+ --primary-agent <name> Override primary agent (codex|copilot|claude)
97
+ --shell, --interactive Enable interactive shell mode in monitor
98
+
99
+ TELEGRAM
100
+ --no-telegram-bot Disable the interactive Telegram bot
101
+ --telegram-commands Enable monitor-side Telegram polling (advanced)
102
+
103
+ WHATSAPP
104
+ --whatsapp-auth Run WhatsApp authentication (QR code mode)
105
+ --whatsapp-auth --pairing-code Authenticate via pairing code instead of QR
106
+
107
+ CONTAINERS
108
+ Container support is configured via environment variables:
109
+ CONTAINER_ENABLED=1 Enable container isolation for agent execution
110
+ CONTAINER_RUNTIME=docker Runtime to use (docker|podman|container)
111
+
112
+ VIBE-KANBAN
113
+ --no-vk-spawn Don't auto-spawn Vibe-Kanban
114
+ --vk-ensure-interval <ms> VK health check interval (default: 60000)
115
+
116
+ STARTUP SERVICE
117
+ --enable-startup Register bosun to auto-start on login
118
+ --disable-startup Remove bosun from startup services
119
+ --startup-status Check if startup service is installed
120
+
121
+ SENTINEL
122
+ --sentinel Start telegram-sentinel in companion mode
123
+ --sentinel-stop Stop a running sentinel
124
+ --sentinel-status Show sentinel status
125
+
126
+ FILE WATCHING
127
+ --no-watch Disable file watching for auto-restart
128
+ --watch-path <path> File to watch (default: script path)
129
+
130
+ CONFIGURATION
131
+ --config-dir <path> Directory containing config files
132
+ --repo-root <path> Repository root (auto-detected)
133
+ --project-name <name> Project name for display
134
+ --repo <org/repo> GitHub repo slug
135
+ --repo-name <name> Select repository from multi-repo config
136
+ --profile <name> Environment profile selection
137
+ --mode <name> Override mode (virtengine/generic)
138
+
139
+ ENVIRONMENT
140
+ Configuration is loaded from (in priority order):
141
+ 1. CLI flags
142
+ 2. Environment variables
143
+ 3. .env file
144
+ 4. bosun.config.json
145
+ 5. Built-in defaults
146
+
147
+ Auto-update environment variables:
148
+ BOSUN_SKIP_UPDATE_CHECK=1 Disable startup version check
149
+ BOSUN_SKIP_AUTO_UPDATE=1 Disable background polling
150
+ BOSUN_UPDATE_INTERVAL_MS=N Override poll interval (default: 600000)
151
+
152
+ See .env.example for all environment variables.
153
+
154
+ EXECUTOR CONFIG (bosun.config.json)
155
+ {
156
+ "projectName": "my-project",
157
+ "executors": [
158
+ { "name": "copilot-claude", "executor": "COPILOT", "variant": "CLAUDE_OPUS_4_6", "weight": 50, "role": "primary" },
159
+ { "name": "codex-default", "executor": "CODEX", "variant": "DEFAULT", "weight": 50, "role": "backup" }
160
+ ],
161
+ "failover": {
162
+ "strategy": "next-in-line",
163
+ "maxRetries": 3,
164
+ "cooldownMinutes": 5,
165
+ "disableOnConsecutiveFailures": 3
166
+ },
167
+ "distribution": "weighted"
168
+ }
169
+
170
+ EXECUTOR ENV SHORTHAND
171
+ EXECUTORS=COPILOT:CLAUDE_OPUS_4_6:50,CODEX:DEFAULT:50
172
+
173
+ EXAMPLES
174
+ bosun # start with defaults
175
+ bosun --setup # interactive setup
176
+ bosun --script ./my-orchestrator.sh # custom script
177
+ bosun --args "-MaxParallel 4" --no-telegram-bot # custom args
178
+ bosun --no-codex --no-autofix # minimal mode
179
+
180
+ DOCS
181
+ https://www.npmjs.com/package/@virtengine/bosun
182
+ `);
183
+ }
184
+
185
+ // ── Main ─────────────────────────────────────────────────────────────────────
186
+
187
+ // ── Daemon Mode ──────────────────────────────────────────────────────────────
188
+
189
+ const PID_FILE = resolve(__dirname, ".cache", "bosun.pid");
190
+ const DAEMON_LOG = resolve(__dirname, "logs", "daemon.log");
191
+ const SENTINEL_PID_FILE = resolve(
192
+ __dirname,
193
+ "..",
194
+ "..",
195
+ ".cache",
196
+ "telegram-sentinel.pid",
197
+ );
198
+ const SENTINEL_PID_FILE_LEGACY = resolve(
199
+ __dirname,
200
+ ".cache",
201
+ "telegram-sentinel.pid",
202
+ );
203
+ const SENTINEL_SCRIPT_PATH = fileURLToPath(
204
+ new URL("./telegram-sentinel.mjs", import.meta.url),
205
+ );
206
+ const IS_DAEMON_CHILD =
207
+ args.includes("--daemon-child") || process.env.BOSUN_DAEMON === "1";
208
+ const DAEMON_RESTART_DELAY_MS = Math.max(
209
+ 1000,
210
+ Number(process.env.BOSUN_DAEMON_RESTART_DELAY_MS || 5000) || 5000,
211
+ );
212
+ const DAEMON_MAX_RESTARTS = Math.max(
213
+ 0,
214
+ Number(process.env.BOSUN_DAEMON_MAX_RESTARTS || 0) || 0,
215
+ );
216
+ const DAEMON_INSTANT_CRASH_WINDOW_MS = Math.max(
217
+ 1000,
218
+ Number(process.env.BOSUN_DAEMON_INSTANT_CRASH_WINDOW_MS || 15000) ||
219
+ 15000,
220
+ );
221
+ const DAEMON_MAX_INSTANT_RESTARTS = Math.max(
222
+ 1,
223
+ Number(process.env.BOSUN_DAEMON_MAX_INSTANT_RESTARTS || 3) || 3,
224
+ );
225
+ let daemonRestartCount = 0;
226
+ const daemonCrashTracker = createDaemonCrashTracker({
227
+ instantCrashWindowMs: DAEMON_INSTANT_CRASH_WINDOW_MS,
228
+ maxInstantCrashes: DAEMON_MAX_INSTANT_RESTARTS,
229
+ });
230
+
231
+ function isProcessAlive(pid) {
232
+ if (!Number.isFinite(pid) || pid <= 0) return false;
233
+ try {
234
+ process.kill(pid, 0);
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ function readAlivePid(pidFile) {
242
+ try {
243
+ if (!existsSync(pidFile)) return null;
244
+ const raw = readFileSync(pidFile, "utf8").trim();
245
+ const pid = Number(raw);
246
+ if (!Number.isFinite(pid) || pid <= 0) return null;
247
+ return isProcessAlive(pid) ? pid : null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ function parseBoolEnv(val, fallback = false) {
254
+ if (val == null || String(val).trim() === "") return fallback;
255
+ return ["1", "true", "yes", "on"].includes(String(val).toLowerCase());
256
+ }
257
+
258
+ function sleep(ms) {
259
+ return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
260
+ }
261
+
262
+ async function runSentinelCli(flag) {
263
+ return await new Promise((resolveExit) => {
264
+ const child = spawn(process.execPath, [SENTINEL_SCRIPT_PATH, flag], {
265
+ stdio: "inherit",
266
+ env: { ...process.env },
267
+ cwd: process.cwd(),
268
+ });
269
+ child.on("error", () => resolveExit(1));
270
+ child.on("exit", (code) => resolveExit(code ?? 1));
271
+ });
272
+ }
273
+
274
+ async function ensureSentinelRunning(options = {}) {
275
+ const { quiet = false } = options;
276
+ const existing =
277
+ readAlivePid(SENTINEL_PID_FILE) || readAlivePid(SENTINEL_PID_FILE_LEGACY);
278
+ if (existing) {
279
+ if (!quiet) {
280
+ console.log(` telegram-sentinel already running (PID ${existing})`);
281
+ }
282
+ return { ok: true, pid: existing, alreadyRunning: true };
283
+ }
284
+
285
+ const child = spawn(process.execPath, [SENTINEL_SCRIPT_PATH], {
286
+ detached: true,
287
+ stdio: "ignore",
288
+ windowsHide: process.platform === "win32",
289
+ env: {
290
+ ...process.env,
291
+ BOSUN_SENTINEL_COMPANION: "1",
292
+ },
293
+ cwd: process.cwd(),
294
+ });
295
+ child.unref();
296
+
297
+ const spawnedPid = child.pid;
298
+ if (!spawnedPid) {
299
+ return { ok: false, error: "sentinel spawn returned no PID" };
300
+ }
301
+
302
+ const timeoutAt = Date.now() + 5000;
303
+ while (Date.now() < timeoutAt) {
304
+ await sleep(200);
305
+ const pid =
306
+ readAlivePid(SENTINEL_PID_FILE) || readAlivePid(SENTINEL_PID_FILE_LEGACY);
307
+ if (pid) {
308
+ if (!quiet) {
309
+ console.log(` telegram-sentinel started (PID ${pid})`);
310
+ }
311
+ return { ok: true, pid, alreadyRunning: false };
312
+ }
313
+ if (!isProcessAlive(spawnedPid)) {
314
+ return {
315
+ ok: false,
316
+ error: "telegram-sentinel exited during startup",
317
+ };
318
+ }
319
+ }
320
+
321
+ return {
322
+ ok: false,
323
+ error: "timed out waiting for telegram-sentinel to become healthy",
324
+ };
325
+ }
326
+
327
+ function getDaemonPid() {
328
+ try {
329
+ if (!existsSync(PID_FILE)) return null;
330
+ const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
331
+ if (isNaN(pid)) return null;
332
+ // Check if process is alive
333
+ try {
334
+ process.kill(pid, 0);
335
+ return pid;
336
+ } catch {
337
+ return null;
338
+ }
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+
344
+ function writePidFile(pid) {
345
+ try {
346
+ mkdirSync(dirname(PID_FILE), { recursive: true });
347
+ writeFileSync(PID_FILE, String(pid), "utf8");
348
+ } catch {
349
+ /* best effort */
350
+ }
351
+ }
352
+
353
+ function removePidFile() {
354
+ try {
355
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
356
+ } catch {
357
+ /* ok */
358
+ }
359
+ }
360
+
361
+ function startDaemon() {
362
+ const existing = getDaemonPid();
363
+ if (existing) {
364
+ console.log(` bosun daemon is already running (PID ${existing})`);
365
+ console.log(` Use --stop-daemon to stop it first.`);
366
+ process.exit(1);
367
+ }
368
+
369
+ // Ensure log directory exists
370
+ try {
371
+ mkdirSync(dirname(DAEMON_LOG), { recursive: true });
372
+ } catch {
373
+ /* ok */
374
+ }
375
+
376
+ const child = spawn(
377
+ process.execPath,
378
+ [
379
+ "--max-old-space-size=4096",
380
+ fileURLToPath(new URL("./cli.mjs", import.meta.url)),
381
+ ...process.argv.slice(2).filter((a) => a !== "--daemon" && a !== "-d"),
382
+ "--daemon-child",
383
+ ],
384
+ {
385
+ detached: true,
386
+ stdio: "ignore",
387
+ windowsHide: process.platform === "win32",
388
+ env: { ...process.env, BOSUN_DAEMON: "1" },
389
+ // Use home dir so spawn never inherits a deleted CWD (e.g. old git worktree)
390
+ cwd: os.homedir(),
391
+ },
392
+ );
393
+
394
+ child.unref();
395
+ writePidFile(child.pid);
396
+
397
+ console.log(`
398
+ ╭──────────────────────────────────────────────────────────╮
399
+ │ bosun daemon started (PID ${String(child.pid).padEnd(24)}│
400
+ ╰──────────────────────────────────────────────────────────╯
401
+
402
+ Logs: ${DAEMON_LOG}
403
+ PID: ${PID_FILE}
404
+
405
+ Commands:
406
+ bosun --daemon-status Check if running
407
+ bosun --stop-daemon Stop the daemon
408
+ bosun --echo-logs Tail live logs
409
+ `);
410
+ process.exit(0);
411
+ }
412
+
413
+ function stopDaemon() {
414
+ const pid = getDaemonPid();
415
+ if (!pid) {
416
+ console.log(" No daemon running (PID file not found or process dead).");
417
+ removePidFile();
418
+ process.exit(0);
419
+ }
420
+ console.log(` Stopping bosun daemon (PID ${pid})...`);
421
+ try {
422
+ process.kill(pid, "SIGTERM");
423
+ // Wait briefly for graceful shutdown
424
+ let tries = 0;
425
+ const check = () => {
426
+ try {
427
+ process.kill(pid, 0);
428
+ } catch {
429
+ removePidFile();
430
+ console.log(" ✓ Daemon stopped.");
431
+ process.exit(0);
432
+ }
433
+ if (++tries > 10) {
434
+ console.log(" Sending SIGKILL...");
435
+ try {
436
+ process.kill(pid, "SIGKILL");
437
+ } catch {
438
+ /* ok */
439
+ }
440
+ removePidFile();
441
+ console.log(" ✓ Daemon killed.");
442
+ process.exit(0);
443
+ }
444
+ setTimeout(check, 500);
445
+ };
446
+ setTimeout(check, 500);
447
+ } catch (err) {
448
+ console.error(` Failed to stop daemon: ${err.message}`);
449
+ removePidFile();
450
+ process.exit(1);
451
+ }
452
+ }
453
+
454
+ function daemonStatus() {
455
+ const pid = getDaemonPid();
456
+ if (pid) {
457
+ console.log(` bosun daemon is running (PID ${pid})`);
458
+ } else {
459
+ console.log(" bosun daemon is not running.");
460
+ removePidFile();
461
+ }
462
+ process.exit(0);
463
+ }
464
+
465
+ async function main() {
466
+ // Apply legacy CODEX_MONITOR_* → BOSUN_* env aliases before any config ops
467
+ applyAllCompatibility();
468
+
469
+ // Handle --help
470
+ if (args.includes("--help") || args.includes("-h")) {
471
+ showHelp();
472
+ process.exit(0);
473
+ }
474
+
475
+ // Handle --version
476
+ if (args.includes("--version") || args.includes("-v")) {
477
+ console.log(`bosun v${VERSION}`);
478
+ process.exit(0);
479
+ }
480
+
481
+ // Handle --doctor
482
+ if (args.includes("--doctor") || args.includes("doctor")) {
483
+ const { runConfigDoctor, formatConfigDoctorReport } =
484
+ await import("./config-doctor.mjs");
485
+ const result = runConfigDoctor();
486
+ console.log(formatConfigDoctorReport(result));
487
+ process.exit(result.ok ? 0 : 1);
488
+ }
489
+
490
+ // Handle sentinel controls
491
+ if (args.includes("--sentinel-stop")) {
492
+ process.exit(await runSentinelCli("--stop"));
493
+ }
494
+ if (args.includes("--sentinel-status")) {
495
+ process.exit(await runSentinelCli("--status"));
496
+ }
497
+
498
+ // Handle --daemon
499
+ if (args.includes("--daemon") || args.includes("-d")) {
500
+ const { shouldRunSetup, runSetup } = await import("./setup.mjs");
501
+ if (shouldRunSetup()) {
502
+ console.log(
503
+ "\n 🚀 First run detected — setup is required before daemon mode.\n",
504
+ );
505
+ await runSetup();
506
+ console.log("\n Setup complete. Starting daemon...\n");
507
+ }
508
+ startDaemon();
509
+ return;
510
+ }
511
+ if (args.includes("--stop-daemon")) {
512
+ stopDaemon();
513
+ return;
514
+ }
515
+ if (args.includes("--daemon-status")) {
516
+ daemonStatus();
517
+ return;
518
+ }
519
+
520
+ // Write PID file if running as daemon child
521
+ if (
522
+ args.includes("--daemon-child") ||
523
+ process.env.BOSUN_DAEMON === "1"
524
+ ) {
525
+ writePidFile(process.pid);
526
+ // Redirect console to log file on daemon child
527
+ const { createWriteStream } = await import("node:fs");
528
+ const logStream = createWriteStream(DAEMON_LOG, { flags: "a" });
529
+ let logStreamErrored = false;
530
+ logStream.on("error", () => {
531
+ logStreamErrored = true;
532
+ });
533
+ const origStdout = process.stdout.write.bind(process.stdout);
534
+ const origStderr = process.stderr.write.bind(process.stderr);
535
+ const safeWrite = (writeFn, chunk, args) => {
536
+ try {
537
+ return writeFn(chunk, ...args);
538
+ } catch (err) {
539
+ if (
540
+ err &&
541
+ (err.code === "EPIPE" ||
542
+ err.code === "ERR_STREAM_DESTROYED" ||
543
+ err.code === "ERR_STREAM_WRITE_AFTER_END")
544
+ ) {
545
+ return false;
546
+ }
547
+ throw err;
548
+ }
549
+ };
550
+ process.stdout.write = (chunk, ...a) => {
551
+ if (!logStreamErrored) {
552
+ safeWrite(logStream.write.bind(logStream), chunk, []);
553
+ }
554
+ return safeWrite(origStdout, chunk, a);
555
+ };
556
+ process.stderr.write = (chunk, ...a) => {
557
+ if (!logStreamErrored) {
558
+ safeWrite(logStream.write.bind(logStream), chunk, []);
559
+ }
560
+ return safeWrite(origStderr, chunk, a);
561
+ };
562
+ console.log(
563
+ `\n[daemon] bosun started at ${new Date().toISOString()} (PID ${process.pid})`,
564
+ );
565
+ }
566
+
567
+ const sentinelRequested =
568
+ args.includes("--sentinel") ||
569
+ parseBoolEnv(process.env.BOSUN_SENTINEL_AUTO_START, false);
570
+ if (sentinelRequested) {
571
+ const sentinel = await ensureSentinelRunning({ quiet: false });
572
+ if (!sentinel.ok) {
573
+ const mode = args.includes("--sentinel")
574
+ ? "requested by --sentinel"
575
+ : "requested by BOSUN_SENTINEL_AUTO_START";
576
+ const strictSentinel = parseBoolEnv(
577
+ process.env.BOSUN_SENTINEL_STRICT,
578
+ false,
579
+ );
580
+ const prefix = strictSentinel ? "✖" : "⚠";
581
+ const suffix = strictSentinel
582
+ ? ""
583
+ : " (continuing without sentinel companion)";
584
+ console.error(
585
+ ` ${prefix} Failed to start telegram-sentinel (${mode}): ${sentinel.error}${suffix}`,
586
+ );
587
+ if (strictSentinel) {
588
+ process.exit(1);
589
+ }
590
+ }
591
+ }
592
+
593
+ // Handle --enable-startup / --disable-startup / --startup-status
594
+ if (args.includes("--enable-startup")) {
595
+ const { installStartupService, getStartupMethodName } =
596
+ await import("./startup-service.mjs");
597
+ const result = await installStartupService({ daemon: true });
598
+ if (result.success) {
599
+ console.log(` \u2705 Startup service installed via ${result.method}`);
600
+ if (result.path) console.log(` Path: ${result.path}`);
601
+ if (result.name) console.log(` Name: ${result.name}`);
602
+ console.log(`\n bosun will auto-start on login.`);
603
+ } else {
604
+ console.error(
605
+ ` \u274c Failed to install startup service: ${result.error}`,
606
+ );
607
+ }
608
+ process.exit(result.success ? 0 : 1);
609
+ }
610
+ if (args.includes("--disable-startup")) {
611
+ const { removeStartupService } = await import("./startup-service.mjs");
612
+ const result = await removeStartupService();
613
+ if (result.success) {
614
+ console.log(` \u2705 Startup service removed (${result.method})`);
615
+ } else {
616
+ console.error(
617
+ ` \u274c Failed to remove startup service: ${result.error}`,
618
+ );
619
+ }
620
+ process.exit(result.success ? 0 : 1);
621
+ }
622
+ if (args.includes("--startup-status")) {
623
+ const { getStartupStatus } = await import("./startup-service.mjs");
624
+ const status = getStartupStatus();
625
+ if (status.installed) {
626
+ console.log(` Startup service: installed (${status.method})`);
627
+ if (status.name) console.log(` Name: ${status.name}`);
628
+ if (status.path) console.log(` Path: ${status.path}`);
629
+ if (status.running !== undefined)
630
+ console.log(` Running: ${status.running ? "yes" : "no"}`);
631
+ } else {
632
+ console.log(` Startup service: not installed`);
633
+ console.log(` Run 'bosun --enable-startup' to register.`);
634
+ }
635
+ process.exit(0);
636
+ }
637
+
638
+ // Handle --update (force update)
639
+ if (args.includes("--update")) {
640
+ const { forceUpdate } = await import("./update-check.mjs");
641
+ await forceUpdate(VERSION);
642
+ process.exit(0);
643
+ }
644
+
645
+ // ── Startup banner with update check ──────────────────────────────────────
646
+ console.log("");
647
+ console.log(" ╭──────────────────────────────────────────────────────────╮");
648
+ console.log(
649
+ ` │ >_ bosun (v${VERSION})${" ".repeat(Math.max(0, 39 - VERSION.length))}│`,
650
+ );
651
+ console.log(" ╰──────────────────────────────────────────────────────────╯");
652
+
653
+ // Non-blocking update check (don't delay startup)
654
+ if (!args.includes("--no-update-check")) {
655
+ import("./update-check.mjs")
656
+ .then(({ checkForUpdate }) => checkForUpdate(VERSION))
657
+ .catch(() => {}); // silent — never block startup
658
+ }
659
+
660
+ // Propagate --no-auto-update to env for monitor.mjs to pick up
661
+ if (args.includes("--no-auto-update")) {
662
+ process.env.BOSUN_SKIP_AUTO_UPDATE = "1";
663
+ }
664
+
665
+ // Mark all child processes as bosun managed.
666
+ // The agent-hook-bridge checks this to avoid firing hooks for standalone
667
+ // agent sessions that happen to have hook config files in their tree.
668
+ process.env.VE_MANAGED = "1";
669
+
670
+ // Handle --setup
671
+ if (args.includes("--setup") || args.includes("setup")) {
672
+ const configDirArg = getArgValue("--config-dir");
673
+ if (configDirArg) {
674
+ process.env.BOSUN_DIR = configDirArg;
675
+ }
676
+ const { runSetup } = await import("./setup.mjs");
677
+ await runSetup();
678
+ process.exit(0);
679
+ }
680
+
681
+ // Handle --whatsapp-auth
682
+ if (args.includes("--whatsapp-auth") || args.includes("whatsapp-auth")) {
683
+ const mode = args.includes("--pairing-code") ? "pairing-code" : "qr";
684
+ const { runWhatsAppAuth } = await import("./whatsapp-channel.mjs");
685
+ await runWhatsAppAuth(mode);
686
+ process.exit(0);
687
+ }
688
+
689
+ // First-run detection
690
+ const { shouldRunSetup } = await import("./setup.mjs");
691
+ if (shouldRunSetup()) {
692
+ console.log("\n 🚀 First run detected — launching setup wizard...\n");
693
+ const configDirArg = getArgValue("--config-dir");
694
+ if (configDirArg) {
695
+ process.env.BOSUN_DIR = configDirArg;
696
+ }
697
+ const { runSetup } = await import("./setup.mjs");
698
+ await runSetup();
699
+ console.log("\n Setup complete! Starting bosun...\n");
700
+ }
701
+
702
+ // Legacy migration: if ~/codex-monitor exists with config, auto-migrate to ~/bosun
703
+ const legacyInfo = detectLegacySetup();
704
+ if (legacyInfo.hasLegacy && !legacyInfo.alreadyMigrated) {
705
+ console.log(
706
+ `\n 📦 Detected legacy codex-monitor config at ${legacyInfo.legacyDir}`,
707
+ );
708
+ console.log(` Auto-migrating to ${legacyInfo.newDir}...\n`);
709
+ const result = migrateFromLegacy(legacyInfo.legacyDir, legacyInfo.newDir);
710
+ if (result.migrated.length > 0) {
711
+ console.log(` ✅ Migrated: ${result.migrated.join(", ")}`);
712
+ console.log(`\n Config is now at ${legacyInfo.newDir}\n`);
713
+ }
714
+ for (const err of result.errors) {
715
+ console.log(` ⚠️ Migration warning: ${err}`);
716
+ }
717
+ }
718
+
719
+ // ── Handle --echo-logs: tail the active monitor's log instead of spawning a new instance ──
720
+ if (args.includes("--echo-logs")) {
721
+ // Search for the monitor PID file in common cache locations
722
+ const candidatePidFiles = [
723
+ PID_FILE,
724
+ process.env.BOSUN_DIR
725
+ ? resolve(process.env.BOSUN_DIR, ".cache", "bosun.pid")
726
+ : null,
727
+ resolve(__dirname, "..", ".cache", "bosun.pid"),
728
+ resolve(process.cwd(), ".cache", "bosun.pid"),
729
+ ].filter(Boolean);
730
+
731
+ let activePidFile = null;
732
+ for (const f of candidatePidFiles) {
733
+ if (existsSync(f)) {
734
+ activePidFile = f;
735
+ break;
736
+ }
737
+ }
738
+
739
+ if (activePidFile) {
740
+ try {
741
+ const raw = readFileSync(activePidFile, "utf8").trim();
742
+ let monitorPid;
743
+ let monitorPath = "";
744
+
745
+ // PID file can be a plain number (from writePidFile) or JSON (legacy format)
746
+ const parsed = parseInt(raw, 10);
747
+ if (!isNaN(parsed) && String(parsed) === raw) {
748
+ monitorPid = parsed;
749
+ // Derive the log directory from __dirname since we don't have argv
750
+ monitorPath = fileURLToPath(new URL("./cli.mjs", import.meta.url));
751
+ } else {
752
+ try {
753
+ const pidData = JSON.parse(raw);
754
+ monitorPid = Number(pidData.pid);
755
+ monitorPath = (pidData.argv || [])[1] || "";
756
+ } catch {
757
+ throw new Error(`Could not parse PID file: ${raw.slice(0, 100)}`);
758
+ }
759
+ }
760
+
761
+ let isAlive = false;
762
+ try {
763
+ process.kill(monitorPid, 0);
764
+ isAlive = true;
765
+ } catch {}
766
+
767
+ if (isAlive) {
768
+ const logDir = monitorPath ? resolve(dirname(monitorPath), "logs") : resolve(__dirname, "logs");
769
+ const daemonLog = existsSync(DAEMON_LOG) ? DAEMON_LOG : resolve(logDir, "daemon.log");
770
+ const monitorLog = resolve(logDir, "monitor.log");
771
+ const logFile = existsSync(daemonLog) ? daemonLog : monitorLog;
772
+
773
+ if (existsSync(logFile)) {
774
+ console.log(
775
+ `\n Tailing logs for active bosun (PID ${monitorPid}):\n ${logFile}\n`,
776
+ );
777
+ await new Promise((res) => {
778
+ // Spawn tail in its own process group (detached) so that
779
+ // Ctrl+C in this terminal only kills the tailing session,
780
+ // never the running daemon.
781
+ const tail = spawn("tail", ["-f", "-n", "200", logFile], {
782
+ stdio: ["ignore", "inherit", "inherit"],
783
+ detached: true,
784
+ });
785
+ tail.on("exit", res);
786
+ process.on("SIGINT", () => {
787
+ try { process.kill(-tail.pid, "SIGTERM"); } catch { tail.kill(); }
788
+ res();
789
+ });
790
+ });
791
+ process.exit(0);
792
+ } else {
793
+ console.error(
794
+ `\n No log file found for active bosun (PID ${monitorPid}).\n Expected: ${logFile}\n`,
795
+ );
796
+ process.exit(1);
797
+ }
798
+ }
799
+ } catch (e) {
800
+ console.error(`\n --echo-logs: failed to read PID file — ${e.message}\n`);
801
+ process.exit(1);
802
+ }
803
+ } else {
804
+ console.error(
805
+ "\n --echo-logs: no active bosun found (PID file missing).\n Start bosun first with: bosun --daemon\n",
806
+ );
807
+ process.exit(1);
808
+ }
809
+
810
+ // Should not reach here — all paths above exit
811
+ process.exit(0);
812
+ }
813
+
814
+ // Fork monitor as a child process — enables self-restart on source changes.
815
+ // When monitor exits with code 75, cli re-forks with a fresh ESM module cache.
816
+ await runMonitor();
817
+ }
818
+
819
+ // ── Crash notification (last resort — raw fetch when monitor can't start) ─────
820
+
821
+ function readEnvCredentials() {
822
+ const envPath = resolve(__dirname, ".env");
823
+ if (!existsSync(envPath)) return {};
824
+ const vars = {};
825
+ try {
826
+ const lines = readFileSync(envPath, "utf8").split("\n");
827
+ for (const line of lines) {
828
+ const trimmed = line.trim();
829
+ if (!trimmed || trimmed.startsWith("#")) continue;
830
+ const eqIdx = trimmed.indexOf("=");
831
+ if (eqIdx === -1) continue;
832
+ const key = trimmed.slice(0, eqIdx).trim();
833
+ let val = trimmed.slice(eqIdx + 1).trim();
834
+ if (
835
+ (val.startsWith('"') && val.endsWith('"')) ||
836
+ (val.startsWith("'") && val.endsWith("'"))
837
+ ) {
838
+ val = val.slice(1, -1);
839
+ }
840
+ if (
841
+ key === "TELEGRAM_BOT_TOKEN" ||
842
+ key === "TELEGRAM_CHAT_ID" ||
843
+ key === "PROJECT_NAME"
844
+ ) {
845
+ vars[key] = val;
846
+ }
847
+ }
848
+ } catch {
849
+ // best effort
850
+ }
851
+ return vars;
852
+ }
853
+
854
+ async function sendCrashNotification(exitCode, signal, options = {}) {
855
+ const { autoRestartInMs = 0, restartAttempt = 0, maxRestarts = 0 } = options;
856
+ const env = readEnvCredentials();
857
+ const token = env.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN;
858
+ const chatId = env.TELEGRAM_CHAT_ID || process.env.TELEGRAM_CHAT_ID;
859
+ if (!token || !chatId) return;
860
+
861
+ const project = env.PROJECT_NAME || process.env.PROJECT_NAME || "";
862
+ const host = os.hostname();
863
+ const tag = project ? `[${project}]` : "";
864
+ const reason = signal ? `signal ${signal}` : `exit code ${exitCode}`;
865
+ const isAutoRestart = Number(autoRestartInMs) > 0;
866
+ const restartLine = isAutoRestart
867
+ ? [
868
+ `Auto-restart scheduled in ${Math.max(1, Math.round(autoRestartInMs / 1000))}s.`,
869
+ restartAttempt > 0
870
+ ? `Restart attempt: ${restartAttempt}${maxRestarts > 0 ? `/${maxRestarts}` : ""}`
871
+ : "",
872
+ ]
873
+ .filter(Boolean)
874
+ .join("\n")
875
+ : "Monitor is no longer running. Manual restart required.";
876
+ const text =
877
+ `🔥 *CRASH* ${tag} bosun v${VERSION} died unexpectedly\n` +
878
+ `Host: \`${host}\`\n` +
879
+ `Reason: \`${reason}\`\n` +
880
+ `Time: ${new Date().toISOString()}\n\n` +
881
+ restartLine;
882
+
883
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
884
+ try {
885
+ await fetch(url, {
886
+ method: "POST",
887
+ headers: { "Content-Type": "application/json" },
888
+ body: JSON.stringify({
889
+ chat_id: chatId,
890
+ text,
891
+ parse_mode: "Markdown",
892
+ }),
893
+ signal: AbortSignal.timeout(10_000),
894
+ });
895
+ } catch {
896
+ // best effort — if Telegram is unreachable, nothing we can do
897
+ }
898
+ }
899
+
900
+ // ── Self-restart exit code (must match monitor.mjs SELF_RESTART_EXIT_CODE) ───
901
+ const SELF_RESTART_EXIT_CODE = 75;
902
+ let monitorChild = null;
903
+
904
+ function runMonitor() {
905
+ return new Promise((resolve, reject) => {
906
+ const monitorPath = fileURLToPath(
907
+ new URL("./monitor.mjs", import.meta.url),
908
+ );
909
+ monitorChild = fork(monitorPath, process.argv.slice(2), {
910
+ stdio: "inherit",
911
+ execArgv: ["--max-old-space-size=4096"],
912
+ windowsHide: IS_DAEMON_CHILD && process.platform === "win32",
913
+ });
914
+ daemonCrashTracker.markStart();
915
+
916
+ monitorChild.on("exit", (code, signal) => {
917
+ monitorChild = null;
918
+ if (code === SELF_RESTART_EXIT_CODE) {
919
+ console.log(
920
+ "\n \u21BB Monitor source changed \u2014 restarting with fresh modules...\n",
921
+ );
922
+ // Small delay to let file writes settle
923
+ setTimeout(() => resolve(runMonitor()), 1000);
924
+ } else {
925
+ const exitCode = code ?? (signal ? 1 : 0);
926
+ // 4294967295 (0xFFFFFFFF / -1 signed) = OS killed the process (OOM, external termination)
927
+ const isOSKill = exitCode === 4294967295 || exitCode === -1;
928
+ const shouldAutoRestart =
929
+ !gracefulShutdown &&
930
+ (isOSKill || (IS_DAEMON_CHILD && exitCode !== 0));
931
+ if (shouldAutoRestart) {
932
+ const crashState = daemonCrashTracker.recordExit();
933
+ daemonRestartCount += 1;
934
+ const delayMs = isOSKill ? 5000 : DAEMON_RESTART_DELAY_MS;
935
+ if (IS_DAEMON_CHILD && crashState.exceeded) {
936
+ const durationSec = Math.max(
937
+ 1,
938
+ Math.round(crashState.runDurationMs / 1000),
939
+ );
940
+ const windowSec = Math.max(
941
+ 1,
942
+ Math.round(crashState.instantCrashWindowMs / 1000),
943
+ );
944
+ console.error(
945
+ `\n ✖ Monitor crashed too quickly ${crashState.instantCrashCount} times in a row (each <= ${windowSec}s, latest ${durationSec}s). Auto-restart is now paused.`,
946
+ );
947
+ sendCrashNotification(exitCode, signal).finally(() =>
948
+ process.exit(exitCode),
949
+ );
950
+ return;
951
+ }
952
+ if (
953
+ IS_DAEMON_CHILD &&
954
+ DAEMON_MAX_RESTARTS > 0 &&
955
+ daemonRestartCount > DAEMON_MAX_RESTARTS
956
+ ) {
957
+ console.error(
958
+ `\n ✖ Monitor crashed too many times (${daemonRestartCount - 1} restarts, max ${DAEMON_MAX_RESTARTS}).`,
959
+ );
960
+ sendCrashNotification(exitCode, signal).finally(() =>
961
+ process.exit(exitCode),
962
+ );
963
+ return;
964
+ }
965
+ const reasonLabel = signal
966
+ ? `signal ${signal}`
967
+ : `exit code ${exitCode}`;
968
+ const attemptLabel =
969
+ IS_DAEMON_CHILD && DAEMON_MAX_RESTARTS > 0
970
+ ? `${daemonRestartCount}/${DAEMON_MAX_RESTARTS}`
971
+ : `${daemonRestartCount}`;
972
+ console.error(
973
+ `\n ⚠ Monitor exited (${reasonLabel}) — auto-restarting in ${Math.max(1, Math.round(delayMs / 1000))}s${IS_DAEMON_CHILD ? ` [attempt ${attemptLabel}]` : ""}...`,
974
+ );
975
+ sendCrashNotification(exitCode, signal, {
976
+ autoRestartInMs: delayMs,
977
+ restartAttempt: daemonRestartCount,
978
+ maxRestarts: IS_DAEMON_CHILD ? DAEMON_MAX_RESTARTS : 0,
979
+ }).catch(() => {});
980
+ setTimeout(() => resolve(runMonitor()), delayMs);
981
+ return;
982
+ }
983
+
984
+ if (exitCode !== 0 && !gracefulShutdown) {
985
+ console.error(
986
+ `\n ✖ Monitor crashed (${signal ? `signal ${signal}` : `exit code ${exitCode}`}) — sending crash notification...`,
987
+ );
988
+ sendCrashNotification(exitCode, signal).finally(() =>
989
+ process.exit(exitCode),
990
+ );
991
+ } else {
992
+ daemonRestartCount = 0;
993
+ daemonCrashTracker.reset();
994
+ process.exit(exitCode);
995
+ }
996
+ }
997
+ });
998
+
999
+ monitorChild.on("error", (err) => {
1000
+ monitorChild = null;
1001
+ console.error(`\n ✖ Monitor failed to start: ${err.message}`);
1002
+ sendCrashNotification(1, null).finally(() => reject(err));
1003
+ });
1004
+ });
1005
+ }
1006
+
1007
+ // Let forked monitor handle signal cleanup — prevent parent from dying first
1008
+ let gracefulShutdown = false;
1009
+ process.on("SIGINT", () => {
1010
+ gracefulShutdown = true;
1011
+ if (!monitorChild) process.exit(0);
1012
+ // Child gets SIGINT too via shared terminal — just wait for it to exit
1013
+ });
1014
+ process.on("SIGTERM", () => {
1015
+ gracefulShutdown = true;
1016
+ if (!monitorChild) process.exit(0);
1017
+ try {
1018
+ monitorChild.kill("SIGTERM");
1019
+ } catch {
1020
+ /* best effort */
1021
+ }
1022
+ });
1023
+
1024
+ main().catch(async (err) => {
1025
+ console.error(`bosun failed: ${err.message}`);
1026
+ await sendCrashNotification(1, null).catch(() => {});
1027
+ process.exit(1);
1028
+ });