bosun 0.28.2 → 0.28.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -303,6 +303,74 @@ TELEGRAM_MINIAPP_ENABLED=false
303
303
  # Optional free-form constraints/scoping notes
304
304
  # PROJECT_REQUIREMENTS_NOTES=
305
305
 
306
+ # ─── Branch Strategy ──────────────────────────────────────────────────
307
+ # How agents work on branches for each task:
308
+ #
309
+ # worktree (default) — Each task gets an isolated ve/<id>-<slug> sub-branch
310
+ # that PRs back into the module/base branch.
311
+ # Best for: independent, high-parallelism work.
312
+ #
313
+ # direct — Agents push directly onto the module branch
314
+ # (no ve/ sub-branch). All work is sequential on that
315
+ # branch. Conflicts are resolved in real time.
316
+ # Best for: fast iteration on a well-scoped module.
317
+ #
318
+ # TASK_BRANCH_MODE=worktree
319
+ #
320
+ # Auto-detect origin/<module> as base_branch from conventional commit task titles
321
+ # e.g. "[m] feat(veid): add verification" → base_branch = origin/veid
322
+ # TASK_BRANCH_AUTO_MODULE=true
323
+ #
324
+ # Prefix used when building module branch refs (default: origin/)
325
+ # MODULE_BRANCH_PREFIX=origin/
326
+ #
327
+ # Default upstream target branch (main branch) for upstream sync and PRs
328
+ # DEFAULT_TARGET_BRANCH=main
329
+ #
330
+ # Merge origin/main into module branches before each push (keeps branches in
331
+ # sync with upstream continuously). Conflicts abort the merge and are resolved
332
+ # by the next agent working on that branch. Default: true
333
+ # TASK_UPSTREAM_SYNC_MAIN=true
334
+
335
+ # ─── GitHub App (Bosun[botswain] Identity + Auth) ────────────────────────────
336
+ # App: https://github.com/apps/bosun-botswain (slug: bosun-botswain)
337
+ # Bot identity: bosun-botswain[bot] (appears as contributor on every agent commit)
338
+ #
339
+ # Numeric App ID (shown on the App settings page under "About"):
340
+ # BOSUN_GITHUB_APP_ID=2911413
341
+ #
342
+ # OAuth Client ID (from App settings → Client ID):
343
+ # BOSUN_GITHUB_CLIENT_ID=Iv23liZpVhGePGka9gcL
344
+ #
345
+ # OAuth Client Secret (only needed for callback-based OAuth, not for Device Flow):
346
+ # BOSUN_GITHUB_CLIENT_SECRET=
347
+ #
348
+ # Webhook secret (set this in App settings → Webhook, and keep in sync):
349
+ # BOSUN_GITHUB_WEBHOOK_SECRET=
350
+ #
351
+ # Path to the PEM private key downloaded from App settings → Generate a private key:
352
+ # BOSUN_GITHUB_PRIVATE_KEY_PATH=/path/to/bosun-botswain.pem
353
+ #
354
+ # ─── Authentication Method ───────────────────────────────────────────────
355
+ # RECOMMENDED: Device Flow (like VS Code / Roo Code — no public URL needed!)
356
+ # 1. Set BOSUN_GITHUB_CLIENT_ID above
357
+ # 2. Enable "Device Flow" in GitHub App settings (Settings → Optional features)
358
+ # 3. Go to Settings → GitHub in the Bosun UI and click "Sign in with GitHub"
359
+ # 4. That's it — no callback URL, no tunnel URL, no client secret needed
360
+ #
361
+ # ALTERNATIVE: OAuth Callback (requires a stable public URL)
362
+ # Set BOSUN_GITHUB_CLIENT_ID + BOSUN_GITHUB_CLIENT_SECRET
363
+ # Register callback URL in App settings:
364
+ # https://<your-bosun-public-url>/api/github/callback
365
+ #
366
+ # WEBHOOKS (optional — for real-time PR/issue sync):
367
+ # Register webhook URL in App settings:
368
+ # https://<your-bosun-public-url>/api/webhooks/github/app
369
+ # Webhooks require a stable public URL. Without them, Bosun polls instead.
370
+ #
371
+ # Leave BOSUN_GITHUB_APP_ID unset to disable co-author trailer injection.
372
+ # BOSUN_GITHUB_APP_ID=
373
+
306
374
  # ─── Kanban Backend ──────────────────────────────────────────────────────────
