bosun 0.29.4 → 0.29.6

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/README.md CHANGED
@@ -110,3 +110,6 @@ npm -C scripts/bosun run hooks:install
110
110
  ## License
111
111
 
112
112
  Apache-2.0
113
+
114
+ <!-- GitHub Analytics Pixel -->
115
+ <img src="https://cloud.umami.is/p/iR78WZdwe" alt="" width="1" height="1" style="display:none;" />
package/cli.mjs CHANGED
@@ -721,15 +721,23 @@ async function main() {
721
721
  );
722
722
  }
723
723
 
724
+ // Auto-start sentinel in daemon mode when Telegram credentials are available
725
+ const hasTelegramCreds = !!(
726
+ (process.env.TELEGRAM_BOT_TOKEN || readEnvCredentials().TELEGRAM_BOT_TOKEN) &&
727
+ (process.env.TELEGRAM_CHAT_ID || readEnvCredentials().TELEGRAM_CHAT_ID)
728
+ );
724
729
  const sentinelRequested =
725
730
  args.includes("--sentinel") ||
726
- parseBoolEnv(process.env.BOSUN_SENTINEL_AUTO_START, false);
731
+ parseBoolEnv(process.env.BOSUN_SENTINEL_AUTO_START, false) ||
732
+ (IS_DAEMON_CHILD && hasTelegramCreds);
727
733
  if (sentinelRequested) {
728
734
  const sentinel = await ensureSentinelRunning({ quiet: false });
729
735
  if (!sentinel.ok) {
730
736
  const mode = args.includes("--sentinel")
731
737
  ? "requested by --sentinel"
732
- : "requested by BOSUN_SENTINEL_AUTO_START";
738
+ : IS_DAEMON_CHILD && hasTelegramCreds
739
+ ? "auto-started in daemon mode (Telegram credentials detected)"
740
+ : "requested by BOSUN_SENTINEL_AUTO_START";
733
741
  const strictSentinel = parseBoolEnv(
734
742
  process.env.BOSUN_SENTINEL_STRICT,
735
743
  false,
@@ -1,3 +1,7 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { resolve } from "node:path";
4
+
1
5
  const DEFAULT_ACTIVE_PROFILE = "xl";
2
6
  const DEFAULT_SUBAGENT_PROFILE = "m";
3
7
 
@@ -63,6 +67,19 @@ function profileRecord(env, profileName, globalProvider) {
63
67
  };
64
68
  }
65
69
 
70
+ function readCodexConfigTopLevelModel() {
71
+ try {
72
+ const configPath = resolve(homedir(), ".codex", "config.toml");
73
+ if (!existsSync(configPath)) return "";
74
+ const content = readFileSync(configPath, "utf8");
75
+ const head = content.split(/\n\[/)[0] || "";
76
+ const match = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
77
+ return match ? match[1].trim() : "";
78
+ } catch {
79
+ return "";
80
+ }
81
+ }
82
+
66
83
  /**
67
84
  * Resolve codex model/provider profile configuration from env vars.
68
85
  * Applies active profile values onto runtime env keys (`CODEX_MODEL`,
@@ -86,6 +103,8 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
86
103
 
87
104
  const env = { ...sourceEnv };
88
105
 
106
+ const configModel = readCodexConfigTopLevelModel();
107
+
89
108
  if (active.model) {
90
109
  env.CODEX_MODEL = active.model;
91
110
  }
@@ -96,6 +115,21 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
96
115
  const profileApiKey = active.apiKey;
97
116
  const resolvedProvider = active.provider || globalProvider;
98
117
 
118
+ // Azure deployments often differ from default model names.
119
+ // If the env is using Azure and the model is still the default,
120
+ // prefer the top-level ~/.codex/config.toml model when present.
121
+ const activeModelExplicit =
122
+ Boolean(readProfileField(sourceEnv, activeProfile, "MODEL")) ||
123
+ Boolean(clean(sourceEnv.CODEX_MODEL));
124
+ if (
125
+ resolvedProvider === "azure" &&
126
+ configModel &&
127
+ (!activeModelExplicit || clean(env.CODEX_MODEL) === "gpt-5.3-codex")
128
+ ) {
129
+ env.CODEX_MODEL = configModel;
130
+ active.model = configModel;
131
+ }
132
+
99
133
  if (profileApiKey) {
100
134
  if (resolvedProvider === "azure") {
101
135
  env.AZURE_OPENAI_API_KEY = profileApiKey;
package/config.mjs CHANGED
@@ -265,17 +265,33 @@ function isEnvEnabled(value, defaultValue = false) {
265
265
 
266
266
  // ── Git helpers ──────────────────────────────────────────────────────────────
267
267
 
268
- function detectRepoSlug() {
269
- try {
270
- const remote = execSync("git remote get-url origin", {
271
- encoding: "utf8",
272
- stdio: ["pipe", "pipe", "ignore"],
273
- }).trim();
274
- const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)/);
275
- return match ? match[1] : null;
276
- } catch {
277
- return null;
268
+ function detectRepoSlug(repoRoot = "") {
269
+ const tryResolve = (cwd) => {
270
+ try {
271
+ const remote = execSync("git remote get-url origin", {
272
+ cwd,
273
+ encoding: "utf8",
274
+ stdio: ["pipe", "pipe", "ignore"],
275
+ }).trim();
276
+ const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)/);
277
+ return match ? match[1] : null;
278
+ } catch {
279
+ return null;
280
+ }
281
+ };
282
+
283
+ // First try current working directory
284
+ const direct = tryResolve(process.cwd());
285
+ if (direct) return direct;
286
+
287
+ // Fall back to detected repo root if provided (or detectable)
288
+ const root = repoRoot || detectRepoRoot();
289
+ if (root) {
290
+ const viaRoot = tryResolve(root);
291
+ if (viaRoot) return viaRoot;
278
292
  }
293
+
294
+ return null;
279
295
  }
280
296
 
281
297
  function detectRepoRoot() {
@@ -15,6 +15,8 @@ const chromeSandbox = resolve(
15
15
  "chrome-sandbox",
16
16
  );
17
17
 
18
+ process.title = "bosun-desktop-launcher";
19
+
18
20
  function shouldDisableSandbox() {
19
21
  if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
20
22
  if (process.platform !== "linux") return false;
package/desktop/main.mjs CHANGED
@@ -9,6 +9,8 @@ import { homedir } from "node:os";
9
9
 
10
10
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
11
 
12
+ process.title = "bosun-desktop";
13
+
12
14
  let mainWindow = null;
13
15
  let shuttingDown = false;
14
16
  let uiServerStarted = false;
@@ -216,6 +216,9 @@ export class GitHubReconciler {
216
216
  if (backend !== "github") {
217
217
  return { status: "skipped", reason: `backend=${backend || "unknown"}` };
218
218
  }
219
+ if (!this.repoSlug || this.repoSlug === "unknown/unknown") {
220
+ return { status: "skipped", reason: "missing-repo" };
221
+ }
219
222
 
220
223
  const summary = {
221
224
  status: "ok",
@@ -350,6 +353,10 @@ export class GitHubReconciler {
350
353
  start() {
351
354
  if (this.running) return this;
352
355
  this.running = true;
356
+ if (!this.repoSlug || this.repoSlug === "unknown/unknown") {
357
+ console.warn(`${TAG} disabled (missing repo slug)`);
358
+ return this;
359
+ }
353
360
  console.log(
354
361
  `${TAG} started (repo=${this.repoSlug}, interval=${this.intervalMs}ms, lookback=${this.mergedLookbackHours}h)`,
355
362
  );
package/monitor.mjs CHANGED
@@ -5863,7 +5863,7 @@ const dependabotMergeAttempted = new Set();
5863
5863
  */
5864
5864
  async function checkAndMergeDependabotPRs() {
5865
5865
  if (!dependabotAutoMerge) return;
5866
- if (!repoSlug) {
5866
+ if (!repoSlug || repoSlug === "unknown/unknown") {
5867
5867
  console.warn("[dependabot] auto-merge disabled — no repo slug configured");
5868
5868
  return;
5869
5869
  }
@@ -12035,6 +12035,10 @@ function restartGitHubReconciler() {
12035
12035
  : "") ||
12036
12036
  repoSlug ||
12037
12037
  "unknown/unknown";
12038
+ if (!repo || repo === "unknown/unknown") {
12039
+ console.warn("[gh-reconciler] disabled — missing repo slug");
12040
+ return;
12041
+ }
12038
12042
 
12039
12043
  ghReconciler = startGitHubReconciler({
12040
12044
  repoSlug: repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.29.4",
3
+ "version": "0.29.6",
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",
package/task-executor.mjs CHANGED
@@ -3168,7 +3168,7 @@ class TaskExecutor {
3168
3168
  tasks = tasks.filter((t) => t.status === "todo");
3169
3169
  if (tasks.length !== before) {
3170
3170
  console.debug(
3171
- `${TAG} filtered ${before - tasks.length} non-todo tasks (VK returned ${before}, kept ${tasks.length})`,
3171
+ `${TAG} filtered ${before - tasks.length} non-todo tasks (API returned ${before}, kept ${tasks.length})`,
3172
3172
  );
3173
3173
  }
3174
3174
  }
@@ -448,87 +448,62 @@ export function WorkspaceManager({ open, onClose }) {
448
448
  `;
449
449
  }
450
450
 
451
- // ─── Main component: compact dropdown + manage trigger ─────
451
+ // ─── Main component: native <select> dropdown + manage trigger ─────
452
452
  export function WorkspaceSwitcher() {
453
- const [open, setOpen] = useState(false);
454
453
  const [managerOpen, setManagerOpen] = useState(false);
455
454
 
456
455
  useEffect(() => {
457
456
  loadWorkspaces();
458
457
  }, []);
459
458
 
460
- // Close dropdown on outside click
461
- useEffect(() => {
462
- if (!open) return;
463
- const handler = (e) => {
464
- if (!e.target.closest?.(".ws-switcher")) setOpen(false);
465
- };
466
- document.addEventListener("click", handler, true);
467
- return () => document.removeEventListener("click", handler, true);
468
- }, [open]);
469
-
470
- const activeWs = workspaces.value.find((ws) => ws.id === activeWorkspaceId.value);
471
459
  const wsList = workspaces.value;
472
- const isLoading = workspacesLoading.value;
460
+ const currentId = activeWorkspaceId.value;
461
+
462
+ const handleChange = async (e) => {
463
+ const wsId = e.target.value;
464
+ if (wsId === "__manage__") {
465
+ e.target.value = currentId || "";
466
+ haptic("medium");
467
+ setManagerOpen(true);
468
+ return;
469
+ }
470
+ if (!wsId || wsId === currentId) return;
471
+ haptic("light");
472
+ await switchWorkspace(wsId);
473
+ };
473
474
 
474
- // Always render even with no workspaces, show a manage button so users can add one
475
+ if (!wsList.length && !workspacesLoading.value) {
476
+ return html`
477
+ <div class="ws-switcher">
478
+ <button
479
+ class="ws-switcher-btn ws-switcher-btn-empty"
480
+ onClick=${() => { haptic("medium"); setManagerOpen(true); }}
481
+ title="Set up a workspace"
482
+ >
483
+ <span class="ws-switcher-icon">⬡</span>
484
+ <span class="ws-switcher-name">Set up workspace</span>
485
+ </button>
486
+ <${WorkspaceManager}
487
+ open=${managerOpen}
488
+ onClose=${() => setManagerOpen(false)}
489
+ />
490
+ </div>
491
+ `;
492
+ }
475
493
 
476
494
  return html`
477
495
  <div class="ws-switcher">
478
- <button
479
- class="ws-switcher-btn ${!wsList.length ? 'ws-switcher-btn-empty' : ''}"
480
- onClick=${(e) => {
481
- e.stopPropagation();
482
- haptic("light");
483
- if (!wsList.length) {
484
- setManagerOpen(true);
485
- } else {
486
- setOpen(!open);
487
- }
488
- }}
489
- title=${wsList.length ? "Switch workspace" : "Set up a workspace"}
496
+ <select
497
+ class="ws-native-select"
498
+ value=${currentId || ""}
499
+ onChange=${handleChange}
490
500
  >
491
- <span class="ws-switcher-icon">⬡</span>
492
- <span class="ws-switcher-name">
493
- ${isLoading ? "Loading…" : (activeWs?.name || (wsList.length ? "Select Workspace" : "Set up workspace"))}
494
- </span>
495
- ${wsList.length ? html`<span class="ws-switcher-chevron ${open ? "open" : ""}">${open ? "▴" : "▾"}</span>` : null}
496
- </button>
497
-
498
- ${open && html`
499
- <div class="ws-switcher-dropdown">
500
- <div class="ws-switcher-header">Workspaces</div>
501
- ${wsList.map((ws) => html`
502
- <button
503
- key=${ws.id}
504
- class="ws-switcher-item ${ws.id === activeWorkspaceId.value ? "active" : ""}"
505
- onClick=${() => { haptic("light"); switchWorkspace(ws.id); setOpen(false); }}
506
- >
507
- <div class="ws-switcher-item-main">
508
- <span class="ws-switcher-item-name">${ws.name}</span>
509
- ${ws.id === activeWorkspaceId.value
510
- ? html`<span class="ws-switcher-badge">Active</span>`
511
- : null}
512
- </div>
513
- <div class="ws-switcher-item-repos">
514
- ${(ws.repos || []).map((r) => html`
515
- <span key=${r.name} class="ws-switcher-repo ${r.exists ? "" : "missing"}" title=${r.slug || r.name}>
516
- ${r.primary ? "★ " : ""}${r.name}
517
- </span>
518
- `)}
519
- </div>
520
- </button>
521
- `)}
522
- <div class="ws-switcher-divider" />
523
- <button
524
- class="ws-switcher-item ws-switcher-manage-btn"
525
- onClick=${() => { haptic("medium"); setOpen(false); setManagerOpen(true); }}
526
- >
527
- <span class="ws-switcher-manage-icon">⚙</span>
528
- <span>Manage Workspaces</span>
529
- </button>
530
- </div>
531
- `}
501
+ ${wsList.map((ws) => html`
502
+ <option key=${ws.id} value=${ws.id}>${ws.name || ws.id}</option>
503
+ `)}
504
+ <option disabled>──────────</option>
505
+ <option value="__manage__">⚙ Manage Workspaces</option>
506
+ </select>
532
507
 
533
508
  <${WorkspaceManager}
534
509
  open=${managerOpen}
package/ui/index.html CHANGED
@@ -165,5 +165,6 @@
165
165
  }
166
166
  }, 12000);
167
167
  </script>
168
+ <img src="https://cloud.umami.is/p/dSHELgZaC" alt="" width="1" height="1" style="display:none;" />
168
169
  </body>
169
170
  </html>
@@ -2478,20 +2478,7 @@ select.input {
2478
2478
 
2479
2479
  @media (min-width: 760px) {
2480
2480
  .dashboard-grid {
2481
- grid-template-columns: repeat(12, 1fr);
2482
- }
2483
-
2484
- .dashboard-health,
2485
- .dashboard-active,
2486
- .dashboard-project {
2487
- grid-column: 1 / span 7;
2488
- }
2489
-
2490
- .dashboard-overview,
2491
- .dashboard-alerts,
2492
- .dashboard-actions,
2493
- .dashboard-quality {
2494
- grid-column: 8 / span 5;
2481
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
2495
2482
  }
2496
2483
 
2497
2484
  .dashboard-activity {
@@ -56,6 +56,14 @@
56
56
  .session-split > * {
57
57
  min-height: 0;
58
58
  }
59
+
60
+ /* Ensure session sidebar is always visible on desktop regardless of drawer state */
61
+ .session-pane {
62
+ position: static !important;
63
+ transform: none !important;
64
+ width: auto !important;
65
+ z-index: auto !important;
66
+ }
59
67
  }
60
68
 
61
69
  @media (min-width: 1200px) {
@@ -1422,7 +1430,9 @@ ul.md-list li::before {
1422
1430
 
1423
1431
  /* ═══ Chat Input Area (bottom bar) ═══ */
1424
1432
  .chat-input-area {
1425
- position: relative;
1433
+ position: sticky;
1434
+ bottom: 0;
1435
+ z-index: 10;
1426
1436
  padding: 16px 16px;
1427
1437
  border-top: 1px solid var(--border);
1428
1438
  background: var(--bg-surface, var(--bg-card));
@@ -1441,14 +1451,14 @@ ul.md-list li::before {
1441
1451
  border: none;
1442
1452
  border-radius: 0;
1443
1453
  background: transparent;
1444
- padding: 16px 12px;
1454
+ padding: 20px 24px;
1445
1455
  }
1446
1456
 
1447
1457
  /* ═══ Session Detail as flex column (messages grow, input pinned) ═══ */
1448
1458
  .session-detail > .chat-view-embedded {
1449
1459
  flex: 1;
1450
1460
  min-height: 0;
1451
- overflow: hidden;
1461
+ overflow-y: auto;
1452
1462
  display: flex;
1453
1463
  flex-direction: column;
1454
1464
  }
@@ -1463,7 +1473,7 @@ ul.md-list li::before {
1463
1473
  }
1464
1474
 
1465
1475
  .app-shell[data-tab="chat"] .chat-view-embedded .chat-messages {
1466
- padding: 0 0 16px;
1476
+ padding: 16px 20px 16px;
1467
1477
  }
1468
1478
 
1469
1479
  .app-shell[data-tab="chat"] .chat-input-area {
@@ -94,11 +94,11 @@
94
94
  --inspector-width: 300px;
95
95
  --content-max: 1200px;
96
96
  --shell-gap: 0px;
97
- --radius-xs: 6px;
98
- --radius-sm: 10px;
99
- --radius-md: 14px;
100
- --radius-lg: 18px;
101
- --radius-xl: 24px;
97
+ --radius-xs: 2px;
98
+ --radius-sm: 3px;
99
+ --radius-md: 4px;
100
+ --radius-lg: 5px;
101
+ --radius-xl: 6px;
102
102
  --radius-full: 9999px;
103
103
 
104
104
  /* Shadows */
@@ -5,6 +5,41 @@
5
5
  z-index: 100;
6
6
  }
7
7
 
8
+ .ws-native-select {
9
+ background: var(--bg-card);
10
+ color: var(--text-primary);
11
+ border: 1px solid var(--border);
12
+ border-radius: var(--radius-sm);
13
+ padding: 6px 28px 6px 10px;
14
+ font-size: 13px;
15
+ font-family: inherit;
16
+ cursor: pointer;
17
+ outline: none;
18
+ appearance: none;
19
+ -webkit-appearance: none;
20
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23b5b0a6'%3E%3Cpath d='M6 8L1 3h10z'/%3E%3C/svg%3E");
21
+ background-repeat: no-repeat;
22
+ background-position: right 8px center;
23
+ max-width: 180px;
24
+ text-overflow: ellipsis;
25
+ overflow: hidden;
26
+ white-space: nowrap;
27
+ }
28
+
29
+ .ws-native-select:hover {
30
+ border-color: var(--border-strong);
31
+ }
32
+
33
+ .ws-native-select:focus {
34
+ border-color: var(--accent);
35
+ box-shadow: 0 0 0 2px var(--accent-soft);
36
+ }
37
+
38
+ .ws-native-select option {
39
+ background: var(--bg-elevated);
40
+ color: var(--text-primary);
41
+ }
42
+
8
43
  .ws-switcher-btn {
9
44
  display: flex;
10
45
  align-items: center;
@@ -352,6 +352,14 @@ const SETTINGS_STYLES = `
352
352
  .settings-content-scroll {
353
353
  padding-bottom: 160px;
354
354
  }
355
+ /* Constrain settings content width on wide viewports */
356
+ .settings-content-constrained {
357
+ max-width: 900px;
358
+ margin-left: auto;
359
+ margin-right: auto;
360
+ width: 100%;
361
+ box-sizing: border-box;
362
+ }
355
363
 
356
364
  body.settings-save-open .main-content {
357
365
  padding-bottom: calc(var(--nav-height) + var(--safe-bottom) + 110px);
@@ -1204,6 +1212,7 @@ function AppPreferencesMode() {
1204
1212
  setter(next);
1205
1213
  cloudSet(key, next);
1206
1214
  haptic();
1215
+ showToast("Preference saved", "success");
1207
1216
  }, []);
1208
1217
 
1209
1218
  const handleFontSize = (v) => {
@@ -1211,6 +1220,7 @@ function AppPreferencesMode() {
1211
1220
  cloudSet("fontSize", v);
1212
1221
  haptic();
1213
1222
  applyFontSize(v);
1223
+ showToast("Font size saved", "success");
1214
1224
  };
1215
1225
 
1216
1226
  const handleColorTheme = (v) => {
@@ -1218,6 +1228,7 @@ function AppPreferencesMode() {
1218
1228
  cloudSet("colorTheme", v);
1219
1229
  haptic();
1220
1230
  applyColorTheme(v);
1231
+ showToast("Theme saved", "success");
1221
1232
  };
1222
1233
 
1223
1234
  const handleDefaultMaxParallel = (v) => {
@@ -1225,18 +1236,21 @@ function AppPreferencesMode() {
1225
1236
  setDefaultMaxParallel(val);
1226
1237
  cloudSet("defaultMaxParallel", val);
1227
1238
  haptic();
1239
+ showToast("Preference saved", "success");
1228
1240
  };
1229
1241
 
1230
1242
  const handleDefaultSdk = (v) => {
1231
1243
  setDefaultSdk(v);
1232
1244
  cloudSet("defaultSdk", v);
1233
1245
  haptic();
1246
+ showToast("Preference saved", "success");
1234
1247
  };
1235
1248
 
1236
1249
  const handleDefaultRegion = (v) => {
1237
1250
  setDefaultRegion(v);
1238
1251
  cloudSet("defaultRegion", v);
1239
1252
  haptic();
1253
+ showToast("Preference saved", "success");
1240
1254
  };
1241
1255
 
1242
1256
  /* Clear cache */
@@ -1785,23 +1799,25 @@ export function SettingsTab() {
1785
1799
  useEffect(() => { injectStyles(); }, []);
1786
1800
 
1787
1801
  return html`
1788
- <!-- Top-level mode switcher -->
1789
- <div style="margin-bottom:12px">
1790
- <${SegmentedControl}
1791
- options=${[
1792
- { value: "preferences", label: "App Preferences" },
1793
- { value: "server", label: "Server Config" },
1794
- ]}
1795
- value=${mode}
1796
- onChange=${(v) => {
1797
- setMode(v);
1798
- haptic("light");
1799
- }}
1800
- />
1801
- </div>
1802
+ <div class="settings-content-constrained">
1803
+ <!-- Top-level mode switcher -->
1804
+ <div style="margin-bottom:12px">
1805
+ <${SegmentedControl}
1806
+ options=${[
1807
+ { value: "preferences", label: "App Preferences" },
1808
+ { value: "server", label: "Server Config" },
1809
+ ]}
1810
+ value=${mode}
1811
+ onChange=${(v) => {
1812
+ setMode(v);
1813
+ haptic("light");
1814
+ }}
1815
+ />
1816
+ </div>
1802
1817
 
1803
- ${mode === "preferences"
1804
- ? html`<${AppPreferencesMode} />`
1805
- : html`<${ServerConfigMode} />`}
1818
+ ${mode === "preferences"
1819
+ ? html`<${AppPreferencesMode} />`
1820
+ : html`<${ServerConfigMode} />`}
1821
+ </div>
1806
1822
  `;
1807
1823
  }