bosun 0.34.8 → 0.34.9

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/cli.mjs CHANGED
@@ -34,6 +34,15 @@ import {
34
34
  migrateFromLegacy,
35
35
  } from "./compat.mjs";
36
36
 
37
+ const MONITOR_START_MAX_WAIT_MS = Math.max(
38
+ 0,
39
+ Number(process.env.BOSUN_MONITOR_START_MAX_WAIT_MS || "15000") || 15000,
40
+ );
41
+ const MONITOR_START_RETRY_MS = Math.max(
42
+ 100,
43
+ Number(process.env.BOSUN_MONITOR_START_RETRY_MS || "500") || 500,
44
+ );
45
+
37
46
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
47
  const args = process.argv.slice(2);
39
48
 
@@ -1484,133 +1493,189 @@ function detectExistingMonitorLockOwner(excludePid = null) {
1484
1493
  return null;
1485
1494
  }
1486
1495
 
1496
+ function getRequiredMonitorRuntimeFiles(monitorPath) {
1497
+ const required = [monitorPath];
1498
+ const copilotDir = resolve(
1499
+ __dirname,
1500
+ "node_modules",
1501
+ "@github",
1502
+ "copilot",
1503
+ );
1504
+ const conptyAgentPath = resolve(copilotDir, "conpty_console_list_agent.js");
1505
+ if (process.platform === "win32" && existsSync(copilotDir)) {
1506
+ required.push(conptyAgentPath);
1507
+ }
1508
+ return required;
1509
+ }
1510
+
1511
+ function listMissingFiles(paths) {
1512
+ return paths.filter((entry) => !existsSync(entry));
1513
+ }
1514
+
1515
+ async function waitForMonitorRuntimeFiles(monitorPath) {
1516
+ const required = getRequiredMonitorRuntimeFiles(monitorPath);
1517
+ const startedAt = Date.now();
1518
+ let missing = listMissingFiles(required);
1519
+ while (
1520
+ missing.length > 0 &&
1521
+ Date.now() - startedAt < MONITOR_START_MAX_WAIT_MS
1522
+ ) {
1523
+ await new Promise((resolveWait) => {
1524
+ setTimeout(resolveWait, MONITOR_START_RETRY_MS);
1525
+ });
1526
+ missing = listMissingFiles(required);
1527
+ }
1528
+ return {
1529
+ ready: missing.length === 0,
1530
+ missing,
1531
+ waitedMs: Date.now() - startedAt,
1532
+ };
1533
+ }
1534
+
1487
1535
  function runMonitor({ restartReason = "" } = {}) {
1488
1536
  return new Promise((resolve, reject) => {
1489
1537
  const monitorPath = fileURLToPath(
1490
1538
  new URL("./monitor.mjs", import.meta.url),
1491
1539
  );
1492
- const childEnv = { ...process.env };
1493
- if (restartReason) {
1494
- childEnv.BOSUN_MONITOR_RESTART_REASON = restartReason;
1495
- } else {
1496
- delete childEnv.BOSUN_MONITOR_RESTART_REASON;
1497
- }
1498
- monitorChild = fork(monitorPath, process.argv.slice(2), {
1499
- stdio: "inherit",
1500
- execArgv: ["--max-old-space-size=4096"],
1501
- env: childEnv,
1502
- windowsHide: IS_DAEMON_CHILD && process.platform === "win32",
1503
- });
1504
- daemonCrashTracker.markStart();
1505
-
1506
- monitorChild.on("exit", (code, signal) => {
1507
- const childPid = monitorChild?.pid ?? null;
1508
- monitorChild = null;
1509
- if (code === SELF_RESTART_EXIT_CODE) {
1510
- console.log(
1511
- "\n \u21BB Monitor restarting with fresh modules...\n",
1512
- );
1513
- // Small delay to let file writes / port releases settle
1514
- setTimeout(() => resolve(runMonitor({ restartReason: "self-restart" })), 2000);
1515
- } else {
1516
- const exitCode = code ?? (signal ? 1 : 0);
1517
- const existingOwner =
1518
- !gracefulShutdown && exitCode === 1
1519
- ? detectExistingMonitorLockOwner(childPid)
1520
- : null;
1521
- if (existingOwner) {
1522
- console.log(
1523
- `\n bosun is already running (PID ${existingOwner.pid}); exiting duplicate start.\n`,
1540
+ waitForMonitorRuntimeFiles(monitorPath)
1541
+ .then(({ ready, missing, waitedMs }) => {
1542
+ if (!ready) {
1543
+ throw new Error(
1544
+ `monitor runtime files missing after waiting ${Math.round(waitedMs / 1000)}s: ${missing.join(", ")}`,
1524
1545
  );
1525
- process.exit(0);
1526
- return;
1527
1546
  }
1528
- // 4294967295 (0xFFFFFFFF / -1 signed) = OS killed the process (OOM, external termination)
1529
- const isOSKill = exitCode === 4294967295 || exitCode === -1;
1530
- const shouldAutoRestart =
1531
- !gracefulShutdown &&
1532
- (isOSKill || (IS_DAEMON_CHILD && exitCode !== 0));
1533
- if (shouldAutoRestart) {
1534
- const crashState = daemonCrashTracker.recordExit();
1535
- daemonRestartCount += 1;
1536
- const delayMs = isOSKill ? 5000 : DAEMON_RESTART_DELAY_MS;
1537
- if (IS_DAEMON_CHILD && crashState.exceeded) {
1538
- const durationSec = Math.max(
1539
- 1,
1540
- Math.round(crashState.runDurationMs / 1000),
1541
- );
1542
- const windowSec = Math.max(
1543
- 1,
1544
- Math.round(crashState.instantCrashWindowMs / 1000),
1545
- );
1546
- console.error(
1547
- `\n ✖ Monitor crashed too quickly ${crashState.instantCrashCount} times in a row (each <= ${windowSec}s, latest ${durationSec}s). Auto-restart is now paused.`,
1548
- );
1549
- sendCrashNotification(exitCode, signal).finally(() =>
1550
- process.exit(exitCode),
1551
- );
1552
- return;
1553
- }
1554
- if (
1555
- IS_DAEMON_CHILD &&
1556
- DAEMON_MAX_RESTARTS > 0 &&
1557
- daemonRestartCount > DAEMON_MAX_RESTARTS
1558
- ) {
1559
- console.error(
1560
- `\n ✖ Monitor crashed too many times (${daemonRestartCount - 1} restarts, max ${DAEMON_MAX_RESTARTS}).`,
1561
- );
1562
- sendCrashNotification(exitCode, signal).finally(() =>
1563
- process.exit(exitCode),
1564
- );
1565
- return;
1566
- }
1567
- const reasonLabel = signal
1568
- ? `signal ${signal}`
1569
- : `exit code ${exitCode}`;
1570
- const attemptLabel =
1571
- IS_DAEMON_CHILD && DAEMON_MAX_RESTARTS > 0
1572
- ? `${daemonRestartCount}/${DAEMON_MAX_RESTARTS}`
1573
- : `${daemonRestartCount}`;
1574
- console.error(
1575
- `\n ⚠ Monitor exited (${reasonLabel}) — auto-restarting in ${Math.max(1, Math.round(delayMs / 1000))}s${IS_DAEMON_CHILD ? ` [attempt ${attemptLabel}]` : ""}...`,
1547
+ if (waitedMs >= MONITOR_START_RETRY_MS) {
1548
+ console.warn(
1549
+ `[cli] delayed monitor start by ${Math.round(waitedMs / 1000)}s while waiting for runtime files to settle`,
1576
1550
  );
1577
- sendCrashNotification(exitCode, signal, {
1578
- autoRestartInMs: delayMs,
1579
- restartAttempt: daemonRestartCount,
1580
- maxRestarts: IS_DAEMON_CHILD ? DAEMON_MAX_RESTARTS : 0,
1581
- }).catch(() => {});
1582
- setTimeout(
1583
- () =>
1584
- resolve(
1585
- runMonitor({
1586
- restartReason: isOSKill ? "os-kill" : "crash",
1587
- }),
1588
- ),
1589
- delayMs,
1590
- );
1591
- return;
1592
1551
  }
1593
-
1594
- if (exitCode !== 0 && !gracefulShutdown) {
1595
- console.error(
1596
- `\n ✖ Monitor crashed (${signal ? `signal ${signal}` : `exit code ${exitCode}`}) — sending crash notification...`,
1597
- );
1598
- sendCrashNotification(exitCode, signal).finally(() =>
1599
- process.exit(exitCode),
1600
- );
1552
+ const childEnv = { ...process.env };
1553
+ if (restartReason) {
1554
+ childEnv.BOSUN_MONITOR_RESTART_REASON = restartReason;
1601
1555
  } else {
1602
- daemonRestartCount = 0;
1603
- daemonCrashTracker.reset();
1604
- process.exit(exitCode);
1556
+ delete childEnv.BOSUN_MONITOR_RESTART_REASON;
1605
1557
  }
1606
- }
1607
- });
1608
-
1609
- monitorChild.on("error", (err) => {
1610
- monitorChild = null;
1611
- console.error(`\n ✖ Monitor failed to start: ${err.message}`);
1612
- sendCrashNotification(1, null).finally(() => reject(err));
1613
- });
1558
+ monitorChild = fork(monitorPath, process.argv.slice(2), {
1559
+ stdio: "inherit",
1560
+ execArgv: ["--max-old-space-size=4096"],
1561
+ env: childEnv,
1562
+ windowsHide: IS_DAEMON_CHILD && process.platform === "win32",
1563
+ });
1564
+ daemonCrashTracker.markStart();
1565
+
1566
+ monitorChild.on("exit", (code, signal) => {
1567
+ const childPid = monitorChild?.pid ?? null;
1568
+ monitorChild = null;
1569
+ if (code === SELF_RESTART_EXIT_CODE) {
1570
+ console.log(
1571
+ "\n ↻ Monitor restarting with fresh modules...\n",
1572
+ );
1573
+ // Small delay to let file writes / port releases settle
1574
+ setTimeout(() => resolve(runMonitor({ restartReason: "self-restart" })), 2000);
1575
+ } else {
1576
+ const exitCode = code ?? (signal ? 1 : 0);
1577
+ const existingOwner =
1578
+ !gracefulShutdown && exitCode === 1
1579
+ ? detectExistingMonitorLockOwner(childPid)
1580
+ : null;
1581
+ if (existingOwner) {
1582
+ console.log(
1583
+ `\n bosun is already running (PID ${existingOwner.pid}); exiting duplicate start.\n`,
1584
+ );
1585
+ process.exit(0);
1586
+ return;
1587
+ }
1588
+ // 4294967295 (0xFFFFFFFF / -1 signed) = OS killed the process (OOM, external termination)
1589
+ const isOSKill = exitCode === 4294967295 || exitCode === -1;
1590
+ const shouldAutoRestart =
1591
+ !gracefulShutdown &&
1592
+ (isOSKill || (IS_DAEMON_CHILD && exitCode !== 0));
1593
+ if (shouldAutoRestart) {
1594
+ const crashState = daemonCrashTracker.recordExit();
1595
+ daemonRestartCount += 1;
1596
+ const delayMs = isOSKill ? 5000 : DAEMON_RESTART_DELAY_MS;
1597
+ if (IS_DAEMON_CHILD && crashState.exceeded) {
1598
+ const durationSec = Math.max(
1599
+ 1,
1600
+ Math.round(crashState.runDurationMs / 1000),
1601
+ );
1602
+ const windowSec = Math.max(
1603
+ 1,
1604
+ Math.round(crashState.instantCrashWindowMs / 1000),
1605
+ );
1606
+ console.error(
1607
+ `\n ✖ Monitor crashed too quickly ${crashState.instantCrashCount} times in a row (each <= ${windowSec}s, latest ${durationSec}s). Auto-restart is now paused.`,
1608
+ );
1609
+ sendCrashNotification(exitCode, signal).finally(() =>
1610
+ process.exit(exitCode),
1611
+ );
1612
+ return;
1613
+ }
1614
+ if (
1615
+ IS_DAEMON_CHILD &&
1616
+ DAEMON_MAX_RESTARTS > 0 &&
1617
+ daemonRestartCount > DAEMON_MAX_RESTARTS
1618
+ ) {
1619
+ console.error(
1620
+ `\n ✖ Monitor crashed too many times (${daemonRestartCount - 1} restarts, max ${DAEMON_MAX_RESTARTS}).`,
1621
+ );
1622
+ sendCrashNotification(exitCode, signal).finally(() =>
1623
+ process.exit(exitCode),
1624
+ );
1625
+ return;
1626
+ }
1627
+ const reasonLabel = signal
1628
+ ? `signal ${signal}`
1629
+ : `exit code ${exitCode}`;
1630
+ const attemptLabel =
1631
+ IS_DAEMON_CHILD && DAEMON_MAX_RESTARTS > 0
1632
+ ? `${daemonRestartCount}/${DAEMON_MAX_RESTARTS}`
1633
+ : `${daemonRestartCount}`;
1634
+ console.error(
1635
+ `\n ⚠ Monitor exited (${reasonLabel}) — auto-restarting in ${Math.max(1, Math.round(delayMs / 1000))}s${IS_DAEMON_CHILD ? ` [attempt ${attemptLabel}]` : ""}...`,
1636
+ );
1637
+ sendCrashNotification(exitCode, signal, {
1638
+ autoRestartInMs: delayMs,
1639
+ restartAttempt: daemonRestartCount,
1640
+ maxRestarts: IS_DAEMON_CHILD ? DAEMON_MAX_RESTARTS : 0,
1641
+ }).catch(() => {});
1642
+ setTimeout(
1643
+ () =>
1644
+ resolve(
1645
+ runMonitor({
1646
+ restartReason: isOSKill ? "os-kill" : "crash",
1647
+ }),
1648
+ ),
1649
+ delayMs,
1650
+ );
1651
+ return;
1652
+ }
1653
+
1654
+ if (exitCode !== 0 && !gracefulShutdown) {
1655
+ console.error(
1656
+ `\n ✖ Monitor crashed (${signal ? `signal ${signal}` : `exit code ${exitCode}`}) — sending crash notification...`,
1657
+ );
1658
+ sendCrashNotification(exitCode, signal).finally(() =>
1659
+ process.exit(exitCode),
1660
+ );
1661
+ } else {
1662
+ daemonRestartCount = 0;
1663
+ daemonCrashTracker.reset();
1664
+ process.exit(exitCode);
1665
+ }
1666
+ }
1667
+ });
1668
+
1669
+ monitorChild.on("error", (err) => {
1670
+ monitorChild = null;
1671
+ console.error(`\n ✖ Monitor failed to start: ${err.message}`);
1672
+ sendCrashNotification(1, null).finally(() => reject(err));
1673
+ });
1674
+ })
1675
+ .catch((err) => {
1676
+ console.error(`\n ✖ Monitor failed to start: ${err.message}`);
1677
+ sendCrashNotification(1, null).finally(() => reject(err));
1678
+ });
1614
1679
  });
1615
1680
  }
1616
1681
 
package/maintenance.mjs CHANGED
@@ -28,6 +28,56 @@ import {
28
28
  } from "./worktree-manager.mjs";
29
29
 
30
30
  const isWindows = process.platform === "win32";
31
+ const BRANCH_SYNC_LOG_THROTTLE_MS = Math.max(
32
+ 5_000,
33
+ Number(process.env.BRANCH_SYNC_LOG_THROTTLE_MS || "300000") || 300000,
34
+ );
35
+ const branchSyncLogState = new Map();
36
+
37
+ function logThrottledBranchSync(
38
+ key,
39
+ message,
40
+ level = "warn",
41
+ throttleMs = BRANCH_SYNC_LOG_THROTTLE_MS,
42
+ ) {
43
+ const normalizedKey = String(key || "default").trim() || "default";
44
+ const now = Date.now();
45
+ const state = branchSyncLogState.get(normalizedKey) || {
46
+ lastLoggedAt: 0,
47
+ suppressed: 0,
48
+ };
49
+
50
+ if (
51
+ state.lastLoggedAt > 0 &&
52
+ now - state.lastLoggedAt < Math.max(1_000, Number(throttleMs) || BRANCH_SYNC_LOG_THROTTLE_MS)
53
+ ) {
54
+ state.suppressed += 1;
55
+ branchSyncLogState.set(normalizedKey, state);
56
+ return;
57
+ }
58
+
59
+ const suppressed = state.suppressed || 0;
60
+ const suffix =
61
+ suppressed > 0
62
+ ? ` (suppressed ${suppressed} similar message(s) in last ${Math.round(Math.max(1_000, Number(throttleMs) || BRANCH_SYNC_LOG_THROTTLE_MS) / 1000)}s)`
63
+ : "";
64
+ const line = `${message}${suffix}`;
65
+
66
+ if (level === "error") {
67
+ console.error(line);
68
+ } else if (level === "info") {
69
+ console.info(line);
70
+ } else if (level === "log") {
71
+ console.log(line);
72
+ } else {
73
+ console.warn(line);
74
+ }
75
+
76
+ branchSyncLogState.set(normalizedKey, {
77
+ lastLoggedAt: now,
78
+ suppressed: 0,
79
+ });
80
+ }
31
81
 
32
82
  /**
33
83
  * Get all running processes matching a filter.
@@ -826,10 +876,10 @@ function isProcessAlive(pid) {
826
876
  * branches spawned from it start stale, causing avoidable rebase conflicts.
827
877
  * This function periodically pulls so the local ref stays current.
828
878
  *
829
- * Safe: only does `--ff-only` — never creates merge commits. If the local
830
- * branch has diverged (someone committed directly), it logs a warning and
831
- * skips. Also skips if the branch is currently checked out with uncommitted
832
- * work (git will refuse the checkout anyway).
879
+ * Safe: only does `--ff-only` — never creates merge commits. It skips when
880
+ * the checked-out branch has uncommitted changes (git will refuse the checkout
881
+ * anyway). If local and remote both have unique commits, it logs a warning and
882
+ * skips.
833
883
  *
834
884
  * @param {string} repoRoot
835
885
  * @param {string[]} [branches] - branches to sync (default: ["main"])
@@ -848,7 +898,11 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
848
898
  windowsHide: true,
849
899
  });
850
900
  } catch (e) {
851
- console.warn(`[maintenance] git fetch --all failed: ${e.message}`);
901
+ logThrottledBranchSync(
902
+ "sync:fetch-failed",
903
+ `[maintenance] git fetch --all failed: ${e.message}`,
904
+ "warn",
905
+ );
852
906
  return 0;
853
907
  }
854
908
 
@@ -913,13 +967,17 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
913
967
  { cwd: repoRoot, encoding: "utf8", timeout: 30_000, windowsHide: true },
914
968
  );
915
969
  if (push.status === 0) {
916
- console.log(
970
+ logThrottledBranchSync(
971
+ `sync:${branch}:push-success`,
917
972
  `[maintenance] pushed local '${branch}' to origin (${ahead} commit(s) ahead)`,
973
+ "info",
918
974
  );
919
975
  synced++;
920
976
  } else {
921
- console.warn(
977
+ logThrottledBranchSync(
978
+ `sync:${branch}:push-failed`,
922
979
  `[maintenance] git push '${branch}' failed: ${(push.stderr || push.stdout || "").toString().trim()}`,
980
+ "warn",
923
981
  );
924
982
  }
925
983
  continue;
@@ -935,8 +993,10 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
935
993
  windowsHide: true,
936
994
  });
937
995
  if (statusCheck.stdout?.trim()) {
938
- console.log(
996
+ logThrottledBranchSync(
997
+ `sync:${branch}:diverged-dirty`,
939
998
  `[maintenance] local '${branch}' diverged (${ahead}↑ ${behind}↓) but has uncommitted changes — skipping`,
999
+ "info",
940
1000
  );
941
1001
  continue;
942
1002
  }
@@ -961,18 +1021,24 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
961
1021
  { cwd: repoRoot, encoding: "utf8", timeout: 30_000, windowsHide: true },
962
1022
  );
963
1023
  if (push.status === 0) {
964
- console.log(
1024
+ logThrottledBranchSync(
1025
+ `sync:${branch}:rebase-push-success`,
965
1026
  `[maintenance] rebased and pushed '${branch}' (was ${ahead}↑ ${behind}↓)`,
1027
+ "info",
966
1028
  );
967
1029
  synced++;
968
1030
  } else {
969
- console.warn(
1031
+ logThrottledBranchSync(
1032
+ `sync:${branch}:rebase-push-failed`,
970
1033
  `[maintenance] push after rebase of '${branch}' failed: ${(push.stderr || push.stdout || "").toString().trim()}`,
1034
+ "warn",
971
1035
  );
972
1036
  }
973
1037
  } else {
974
- console.warn(
1038
+ logThrottledBranchSync(
1039
+ `sync:${branch}:diverged-not-checked-out`,
975
1040
  `[maintenance] local '${branch}' diverged (${ahead}↑ ${behind}↓) and not checked out — skipping (rebase requires checkout)`,
1041
+ "warn",
976
1042
  );
977
1043
  }
978
1044
  continue;
@@ -988,8 +1054,10 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
988
1054
  windowsHide: true,
989
1055
  });
990
1056
  if (statusCheck.stdout?.trim()) {
991
- console.log(
1057
+ logThrottledBranchSync(
1058
+ `sync:${branch}:dirty-pull-skip`,
992
1059
  `[maintenance] '${branch}' is checked out with uncommitted changes — skipping pull`,
1060
+ "info",
993
1061
  );
994
1062
  continue;
995
1063
  }
@@ -1001,13 +1069,17 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
1001
1069
  windowsHide: true,
1002
1070
  });
1003
1071
  if (pull.status === 0) {
1004
- console.log(
1072
+ logThrottledBranchSync(
1073
+ `sync:${branch}:pull-success`,
1005
1074
  `[maintenance] fast-forwarded checked-out '${branch}' (was ${behind} behind)`,
1075
+ "info",
1006
1076
  );
1007
1077
  synced++;
1008
1078
  } else {
1009
- console.warn(
1079
+ logThrottledBranchSync(
1080
+ `sync:${branch}:pull-failed`,
1010
1081
  `[maintenance] git pull --ff-only on '${branch}' failed: ${(pull.stderr || pull.stdout || "").toString().trim()}`,
1082
+ "warn",
1011
1083
  );
1012
1084
  }
1013
1085
  } else {
@@ -1019,22 +1091,34 @@ export function syncLocalTrackingBranches(repoRoot, branches) {
1019
1091
  { cwd: repoRoot, timeout: 5000, windowsHide: true },
1020
1092
  );
1021
1093
  if (update.status === 0) {
1022
- console.log(
1094
+ logThrottledBranchSync(
1095
+ `sync:${branch}:update-ref-success`,
1023
1096
  `[maintenance] fast-forwarded '${branch}' → ${remoteRef} (was ${behind} behind)`,
1097
+ "info",
1024
1098
  );
1025
1099
  synced++;
1026
1100
  } else {
1027
- console.warn(`[maintenance] update-ref failed for '${branch}'`);
1101
+ logThrottledBranchSync(
1102
+ `sync:${branch}:update-ref-failed`,
1103
+ `[maintenance] update-ref failed for '${branch}'`,
1104
+ "warn",
1105
+ );
1028
1106
  }
1029
1107
  }
1030
1108
  } catch (e) {
1031
- console.warn(`[maintenance] error syncing '${branch}': ${e.message}`);
1109
+ logThrottledBranchSync(
1110
+ `sync:${branch}:unexpected-error`,
1111
+ `[maintenance] error syncing '${branch}': ${e.message}`,
1112
+ "error",
1113
+ );
1032
1114
  }
1033
1115
  }
1034
1116
 
1035
1117
  if (synced > 0) {
1036
- console.log(
1118
+ logThrottledBranchSync(
1119
+ "sync:summary",
1037
1120
  `[maintenance] synced ${synced}/${toSync.length} local tracking branch(es)`,
1121
+ "info",
1038
1122
  );
1039
1123
  }
1040
1124
  return synced;
package/monitor.mjs CHANGED
@@ -1457,10 +1457,27 @@ function isMonitorMonitorEnabled() {
1457
1457
  }
1458
1458
 
1459
1459
  function isSelfRestartWatcherEnabled() {
1460
+ const devMode = isDevMode();
1461
+ const force = process.env.SELF_RESTART_WATCH_FORCE;
1462
+ const forceEnabled = isTruthyFlag(force);
1463
+ const npmLifecycleEvent = String(process.env.npm_lifecycle_event || "")
1464
+ .trim()
1465
+ .toLowerCase();
1466
+ const launchedViaNpmStartScript =
1467
+ npmLifecycleEvent === "start" || npmLifecycleEvent.startsWith("start:");
1460
1468
  const explicit = process.env.SELF_RESTART_WATCH_ENABLED;
1461
1469
  if (explicit !== undefined && String(explicit).trim() !== "") {
1462
1470
  return !isFalsyFlag(explicit);
1463
1471
  }
1472
+ if (!devMode && !forceEnabled) {
1473
+ return false;
1474
+ }
1475
+ if (devMode && !forceEnabled && !launchedViaNpmStartScript) {
1476
+ // Plain `bosun` command launches from a source checkout should behave like
1477
+ // npm/prod installs by default: no self-restart watcher unless explicitly
1478
+ // enabled. Auto-updates still handle published package changes.
1479
+ return false;
1480
+ }
1464
1481
  if (
1465
1482
  String(executorMode || "")
1466
1483
  .trim()
@@ -1476,7 +1493,7 @@ function isSelfRestartWatcherEnabled() {
1476
1493
  // Dev mode (source checkout / monorepo) → watch for code changes.
1477
1494
  // npm mode (installed via npm) → do NOT watch; source only changes via
1478
1495
  // npm update, which is handled by the auto-update loop instead.
1479
- return isDevMode();
1496
+ return devMode;
1480
1497
  }
1481
1498
 
1482
1499
  const MONITOR_MONITOR_DEFAULT_TIMEOUT_MS = 6 * 60 * 60 * 1000;
@@ -14532,7 +14549,7 @@ function applyConfig(nextConfig, options = {}) {
14532
14549
  } else {
14533
14550
  stopSelfWatcher();
14534
14551
  console.log(
14535
- "[monitor] self-restart watcher disabled (set SELF_RESTART_WATCH_ENABLED=1 to force-enable)",
14552
+ "[monitor] self-restart watcher disabled (set SELF_RESTART_WATCH_FORCE=1 to allow in npm/prod mode)",
14536
14553
  );
14537
14554
  }
14538
14555
  }
@@ -15136,8 +15153,22 @@ if (selfRestartWatcherEnabled) {
15136
15153
  const normalizedExecutorMode = String(executorMode || "")
15137
15154
  .trim()
15138
15155
  .toLowerCase();
15139
- const disabledReason = !isDevMode()
15156
+ const explicitSelfRestartWatch = process.env.SELF_RESTART_WATCH_ENABLED;
15157
+ const hasExplicitSelfRestartWatch =
15158
+ explicitSelfRestartWatch !== undefined &&
15159
+ String(explicitSelfRestartWatch).trim() !== "";
15160
+ const forceSelfRestartWatch = isTruthyFlag(process.env.SELF_RESTART_WATCH_FORCE);
15161
+ const npmLifecycleEvent = String(process.env.npm_lifecycle_event || "")
15162
+ .trim()
15163
+ .toLowerCase();
15164
+ const launchedViaNpmStartScript =
15165
+ npmLifecycleEvent === "start" || npmLifecycleEvent.startsWith("start:");
15166
+ const disabledReason = hasExplicitSelfRestartWatch
15167
+ ? "explicitly"
15168
+ : !isDevMode()
15140
15169
  ? "npm/prod mode — updates via auto-update loop"
15170
+ : !forceSelfRestartWatch && !launchedViaNpmStartScript
15171
+ ? "CLI command mode in source checkout — use npm run start or SELF_RESTART_WATCH_ENABLED=1 to enable"
15141
15172
  : normalizedExecutorMode === "internal" || normalizedExecutorMode === "hybrid"
15142
15173
  ? `executor mode "${normalizedExecutorMode}" (continuous task-driven code changes)`
15143
15174
  : "explicitly";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.34.8",
3
+ "version": "0.34.9",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, 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",