307
375
  # Task-board backend:
308
376
  # internal - local task-store source of truth (recommended primary)
package/README.md CHANGED
@@ -10,7 +10,7 @@ Bosun is a production-grade supervisor for AI coding agents. It routes tasks acr
10
10
  </p>
11
11
 
12
12
  <p align="center">
13
- <img src="site/social-banner.png" alt="bosun — AI agent supervisor" width="100%" />
13
+ <img src="site/social-card.png" alt="bosun — AI agent supervisor" width="100%" />
14
14
  </p>
15
15
 
16
16
  <p align="center">
package/agent-prompts.mjs CHANGED
@@ -173,11 +173,12 @@ Return exactly one fenced json block with this shape:
173
173
  {
174
174
  "tasks": [
175
175
  {
176
- "title": "[m] Example task title",
176
+ "title": "[m] feat(veid): example task title",
177
177
  "description": "Problem statement and scope",
178
178
  "implementation_steps": ["step 1", "step 2"],
179
179
  "acceptance_criteria": ["criterion 1", "criterion 2"],
180
- "verification": ["test/check 1", "test/check 2"]
180
+ "verification": ["test/check 1", "test/check 2"],
181
+ "base_branch": "origin/veid"
181
182
  }
182
183
  ]
183
184
  }
@@ -187,6 +188,11 @@ Rules:
187
188
  - Provide at least the requested task count unless blocked by duplicate safeguards.
188
189
  - Keep titles unique and specific.
189
190
  - Keep file overlap low across tasks to maximize parallel execution.
191
+ - **Module branch routing:** When the task title follows conventional commit format
192
+ \`feat(module):\` or \`fix(module):\`, set \`base_branch\` to \`origin/<module>\`.
193
+ This routes the task to the module's dedicated branch for parallel, isolated development.
194
+ Examples: \`feat(veid):\` → \`"base_branch": "origin/veid"\`, \`fix(market):\` → \`"base_branch": "origin/market"\`.
195
+ Omit \`base_branch\` for cross-cutting tasks that span multiple modules.
190
196
  `,
