bosun 0.29.5 → 0.29.7

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
@@ -66,6 +66,7 @@ function showHelp() {
66
66
 
67
67
  COMMANDS
68
68
  --setup Run the interactive setup wizard
69
+ --where Show the resolved bosun config directory
69
70
  --doctor Validate bosun .env/config setup
70
71
  --help Show this help
71
72
  --version Show version
@@ -203,6 +204,48 @@ function showHelp() {
203
204
  `);
204
205
  }
205
206
 
207
+ function isWslInteropRuntime() {
208
+ return Boolean(
209
+ process.env.WSL_DISTRO_NAME ||
210
+ process.env.WSL_INTEROP ||
211
+ (process.platform === "win32" &&
212
+ String(process.env.HOME || "")
213
+ .trim()
214
+ .startsWith("/home/")),
215
+ );
216
+ }
217
+
218
+ function resolveConfigDirForCli() {
219
+ if (process.env.BOSUN_DIR) return resolve(process.env.BOSUN_DIR);
220
+ const preferWindowsDirs =
221
+ process.platform === "win32" && !isWslInteropRuntime();
222
+ const baseDir = preferWindowsDirs
223
+ ? process.env.APPDATA ||
224
+ process.env.LOCALAPPDATA ||
225
+ process.env.USERPROFILE ||
226
+ process.env.HOME ||
227
+ process.cwd()
228
+ : process.env.HOME ||
229
+ process.env.XDG_CONFIG_HOME ||
230
+ process.env.USERPROFILE ||
231
+ process.env.APPDATA ||
232
+ process.env.LOCALAPPDATA ||
233
+ process.cwd();
234
+ return resolve(baseDir, "bosun");
235
+ }
236
+
237
+ function printConfigLocations() {
238
+ const configDir = resolveConfigDirForCli();
239
+ const envPath = resolve(configDir, ".env");
240
+ const configPath = resolve(configDir, "bosun.config.json");
241
+ const workspacesPath = resolve(configDir, "workspaces");
242
+ console.log("\n Bosun config directory");
243
+ console.log(` ${configDir}`);
244
+ console.log(` .env: ${envPath}`);
245
+ console.log(` bosun.config.json: ${configPath}`);
246
+ console.log(` workspaces: ${workspacesPath}\n`);
247
+ }
248
+
206
249
  // ── Main ─────────────────────────────────────────────────────────────────────
207
250
 
208
251
  // ── Daemon Mode ──────────────────────────────────────────────────────────────
@@ -572,6 +615,12 @@ async function main() {
572
615
  process.exit(0);
573
616
  }
574
617
 
618
+ // Handle --where
619
+ if (args.includes("--where") || args.includes("where")) {
620
+ printConfigLocations();
621
+ process.exit(0);
622
+ }
623
+
575
624
  // Handle desktop shortcut controls
576
625
  if (args.includes("--desktop-shortcut")) {
577
626
  const { installDesktopShortcut, getDesktopShortcutMethodName } =
@@ -721,15 +770,23 @@ async function main() {
721
770
  );
722
771
  }
723
772
 
773
+ // Auto-start sentinel in daemon mode when Telegram credentials are available
774
+ const hasTelegramCreds = !!(
775
+ (process.env.TELEGRAM_BOT_TOKEN || readEnvCredentials().TELEGRAM_BOT_TOKEN) &&
776
+ (process.env.TELEGRAM_CHAT_ID || readEnvCredentials().TELEGRAM_CHAT_ID)
777
+ );
724
778
  const sentinelRequested =
725
779
  args.includes("--sentinel") ||
726
- parseBoolEnv(process.env.BOSUN_SENTINEL_AUTO_START, false);
780
+ parseBoolEnv(process.env.BOSUN_SENTINEL_AUTO_START, false) ||
781
+ (IS_DAEMON_CHILD && hasTelegramCreds);
727
782
  if (sentinelRequested) {
728
783
  const sentinel = await ensureSentinelRunning({ quiet: false });
729
784
  if (!sentinel.ok) {
730
785
  const mode = args.includes("--sentinel")
731
786
  ? "requested by --sentinel"
732
- : "requested by BOSUN_SENTINEL_AUTO_START";
787
+ : IS_DAEMON_CHILD && hasTelegramCreds
788
+ ? "auto-started in daemon mode (Telegram credentials detected)"
789
+ : "requested by BOSUN_SENTINEL_AUTO_START";
733
790
  const strictSentinel = parseBoolEnv(
734
791
  process.env.BOSUN_SENTINEL_STRICT,
735
792
  false,
package/codex-config.mjs CHANGED
@@ -312,6 +312,51 @@ export function hasSandboxPermissions(toml) {
312
312
  return /^sandbox_permissions\s*=/m.test(toml);
313
313
  }
314
314
 
315
+ function stripSandboxPermissions(toml) {
316
+ let next = toml.replace(
317
+ /^\s*#\s*Sandbox permissions.*(?:\r?\n)?/gim,
318
+ "",
319
+ );
320
+ next = next.replace(/^\s*sandbox_permissions\s*=.*(?:\r?\n)?/gim, "");
321
+ return next;
322
+ }
323
+
324
+ function extractSandboxPermissionsValue(toml) {
325
+ const match = toml.match(/^\s*sandbox_permissions\s*=\s*(.+)$/m);
326
+ if (!match) return "";
327
+ const raw = String(match[1] || "").split("#")[0].trim();
328
+ if (!raw) return "";
329
+ if (raw.startsWith("[")) {
330
+ const values = parseTomlArrayLiteral(raw);
331
+ return values.join(",");
332
+ }
333
+ const quoted = raw.match(/^"(.*)"$/) || raw.match(/^'(.*)'$/);
334
+ if (quoted) return quoted[1];
335
+ return raw;
336
+ }
337
+
338
+ function insertTopLevelSandboxPermissions(toml, permValue) {
339
+ const block = buildSandboxPermissions(permValue).trim();
340
+ const tableIdx = toml.search(/^\s*\[/m);
341
+ if (tableIdx === -1) {
342
+ return `${toml.trimEnd()}\n\n${block}\n`;
343
+ }
344
+ const head = toml.slice(0, tableIdx).trimEnd();
345
+ const tail = toml.slice(tableIdx).trimStart();
346
+ return `${head}\n\n${block}\n\n${tail}`;
347
+ }
348
+
349
+ function ensureTopLevelSandboxPermissions(toml, envValue) {
350
+ const existingValue = extractSandboxPermissionsValue(toml);
351
+ const permValue = envValue || existingValue || "disk-full-write-access";
352
+ const stripped = stripSandboxPermissions(toml);
353
+ const updated = insertTopLevelSandboxPermissions(stripped, permValue);
354
+ return {
355
+ toml: updated,
356
+ changed: updated !== toml,
357
+ };
358
+ }
359
+
315
360
  /**
316
361
  * Build a [features] block with the recommended flags.
317
362
  * Reads environment overrides: set CODEX_FEATURES_<NAME>=false to disable.
@@ -407,16 +452,18 @@ export function ensureFeatureFlags(toml, envOverrides = process.env) {
407
452
 
408
453
  /**
409
454
  * Build the sandbox_permissions top-level key.
410
- * Default: ["disk-full-write-access"] for agentic workloads.
455
+ * Default: "disk-full-write-access" for agentic workloads.
456
+ *
457
+ * Codex CLI expects sandbox_permissions as a plain string, NOT an array.
411
458
  *
412
459
  * @param {string} [envValue] CODEX_SANDBOX_PERMISSIONS env var value
413
460
  * @returns {string} TOML line(s)
414
461
  */
415
462
  export function buildSandboxPermissions(envValue) {
416
- const perms = envValue
417
- ? envValue.split(",").map((s) => `"${s.trim()}"`)
418
- : ['"disk-full-write-access"'];
419
- return `\n# Sandbox permissions (added by bosun)\nsandbox_permissions = [${perms.join(", ")}]\n`;
463
+ const perm = envValue
464
+ ? envValue.split(",").map((s) => s.trim()).filter(Boolean).join(",")
465
+ : "disk-full-write-access";
466
+ return `\n# Sandbox permissions (added by bosun)\nsandbox_permissions = "${perm}"\n`;
420
467
  }
421
468
 
422
469
  function parseTomlArrayLiteral(raw) {
@@ -1289,10 +1336,13 @@ export function ensureCodexConfig({
1289
1336
 
1290
1337
  // ── 1e. Ensure sandbox permissions ────────────────────────
1291
1338
 
1292
- if (!hasSandboxPermissions(toml)) {
1339
+ {
1293
1340
  const envPerms = env.CODEX_SANDBOX_PERMISSIONS || "";
1294
- toml = toml.trimEnd() + "\n" + buildSandboxPermissions(envPerms || undefined);
1295
- result.sandboxAdded = true;
1341
+ const ensured = ensureTopLevelSandboxPermissions(toml, envPerms);
1342
+ if (ensured.changed) {
1343
+ toml = ensured.toml;
1344
+ result.sandboxAdded = true;
1345
+ }
1296
1346
  }
1297
1347
 
1298
1348
  // ── 1f. Ensure sandbox workspace-write defaults ───────────
@@ -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/hook-profiles.mjs CHANGED
@@ -552,6 +552,10 @@ function buildDisableEnv(hookConfig) {
552
552
  };
553
553
  }
554
554
 
555
+ function normalizePathForOutput(value) {
556
+ return String(value || "").replace(/\\/g, "/");
557
+ }
558
+
555
559
  export function scaffoldAgentHookFiles(repoRoot, options = {}) {
556
560
  const root = resolve(repoRoot || process.cwd());
557
561
  const targets = normalizeHookTargets(options.targets);
@@ -585,13 +589,13 @@ export function scaffoldAgentHookFiles(repoRoot, options = {}) {
585
589
  const codexPath = resolve(root, ".codex", "hooks.json");
586
590
  const existedBefore = existsSync(codexPath);
587
591
  if (existedBefore && !overwriteExisting) {
588
- result.skipped.push(relative(root, codexPath));
592
+ result.skipped.push(normalizePathForOutput(relative(root, codexPath)));
589
593
  } else {
590
594
  writeJson(codexPath, codexHookConfig);
591
595
  if (existedBefore) {
592
- result.updated.push(relative(root, codexPath));
596
+ result.updated.push(normalizePathForOutput(relative(root, codexPath)));
593
597
  } else {
594
- result.written.push(relative(root, codexPath));
598
+ result.written.push(normalizePathForOutput(relative(root, codexPath)));
595
599
  }
596
600
  }
597
601
  }
@@ -610,18 +614,18 @@ export function scaffoldAgentHookFiles(repoRoot, options = {}) {
610
614
  hasLegacyBridgeInCopilotConfig(existingCopilot);
611
615
 
612
616
  if (existedBefore && !overwriteExisting && !forceLegacyMigration) {
613
- result.skipped.push(relative(root, copilotPath));
617
+ result.skipped.push(normalizePathForOutput(relative(root, copilotPath)));
614
618
  } else {
615
619
  writeJson(copilotPath, config);
616
620
  if (existedBefore) {
617
- result.updated.push(relative(root, copilotPath));
621
+ result.updated.push(normalizePathForOutput(relative(root, copilotPath)));
618
622
  if (forceLegacyMigration) {
619
623
  result.warnings.push(
620
- `${relative(root, copilotPath)} contained legacy bridge path and was auto-updated`,
624
+ `${normalizePathForOutput(relative(root, copilotPath))} contained legacy bridge path and was auto-updated`,
621
625
  );
622
626
  }
623
627
  } else {
624
- result.written.push(relative(root, copilotPath));
628
+ result.written.push(normalizePathForOutput(relative(root, copilotPath)));
625
629
  }
626
630
  }
627
631
  }
@@ -634,15 +638,15 @@ export function scaffoldAgentHookFiles(repoRoot, options = {}) {
634
638
 
635
639
  if (existing === null && existsSync(claudePath)) {
636
640
  result.warnings.push(
637
- `${relative(root, claudePath)} exists but is not valid JSON; skipped`,
641
+ `${normalizePathForOutput(relative(root, claudePath))} exists but is not valid JSON; skipped`,
638
642
  );
639
643
  } else {
640
644
  const merged = mergeClaudeSettings(existing, generated);
641
645
  writeJson(claudePath, merged);
642
646
  if (existedBefore) {
643
- result.updated.push(relative(root, claudePath));
647
+ result.updated.push(normalizePathForOutput(relative(root, claudePath)));
644
648
  } else {
645
- result.written.push(relative(root, claudePath));
649
+ result.written.push(normalizePathForOutput(relative(root, claudePath)));
646
650
  }
647
651
  }
648
652
  }
package/monitor.mjs CHANGED
@@ -216,6 +216,7 @@ import {
216
216
  setKanbanBackend,
217
217
  listTasks as listKanbanTasks,
218
218
  updateTaskStatus as updateKanbanTaskStatus,
219
+ updateTask as updateKanbanTask,
219
220
  listProjects as listKanbanProjects,
220
221
  createTask as createKanbanTask,
221
222
  } from "./kanban-adapter.mjs";
@@ -5863,7 +5864,7 @@ const dependabotMergeAttempted = new Set();
5863
5864
  */
5864
5865
  async function checkAndMergeDependabotPRs() {
5865
5866
  if (!dependabotAutoMerge) return;
5866
- if (!repoSlug) {
5867
+ if (!repoSlug || repoSlug === "unknown/unknown") {
5867
5868
  console.warn("[dependabot] auto-merge disabled — no repo slug configured");
5868
5869
  return;
5869
5870
  }
@@ -9026,8 +9027,20 @@ async function triggerTaskPlannerViaKanban(
9026
9027
  `[monitor] task planner task already exists in backlog — skipping: "${existingPlanner.title}" (${existingPlanner.id})`,
9027
9028
  );
9028
9029
  // Best-effort: keep backlog task aligned with current requirements
9029
- // Note: For now, we skip updating existing tasks as not all backends support partial updates
9030
- // TODO: Implement backend-specific update logic if needed
9030
+ // Update description if the backend supports it, so the agent gets fresh context
9031
+ try {
9032
+ await updateKanbanTask(existingPlanner.id, {
9033
+ description: desiredDescription,
9034
+ });
9035
+ console.log(
9036
+ `[monitor] updated description of existing planner task: "${existingPlanner.title}" (${existingPlanner.id})`,
9037
+ );
9038
+ } catch (updateErr) {
9039
+ // Not all backends support partial description updates — log and continue
9040
+ console.log(
9041
+ `[monitor] could not update existing planner task description (${updateErr.message || updateErr}) — skipping`,
9042
+ );
9043
+ }
9031
9044
 
9032
9045
  const taskUrl = buildTaskUrl(existingPlanner, projectId);
9033
9046
  if (notify) {
@@ -12035,6 +12048,10 @@ function restartGitHubReconciler() {
12035
12048
  : "") ||
12036
12049
  repoSlug ||
12037
12050
  "unknown/unknown";
12051
+ if (!repo || repo === "unknown/unknown") {
12052
+ console.warn("[gh-reconciler] disabled — missing repo slug");
12053
+ return;
12054
+ }
12038
12055
 
12039
12056
  ghReconciler = startGitHubReconciler({
12040
12057
  repoSlug: repo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.29.5",
3
+ "version": "0.29.7",
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",
@@ -151,6 +151,7 @@
151
151
  "prepublish-check.mjs",
152
152
  "presence.mjs",
153
153
  "primary-agent.mjs",
154
+ "repo-config.mjs",
154
155
  "repo-root.mjs",
155
156
  "restart-controller.mjs",
156
157
  "review-agent.mjs",