191
197
  monitorMonitor: `# Bosun-Monitor Agent
192
198
 
@@ -240,14 +246,14 @@ You are running as a **Bosun-managed task agent**. Environment variables
240
246
  **After committing:**
241
247
  - If a precommit hook auto-applies additional formatting changes, add those
242
248
  to a follow-up commit before pushing.
243
- - Merge any upstream changes from the base branch before pushing:
244
- \`git fetch origin && git merge origin/<base-branch> --no-edit\`
245
- Resolve any conflicts that arise.
249
+ - Merge any upstream changes — BOTH from the base (module) branch AND from main:
250
+ \`git fetch origin && git merge origin/<base-branch> --no-edit && git merge origin/main --no-edit\`
251
+ Resolve any conflicts that arise before pushing.
246
252
  - Push: \`git push --set-upstream origin {{BRANCH}}\`
247
253
  - After a successful push, open a Pull Request:
248
254
  \`gh pr create --title "{{TASK_TITLE}}" --body "Closes task {{TASK_ID}}"\`
249
255
  - **Do NOT** run \`gh pr merge\` — the orchestrator handles merges after CI.
250
-
256
+ {{COAUTHOR_INSTRUCTION}}
251
257
  **Do NOT:**
252
258
  - Bypass pre-push hooks (\`git push --no-verify\` is forbidden).
253
259
  - Use \`git add .\` — stage files individually.
@@ -12,8 +12,8 @@
12
12
  * - Cost anomaly detection (unusually expensive sessions)
13
13
  */
14
14
 
15
- import { readFile, writeFile, appendFile, stat, watch } from "fs/promises";
16
- import { createReadStream, existsSync } from "fs";
15
+ import { readFile, writeFile, appendFile, stat, watch, mkdir } from "fs/promises";
16
+ import { createReadStream, existsSync, mkdirSync } from "fs";
17
17
  import { createInterface } from "readline";
18
18
  import { resolve, dirname } from "path";
19
19
  import { fileURLToPath } from "url";
@@ -73,30 +73,54 @@ export async function startAnalyzer() {
73
73
 
74
74
  console.log("[agent-work-analyzer] Starting...");
75
75
 
76
- // Ensure alerts log exists
77
- if (!existsSync(ALERTS_LOG)) {
78
- await writeFile(ALERTS_LOG, "");
76
+ // Ensure parent directory and alerts log exist
77
+ try {
78
+ const alertsDir = dirname(ALERTS_LOG);
79
+ if (!existsSync(alertsDir)) {
80
+ await mkdir(alertsDir, { recursive: true });
81
+ }
82
+ if (!existsSync(ALERTS_LOG)) {
83
+ await writeFile(ALERTS_LOG, "");
84
+ }
85
+ } catch (err) {
86
+ console.warn(`[agent-work-analyzer] Failed to init alerts log: ${err.message}`);
79
87
  }
80
88
 
81
89
  // Initial read of existing log
82
90
  if (existsSync(AGENT_WORK_STREAM)) {
83
91
  filePosition = await processLogFile(filePosition);
92
+ } else {
93
+ // Ensure the stream file exists so the watcher doesn't throw
94
+ try {
95
+ await writeFile(AGENT_WORK_STREAM, "");
96
+ } catch {
97
+ // May fail if another process creates it first — that's fine
98
+ }
84
99
  }
85
100
 
86
- // Watch for changes
101
+ // Watch for changes — retry loop handles the case where the file
102
+ // is deleted and recreated (e.g. log rotation).
87
103
  console.log(`[agent-work-analyzer] Watching: ${AGENT_WORK_STREAM}`);
88
104
 
89
- const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
90
-
91
- try {
92
- for await (const event of watcher) {
93
- if (event.eventType === "change") {
94
- filePosition = await processLogFile(filePosition);
105
+ while (isRunning) {
106
+ try {
107
+ const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
108
+ for await (const event of watcher) {
109
+ if (!isRunning) break;
110
+ if (event.eventType === "change") {
111
+ filePosition = await processLogFile(filePosition);
112
+ }
113
+ }
114
+ } catch (err) {
115
+ if (!isRunning) break;
116
+ if (err.code === "ENOENT") {
117
+ // File was deleted — wait a bit and retry
118
+ await new Promise((r) => setTimeout(r, 5000));
119
+ continue;
95
120
  }
96
- }
97
- } catch (err) {
98
- if (err.code !== "ENOENT") {
99
121
  console.error(`[agent-work-analyzer] Watcher error: ${err.message}`);
122
+ // Wait before retrying to avoid busy-loop
123
+ await new Promise((r) => setTimeout(r, 10000));
100
124
  }
101
125
  }
102
126
  }
package/cli.mjs CHANGED
@@ -979,7 +979,10 @@ async function main() {
979
979
  const logDir = monitorPath ? resolve(dirname(monitorPath), "logs") : resolve(__dirname, "logs");
980
980
  const daemonLog = existsSync(DAEMON_LOG) ? DAEMON_LOG : resolve(logDir, "daemon.log");
981
981
  const monitorLog = resolve(logDir, "monitor.log");
982
- const logFile = existsSync(daemonLog) ? daemonLog : monitorLog;
982
+ // Prefer monitor.log that's where the real activity goes.
983
+ // daemon.log only has the startup line; monitor.mjs intercepts
984
+ // all console output and writes to monitor.log.
985
+ const logFile = existsSync(monitorLog) ? monitorLog : daemonLog;
983
986
 
984
987
  if (existsSync(logFile)) {
985
988
  console.log(
package/codex-config.mjs CHANGED
@@ -373,11 +373,18 @@ function normalizeWritableRoots(input, { repoRoot } = {}) {
373
373
  if (repo) {
374
374
  addRoot(repo);
375
375
  addRoot(resolve(repo, ".git"));
376
+ // Worktree checkout paths (used by task-executor)
377
+ addRoot(resolve(repo, ".cache", "worktrees"));
378
+ // Cache directories for agent work logs, build artifacts, etc.
379
+ addRoot(resolve(repo, ".cache"));
376
380
  const parent = dirname(repo);
377
381
  if (parent && parent !== repo) addRoot(parent);
378
382
  }
379
383
  }
380
384
 
385
+ // /tmp is needed for sandbox temp files, pip installs, etc.
386
+ addRoot("/tmp");
387
+
381
388
  return Array.from(roots);
382
389
  }
383
390
 
package/monitor.mjs CHANGED
@@ -157,6 +157,7 @@ import { WorkspaceMonitor } from "./workspace-monitor.mjs";
157
157
  import { VkLogStream } from "./vk-log-stream.mjs";
158
158
  import { VKErrorResolver } from "./vk-error-resolver.mjs";
159
159
  import { createAnomalyDetector } from "./anomaly-detector.mjs";
160
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
160
161
  import {
161
162
  getWorktreeManager,
162
163
  acquireWorktree,
@@ -311,7 +312,10 @@ function startAgentWorkAnalyzer() {
311
312
  if (agentWorkAnalyzerActive) return;
312
313
  if (process.env.AGENT_WORK_ANALYZER_ENABLED === "false") return;
313
314
  try {
314
- void startAnalyzer();
315
+ startAnalyzer().catch((err) => {
316
+ console.warn(`[monitor] agent-work analyzer async error: ${err.message}`);
317
+ agentWorkAnalyzerActive = false;
318
+ });
315
319
  agentWorkAnalyzerActive = true;
316
320
  console.log("[monitor] agent-work analyzer started");
317
321
  } catch (err) {
@@ -1530,8 +1534,9 @@ async function handleMonitorFailure(reason, err) {
1530
1534
 
1531
1535
  if (telegramToken && telegramChatId) {
1532
1536
  try {
1537
+ const shortMsg = message.length > 200 ? message.slice(0, 200) + "…" : message;
1533
1538
  await sendTelegramMessage(
1534
- `⚠️ bosun exception (${reason}). Attempting recovery (count=${failureCount}).`,
1539
+ `⚠️ bosun exception (${reason}): ${shortMsg}\n\nAttempting recovery (count=${failureCount}).`,
1535
1540
  );
1536
1541
  } catch {
1537
1542
  /* suppress Telegram errors during failure handling */
@@ -7550,17 +7555,47 @@ async function buildPlannerRuntimeContext(reason, details, numTasks) {
7550
7555
  };
7551
7556
  }
7552
7557
 
7558
+ /**
7559
+ * Extract a conventional-commit scope from a task title and return the
7560
+ * corresponding module branch ref (e.g. "origin/veid").
7561
+ * Respects TASK_BRANCH_AUTO_MODULE env var (default: true) and
7562
+ * MODULE_BRANCH_PREFIX (default: "origin/").
7563
+ */
7564
+ function extractModuleBaseBranchFromTitle(title) {
7565
+ const enabled = (process.env.TASK_BRANCH_AUTO_MODULE ?? "true") !== "false";
7566
+ if (!enabled || !title) return null;
7567
+ const match = String(title).match(
7568
+ /(?:^\[[^\]]+\]\s*)?(?:feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)\(([^)]+)\)/i,
7569
+ );
7570
+ if (!match) return null;
7571
+ const scope = match[1].toLowerCase().trim();
7572
+ // Exclude generic scopes that don't map to real module branches
7573
+ const generic = new Set(["deps", "app", "sdk", "cli", "api"]);
7574
+ if (generic.has(scope)) return null;
7575
+ const prefix = (process.env.MODULE_BRANCH_PREFIX || "origin/").replace(/\/*$/, "/");
7576
+ return `${prefix}${scope}`;
7577
+ }
7578
+
7553
7579
  function buildPlannerTaskDescription({
7554
7580
  plannerPrompt,
7555
7581
  reason,
7556
7582
  numTasks,
7557
7583
  runtimeContext,
7584
+ userPrompt,
7558
7585
  }) {
7559
7586
  return [
7560
7587
  "## Task Planner — Auto-created by bosun",
7561
7588
  "",
7562
7589
  `**Trigger reason:** ${reason || "manual"}`,
7563
7590
  `**Requested task count:** ${numTasks}`,
7591
+ ...(userPrompt
7592
+ ? [
7593
+ "",
7594
+ "### User Planning Prompt",
7595
+ "",
7596
+ userPrompt,
7597
+ ]
7598
+ : []),
7564
7599
  "",
7565
7600
  "### Planner Prompt (Injected by bosun)",
7566
7601
  "",
@@ -7610,8 +7645,13 @@ function buildPlannerTaskDescription({
7610
7645
  "4. Prioritize reliability and unblockers first when errors/review backlog is elevated.",
7611
7646
  "5. Avoid duplicates with existing todo/inprogress/review tasks and open PRs.",
7612
7647
  "6. Prefer task sets that can run in parallel with minimal file overlap.",
7613
- "7. If a task should target a non-default epic/base branch, include `base_branch` in the JSON task object.",
7614
- ].join("\n");
7648
+ "7. **Module branch routing (important):** When a task's title follows conventional commit format",
7649
+ " `feat(module):` or `fix(module):`, ALWAYS set `base_branch` to `origin/<module>` in the JSON.",
7650
+ " This enables parallel branch-per-module execution where all work for a module accumulates on",
7651
+ " its dedicated branch and integrates upstream changes continuously.",
7652
+ " Examples: `feat(veid):` → `origin/veid`, `fix(market):` → `origin/market`.",
7653
+ " Do NOT set base_branch for cross-cutting tasks that modify many modules.",
7654
+ "8. If a task should target a non-default epic/base branch for other reasons, include `base_branch` in the JSON task object.",].join("\n");
7615
7655
  }
7616
7656
 
7617
7657
  function normalizePlannerTitleForComparison(title) {
@@ -7788,7 +7828,12 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
7788
7828
  title: task.title,
7789
7829
  description: task.description,
7790
7830
  status: "todo",
7791
- ...(task.baseBranch ? { baseBranch: task.baseBranch } : {}),
7831
+ // Honour explicit baseBranch from planner JSON, then fall back to
7832
+ // module auto-detection via conventional commit scope in the title.
7833
+ ...(() => {
7834
+ const b = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7835
+ return b ? { baseBranch: b } : {};
7836
+ })(),
7792
7837
  });
7793
7838
  if (createdTask?.id) {
7794
7839
  created.push({ id: createdTask.id, title: task.title });
@@ -8689,6 +8734,7 @@ async function triggerTaskPlanner(
8689
8734
  details,
8690
8735
  {
8691
8736
  taskCount,
8737
+ userPrompt,
8692
8738
  notify = true,
8693
8739
  preferredMode,
8694
8740
  allowCodexWhenDisabled = false,
@@ -8724,6 +8770,7 @@ async function triggerTaskPlanner(
8724
8770
  try {
8725
8771
  result = await triggerTaskPlannerViaKanban(reason, details, {
8726
8772
  taskCount,
8773
+ userPrompt,
8727
8774
  notify,
8728
8775
  });
8729
8776
  } catch (kanbanErr) {
@@ -8753,6 +8800,7 @@ async function triggerTaskPlanner(
8753
8800
  }
8754
8801
  result = await triggerTaskPlannerViaCodex(reason, details, {
8755
8802
  taskCount,
8803
+ userPrompt,
8756
8804
  notify,
8757
8805
  allowWhenDisabled: allowCodexWhenDisabled,
8758
8806
  });
@@ -8761,6 +8809,7 @@ async function triggerTaskPlanner(
8761
8809
  try {
8762
8810
  result = await triggerTaskPlannerViaCodex(reason, details, {
8763
8811
  taskCount,
8812
+ userPrompt,
8764
8813
  notify,
8765
8814
  allowWhenDisabled: allowCodexWhenDisabled,
8766
8815
  });
@@ -8784,6 +8833,7 @@ async function triggerTaskPlanner(
8784
8833
 
8785
8834
  result = await triggerTaskPlannerViaKanban(reason, details, {
8786
8835
  taskCount,
8836
+ userPrompt,
8787
8837
  notify,
8788
8838
  });
8789
8839
  }
@@ -8818,7 +8868,7 @@ async function triggerTaskPlanner(
8818
8868
  async function triggerTaskPlannerViaKanban(
8819
8869
  reason,
8820
8870
  details,
8821
- { taskCount, notify = true } = {},
8871
+ { taskCount, userPrompt, notify = true } = {},
8822
8872
  ) {
8823
8873
  const defaultPlannerTaskCount = Number(
8824
8874
  process.env.TASK_PLANNER_DEFAULT_COUNT || "30",
@@ -8844,12 +8894,15 @@ async function triggerTaskPlannerViaKanban(
8844
8894
  );
8845
8895
  }
8846
8896
 
8847
- const desiredTitle = `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8897
+ const desiredTitle = userPrompt
8898
+ ? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
8899
+ : `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8848
8900
  const desiredDescription = buildPlannerTaskDescription({
8849
8901
  plannerPrompt,
8850
8902
  reason,
8851
8903
  numTasks,
8852
8904
  runtimeContext,
8905
+ userPrompt,
8853
8906
  });
8854
8907
 
8855
8908
  // Check for existing planner tasks to avoid duplicates
@@ -8941,7 +8994,7 @@ async function triggerTaskPlannerViaKanban(
8941
8994
  async function triggerTaskPlannerViaCodex(
8942
8995
  reason,
8943
8996
  details,
8944
- { taskCount, notify = true, allowWhenDisabled = false } = {},
8997
+ { taskCount, userPrompt, notify = true, allowWhenDisabled = false } = {},
8945
8998
  ) {
8946
8999
  if (!codexEnabled && !allowWhenDisabled) {
8947
9000
  throw new Error(
@@ -8973,6 +9026,17 @@ async function triggerTaskPlannerViaCodex(
8973
9026
  "## Execution Context",
8974
9027
  `- Trigger reason: ${reason || "manual"}`,
8975
9028
  `- Requested task count: ${numTasks}`,
9029
+ ...(userPrompt
9030
+ ? [
9031
+ "",
9032
+ "## User Planning Prompt",
9033
+ "",
9034
+ userPrompt,
9035
+ "",
9036
+ "Incorporate the above user prompt into any relevant planning decisions.",
9037
+ ]
9038
+ : []),
9039
+ "",
8976
9040
  "Context JSON:",
8977
9041
  "```json",
8978
9042
  safeJsonBlock(runtimeContext),
@@ -10590,21 +10654,16 @@ async function startProcess() {
10590
10654
  let orchestratorArgs = [...scriptArgs];
10591
10655
 
10592
10656
  if (scriptLower.endsWith(".ps1")) {
10593
- const configuredPwsh = String(process.env.PWSH_PATH || "").trim();
10594
- const bundledPwsh = resolve(__dirname, ".cache", "bosun", "pwsh", "pwsh");
10595
- const bundledPwshExists = existsSync(bundledPwsh);
10596
- const pwshCmd = configuredPwsh || (bundledPwshExists ? bundledPwsh : "pwsh");
10597
- const pwshExists = configuredPwsh
10598
- ? configuredPwsh.includes("/") || configuredPwsh.includes("\\")
10599
- ? existsSync(configuredPwsh)
10600
- : commandExists(configuredPwsh)
10601
- : bundledPwshExists || commandExists("pwsh");
10602
- if (!pwshExists) {
10603
- const pwshLabel = configuredPwsh
10604
- ? `PWSH_PATH (${configuredPwsh})`
10605
- : bundledPwshExists
10606
- ? `bundled pwsh (${bundledPwsh})`
10607
- : "pwsh on PATH";
10657
+ const pwshRuntime = resolvePwshRuntime({ preferBundled: true });
10658
+ if (!pwshRuntime.exists) {
10659
+ const pwshLabel =
10660
+ pwshRuntime.source === "env"
10661
+ ? `PWSH_PATH (${pwshRuntime.command})`
10662
+ : pwshRuntime.source === "bundled"
10663
+ ? `bundled pwsh (${pwshRuntime.command})`
10664
+ : pwshRuntime.source === "powershell"
10665
+ ? `powershell on PATH`
10666
+ : "pwsh on PATH";
10608
10667
  const pauseMs = Math.max(orchestratorPauseMs, 60_000);
10609
10668
  const pauseMin = Math.max(1, Math.round(pauseMs / 60_000));
10610
10669
  monitorSafeModeUntil = Math.max(monitorSafeModeUntil, Date.now() + pauseMs);
@@ -10622,7 +10681,7 @@ async function startProcess() {
10622
10681
  setTimeout(startProcess, pauseMs);
10623
10682
  return;
10624
10683
  }
10625
- orchestratorCmd = pwshCmd;
10684
+ orchestratorCmd = pwshRuntime.command;
10626
10685
  orchestratorArgs = ["-File", scriptPath, ...scriptArgs];
10627
10686
  } else if (scriptLower.endsWith(".sh")) {
10628
10687
  const shellCmd =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.2",
3
+ "version": "0.28.4",
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",
@@ -152,6 +152,7 @@
152
152
  "sdk-conflict-resolver.mjs",
153
153
  "session-tracker.mjs",
154
154
  "setup.mjs",
155
+ "pwsh-runtime.mjs",
155
156
  "shared-knowledge.mjs",
156
157
  "shared-state-manager.mjs",
157
158
  "shared-workspace-cli.mjs",
package/preflight.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { resolve } from "node:path";
3
3
  import os from "node:os";
4
+ import { resolvePwshRuntime } from "./pwsh-runtime.mjs";
4
5
 
5
6
  const isWindows = process.platform === "win32";
6
7
  const MIN_FREE_GB = Number(process.env.BOSUN_MIN_FREE_GB || "10");
@@ -179,6 +180,7 @@ function checkToolchain() {
179
180
  "node",
180
181
  shellMode ? "shell" : "pwsh",
181
182
  ]);
183
+ const pwshRuntime = resolvePwshRuntime({ preferBundled: true });
182
184
 
183
185
  const tools = [
184
186
  checkToolVersion(
@@ -213,7 +215,7 @@ function checkToolchain() {
213
215
  ),
214
216
  checkToolVersion(
215
217
  "pwsh",
216
- "pwsh",
218
+ pwshRuntime.command,
217
219
  ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
218
220
  "Install PowerShell 7+ (pwsh) and ensure it is on PATH.",
219
221
  ),
package/primary-agent.mjs CHANGED
@@ -362,9 +362,13 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
362
362
  );
363
363
 
364
364
  if (result) {
365
+ // Extract human-readable text from structured responses
366
+ const text = typeof result === "string"
367
+ ? result
368
+ : result.finalResponse || result.text || result.message || JSON.stringify(result);
365
369
  tracker.recordEvent(sessionId, {
366
370
  role: "assistant",
367
- content: typeof result === "string" ? result : JSON.stringify(result),
371
+ content: text,
368
372
  timestamp: new Date().toISOString(),
369
373
  _sessionType: sessionType,
370
374
  });
@@ -0,0 +1,62 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { execSync } from "node:child_process";
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const BUNDLED_PWSH_PATH = resolve(__dirname, ".cache", "bosun", "pwsh", "pwsh");
8
+
9
+ function commandExists(cmd) {
10
+ try {
11
+ execSync(`${process.platform === "win32" ? "where" : "which"} ${cmd}`, {
12
+ stdio: "ignore",
13
+ });
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function isPathLike(value) {
21
+ return value.includes("/") || value.includes("\\");
22
+ }
23
+
24
+ export function resolvePwshRuntime({ preferBundled = true } = {}) {
25
+ const configured = String(process.env.PWSH_PATH || "").trim();
26
+ if (configured) {
27
+ if (isPathLike(configured)) {
28
+ if (existsSync(configured)) {
29
+ return { command: configured, source: "env", exists: true };
30
+ }
31
+ return { command: configured, source: "env", exists: false };
32
+ }
33
+ if (commandExists(configured)) {
34
+ return { command: configured, source: "env", exists: true };
35
+ }
36
+ return { command: configured, source: "env", exists: false };
37
+ }
38
+
39
+ if (preferBundled && existsSync(BUNDLED_PWSH_PATH)) {
40
+ return { command: BUNDLED_PWSH_PATH, source: "bundled", exists: true };
41
+ }
42
+
43
+ if (commandExists("pwsh")) {
44
+ return { command: "pwsh", source: "path", exists: true };
45
+ }
46
+
47
+ if (process.platform === "win32" && commandExists("powershell")) {
48
+ return { command: "powershell", source: "powershell", exists: true };
49
+ }
50
+
51
+ return { command: "pwsh", source: "missing", exists: false };
52
+ }
53
+
54
+ export function resolvePwshCommand(options = {}) {
55
+ return resolvePwshRuntime(options).command;
56
+ }
57
+
58
+ export function hasPwshRuntime(options = {}) {
59
+ return resolvePwshRuntime(options).exists;
60
+ }
61
+
62
+ export { BUNDLED_PWSH_PATH };