bosun 0.28.1 → 0.28.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.
package/.env.example CHANGED
@@ -169,6 +169,13 @@ TELEGRAM_MINIAPP_ENABLED=false
169
169
  # Priority threshold for immediate delivery: 1=critical only, 2=critical+errors (default: 1)
170
170
  # TELEGRAM_IMMEDIATE_PRIORITY=1
171
171
 
172
+ # ─── Auto-Delete Old Messages ──────────────────────────────────────────────────────────
173
+ # Automatically delete bot messages older than N days to keep chat tidy.
174
+ # Set to 0 to disable. Default: 3 days.
175
+ # Note: Telegram’s API may silently skip messages older than 48 h in private
176
+ # chats — those will just remain; no error is raised.
177
+ # TELEGRAM_HISTORY_RETENTION_DAYS=3
178
+
172
179
  # ─── Presence & Multi-Instance Coordination ──────────────────────────────────
173
180
  # Presence heartbeat allows discovering multiple bosun instances.
174
181
  # Heartbeat interval in seconds (default: 60)
package/README.md CHANGED
@@ -5,11 +5,19 @@
5
5
 
6
6
  Bosun is a production-grade supervisor for AI coding agents. It routes tasks across executors, automates PR lifecycles, and keeps operators in control through Telegram, the Mini App dashboard, and optional WhatsApp notifications.
7
7
 
8
- [Website](https://bosun.virtengine.com) · [Docs](https://bosun.virtengine.com/docs/) · [GitHub](https://github.com/virtengine/bosun?tab=readme-ov-file#bosun) · [npm](https://www.npmjs.com/package/bosun) · [Issues](https://github.com/virtengine/bosun/issues)
8
+ <p align="center">
9
+ <a href="https://bosun.virtengine.com">Website</a> · <a href="https://bosun.virtengine.com/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
10
+ </p>
9
11
 
10
- [![CI](https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/virtengine/bosun/actions/workflows/ci.yaml)
11
- [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
12
- [![npm](https://img.shields.io/npm/v/bosun.svg)](https://www.npmjs.com/package/bosun)
12
+ <p align="center">
13
+ <img src="site/social-card.png" alt="bosun — AI agent supervisor" width="100%" />
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://github.com/virtengine/bosun/actions/workflows/ci.yaml"><img src="https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI" /></a>
18
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License" /></a>
19
+ <a href="https://www.npmjs.com/package/bosun"><img src="https://img.shields.io/npm/v/bosun.svg" alt="npm" /></a>
20
+ </p>
13
21
 
14
22
  ---
15
23
 
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { spawnSync, spawn } from "node:child_process";
@@ -7,6 +7,28 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
7
7
  const desktopDir = resolve(__dirname);
8
8
  const binName = process.platform === "win32" ? "electron.cmd" : "electron";
9
9
  const electronBin = resolve(desktopDir, "node_modules", ".bin", binName);
10
+ const chromeSandbox = resolve(
11
+ desktopDir,
12
+ "node_modules",
13
+ "electron",
14
+ "dist",
15
+ "chrome-sandbox",
16
+ );
17
+
18
+ function shouldDisableSandbox() {
19
+ if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
20
+ if (process.platform !== "linux") return false;
21
+ if (!existsSync(chromeSandbox)) return true;
22
+ try {
23
+ const stats = statSync(chromeSandbox);
24
+ const mode = stats.mode & 0o7777;
25
+ const isRootOwned = stats.uid === 0;
26
+ const isSetuid = mode === 0o4755;
27
+ return !(isRootOwned && isSetuid);
28
+ } catch {
29
+ return true;
30
+ }
31
+ }
10
32
 
11
33
  function ensureElectronInstalled() {
12
34
  if (existsSync(electronBin)) return true;
@@ -28,11 +50,18 @@ function launch() {
28
50
  process.exit(1);
29
51
  }
30
52
 
31
- const child = spawn(electronBin, [desktopDir], {
53
+ const disableSandbox = shouldDisableSandbox();
54
+ const args = [desktopDir];
55
+ if (disableSandbox) {
56
+ args.push("--no-sandbox", "--disable-gpu-sandbox");
57
+ }
58
+
59
+ const child = spawn(electronBin, args, {
32
60
  stdio: "inherit",
33
61
  env: {
34
62
  ...process.env,
35
63
  BOSUN_DESKTOP: "1",
64
+ ...(disableSandbox ? { ELECTRON_DISABLE_SANDBOX: "1" } : {}),
36
65
  },
37
66
  });
38
67
 
package/desktop/main.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { app, BrowserWindow } from "electron";
1
+ import { app, BrowserWindow, session } from "electron";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { existsSync, readFileSync } from "node:fs";
@@ -18,6 +18,19 @@ let runtimeConfigLoaded = false;
18
18
 
19
19
  const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
20
20
 
21
+ // Local/private-network patterns — TLS cert bypass for the embedded UI server
22
+ const LOCAL_HOSTNAME_RE = [
23
+ /^127\./,
24
+ /^192\.168\./,
25
+ /^10\./,
26
+ /^172\.(1[6-9]|2[0-9]|3[01])\./,
27
+ /^::1$/,
28
+ /^localhost$/i,
29
+ ];
30
+ function isLocalHost(hostname) {
31
+ return LOCAL_HOSTNAME_RE.some((re) => re.test(hostname));
32
+ }
33
+
21
34
  function parseBoolEnv(value, fallback) {
22
35
  if (value === undefined || value === null) return fallback;
23
36
  const normalized = String(value).trim().toLowerCase();
@@ -163,7 +176,7 @@ async function resolveDaemonUiUrl() {
163
176
  async function ensureDaemonRunning() {
164
177
  const autoStart = parseBoolEnv(
165
178
  process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
166
- true,
179
+ false,
167
180
  );
168
181
  if (!autoStart) return;
169
182
 
@@ -239,6 +252,7 @@ async function buildUiUrl() {
239
252
 
240
253
  async function createMainWindow() {
241
254
  if (mainWindow) return;
255
+ const iconPath = resolveBosunRuntimePath("logo.png");
242
256
 
243
257
  mainWindow = new BrowserWindow({
244
258
  width: 1280,
@@ -246,6 +260,7 @@ async function createMainWindow() {
246
260
  minWidth: 960,
247
261
  minHeight: 640,
248
262
  backgroundColor: "#0b0b0c",
263
+ ...(existsSync(iconPath) ? { icon: iconPath } : {}),
249
264
  show: false,
250
265
  webPreferences: {
251
266
  contextIsolation: true,
@@ -269,9 +284,34 @@ async function createMainWindow() {
269
284
 
270
285
  async function bootstrap() {
271
286
  try {
287
+ if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
288
+ app.commandLine.appendSwitch("no-sandbox");
289
+ app.commandLine.appendSwitch("disable-gpu-sandbox");
290
+ }
272
291
  app.setAppUserModelId("com.virtengine.bosun");
292
+ const iconPath = resolveBosunRuntimePath("logo.png");
293
+ if (existsSync(iconPath)) {
294
+ try {
295
+ app.setIcon(iconPath);
296
+ } catch {
297
+ /* best effort */
298
+ }
299
+ }
273
300
  process.chdir(resolveBosunRoot());
274
301
  await loadRuntimeConfig();
302
+
303
+ // Bypass TLS verification for the local embedded UI server.
304
+ // setCertificateVerifyProc works at the OpenSSL level — it fires before
305
+ // the higher-level `certificate-error` event and stops the repeated
306
+ // "handshake failed" logs from Chromium's ssl_client_socket_impl.
307
+ session.defaultSession.setCertificateVerifyProc((request, callback) => {
308
+ if (isLocalHost(request.hostname)) {
309
+ callback(0); // 0 = verified OK
310
+ return;
311
+ }
312
+ callback(-3); // -3 = use Chromium default chain verification
313
+ });
314
+
275
315
  await ensureDaemonRunning();
276
316
  await createMainWindow();
277
317
  await maybeAutoUpdate();
@@ -331,10 +371,15 @@ app.on("before-quit", () => {
331
371
  app.on(
332
372
  "certificate-error",
333
373
  (event, _webContents, url, _error, _certificate, callback) => {
334
- if (uiOrigin && url.startsWith(uiOrigin)) {
335
- event.preventDefault();
336
- callback(true);
337
- return;
374
+ try {
375
+ const hostname = new URL(url).hostname;
376
+ if ((uiOrigin && url.startsWith(uiOrigin)) || isLocalHost(hostname)) {
377
+ event.preventDefault();
378
+ callback(true);
379
+ return;
380
+ }
381
+ } catch {
382
+ // malformed URL — fall through
338
383
  }
339
384
  callback(false);
340
385
  },
@@ -3,6 +3,14 @@
3
3
  "version": "0.1.0",
4
4
  "private": true,
5
5
  "type": "module",
6
+ "author": "VirtEngine Maintainers <hello@virtengine.com>",
7
+ "homepage": "https://bosun.virtengine.com",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/virtengine/bosun.git",
11
+ "directory": "/desktop"
12
+ },
13
+
6
14
  "main": "main.mjs",
7
15
  "description": "Electron wrapper for the Bosun desktop portal",
8
16
  "scripts": {
@@ -9,7 +9,7 @@
9
9
  * - Linux: .desktop entry
10
10
  */
11
11
 
12
- import { spawnSync } from "node:child_process";
12
+ import { spawnSync, execSync } from "node:child_process";
13
13
  import {
14
14
  existsSync,
15
15
  readFileSync,
@@ -182,22 +182,41 @@ function installMacShortcut(desktopDir) {
182
182
 
183
183
  function installLinuxShortcut(desktopDir) {
184
184
  const desktopPath = resolve(desktopDir, `${APP_NAME}.desktop`);
185
+ const appDir = resolve(homedir(), ".local", "share", "applications");
186
+ const appPath = resolve(appDir, `${APP_NAME}.desktop`);
187
+ const iconPath = resolve(__dirname, "logo.png");
185
188
  const content = [
186
189
  "[Desktop Entry]",
187
190
  "Type=Application",
188
191
  `Name=${APP_NAME}`,
189
192
  "Comment=Bosun Desktop Portal",
193
+ `Icon=${iconPath}`,
190
194
  `Exec=${buildShellCommand()}`,
191
195
  `Path=${getWorkingDirectory()}`,
192
196
  "Terminal=false",
193
197
  "StartupNotify=true",
194
198
  "Categories=Development;Utility;",
199
+ "NoDisplay=false",
195
200
  "",
196
201
  ].join("\n");
197
202
 
198
203
  try {
199
204
  writeFileSync(desktopPath, content, "utf8");
200
205
  chmodSync(desktopPath, 0o755);
206
+ mkdirSync(appDir, { recursive: true });
207
+ writeFileSync(appPath, content, "utf8");
208
+ chmodSync(appPath, 0o755);
209
+
210
+ try {
211
+ execSync(`gio set "${desktopPath}" metadata::trusted true`, {
212
+ stdio: "ignore",
213
+ });
214
+ execSync(`gio set "${appPath}" metadata::trusted true`, {
215
+ stdio: "ignore",
216
+ });
217
+ } catch {
218
+ /* best effort */
219
+ }
201
220
  return {
202
221
  success: true,
203
222
  method: "Linux desktop entry",
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,
@@ -7555,12 +7556,21 @@ function buildPlannerTaskDescription({
7555
7556
  reason,
7556
7557
  numTasks,
7557
7558
  runtimeContext,
7559
+ userPrompt,
7558
7560
  }) {
7559
7561
  return [
7560
7562
  "## Task Planner — Auto-created by bosun",
7561
7563
  "",
7562
7564
  `**Trigger reason:** ${reason || "manual"}`,
7563
7565
  `**Requested task count:** ${numTasks}`,
7566
+ ...(userPrompt
7567
+ ? [
7568
+ "",
7569
+ "### User Planning Prompt",
7570
+ "",
7571
+ userPrompt,
7572
+ ]
7573
+ : []),
7564
7574
  "",
7565
7575
  "### Planner Prompt (Injected by bosun)",
7566
7576
  "",
@@ -8689,6 +8699,7 @@ async function triggerTaskPlanner(
8689
8699
  details,
8690
8700
  {
8691
8701
  taskCount,
8702
+ userPrompt,
8692
8703
  notify = true,
8693
8704
  preferredMode,
8694
8705
  allowCodexWhenDisabled = false,
@@ -8724,6 +8735,7 @@ async function triggerTaskPlanner(
8724
8735
  try {
8725
8736
  result = await triggerTaskPlannerViaKanban(reason, details, {
8726
8737
  taskCount,
8738
+ userPrompt,
8727
8739
  notify,
8728
8740
  });
8729
8741
  } catch (kanbanErr) {
@@ -8753,6 +8765,7 @@ async function triggerTaskPlanner(
8753
8765
  }
8754
8766
  result = await triggerTaskPlannerViaCodex(reason, details, {
8755
8767
  taskCount,
8768
+ userPrompt,
8756
8769
  notify,
8757
8770
  allowWhenDisabled: allowCodexWhenDisabled,
8758
8771
  });
@@ -8761,6 +8774,7 @@ async function triggerTaskPlanner(
8761
8774
  try {
8762
8775
  result = await triggerTaskPlannerViaCodex(reason, details, {
8763
8776
  taskCount,
8777
+ userPrompt,
8764
8778
  notify,
8765
8779
  allowWhenDisabled: allowCodexWhenDisabled,
8766
8780
  });
@@ -8784,6 +8798,7 @@ async function triggerTaskPlanner(
8784
8798
 
8785
8799
  result = await triggerTaskPlannerViaKanban(reason, details, {
8786
8800
  taskCount,
8801
+ userPrompt,
8787
8802
  notify,
8788
8803
  });
8789
8804
  }
@@ -8818,7 +8833,7 @@ async function triggerTaskPlanner(
8818
8833
  async function triggerTaskPlannerViaKanban(
8819
8834
  reason,
8820
8835
  details,
8821
- { taskCount, notify = true } = {},
8836
+ { taskCount, userPrompt, notify = true } = {},
8822
8837
  ) {
8823
8838
  const defaultPlannerTaskCount = Number(
8824
8839
  process.env.TASK_PLANNER_DEFAULT_COUNT || "30",
@@ -8844,12 +8859,15 @@ async function triggerTaskPlannerViaKanban(
8844
8859
  );
8845
8860
  }
8846
8861
 
8847
- const desiredTitle = `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8862
+ const desiredTitle = userPrompt
8863
+ ? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
8864
+ : `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"})`;
8848
8865
  const desiredDescription = buildPlannerTaskDescription({
8849
8866
  plannerPrompt,
8850
8867
  reason,
8851
8868
  numTasks,
8852
8869
  runtimeContext,
8870
+ userPrompt,
8853
8871
  });
8854
8872
 
8855
8873
  // Check for existing planner tasks to avoid duplicates
@@ -8941,7 +8959,7 @@ async function triggerTaskPlannerViaKanban(
8941
8959
  async function triggerTaskPlannerViaCodex(
8942
8960
  reason,
8943
8961
  details,
8944
- { taskCount, notify = true, allowWhenDisabled = false } = {},
8962
+ { taskCount, userPrompt, notify = true, allowWhenDisabled = false } = {},
8945
8963
  ) {
8946
8964
  if (!codexEnabled && !allowWhenDisabled) {
8947
8965
  throw new Error(
@@ -8973,6 +8991,17 @@ async function triggerTaskPlannerViaCodex(
8973
8991
  "## Execution Context",
8974
8992
  `- Trigger reason: ${reason || "manual"}`,
8975
8993
  `- Requested task count: ${numTasks}`,
8994
+ ...(userPrompt
8995
+ ? [
8996
+ "",
8997
+ "## User Planning Prompt",
8998
+ "",
8999
+ userPrompt,
9000
+ "",
9001
+ "Incorporate the above user prompt into any relevant planning decisions.",
9002
+ ]
9003
+ : []),
9004
+ "",
8976
9005
  "Context JSON:",
8977
9006
  "```json",
8978
9007
  safeJsonBlock(runtimeContext),
@@ -10590,21 +10619,16 @@ async function startProcess() {
10590
10619
  let orchestratorArgs = [...scriptArgs];
10591
10620
 
10592
10621
  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";
10622
+ const pwshRuntime = resolvePwshRuntime({ preferBundled: true });
10623
+ if (!pwshRuntime.exists) {
10624
+ const pwshLabel =
10625
+ pwshRuntime.source === "env"
10626
+ ? `PWSH_PATH (${pwshRuntime.command})`
10627
+ : pwshRuntime.source === "bundled"
10628
+ ? `bundled pwsh (${pwshRuntime.command})`
10629
+ : pwshRuntime.source === "powershell"
10630
+ ? `powershell on PATH`
10631
+ : "pwsh on PATH";
10608
10632
  const pauseMs = Math.max(orchestratorPauseMs, 60_000);
10609
10633
  const pauseMin = Math.max(1, Math.round(pauseMs / 60_000));
10610
10634
  monitorSafeModeUntil = Math.max(monitorSafeModeUntil, Date.now() + pauseMs);
@@ -10622,7 +10646,7 @@ async function startProcess() {
10622
10646
  setTimeout(startProcess, pauseMs);
10623
10647
  return;
10624
10648
  }
10625
- orchestratorCmd = pwshCmd;
10649
+ orchestratorCmd = pwshRuntime.command;
10626
10650
  orchestratorArgs = ["-File", scriptPath, ...scriptArgs];
10627
10651
  } else if (scriptLower.endsWith(".sh")) {
10628
10652
  const shellCmd =
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.1",
3
+ "version": "0.28.3",
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",
7
- "author": "VirtEngine Maintainers <maintainers@virtengine.com>",
7
+ "author": "VirtEngine Maintainers <hello@virtengine.com>",
8
8
  "homepage": "https://bosun.virtengine.com",
9
9
  "repository": {
10
10
  "type": "git",
@@ -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
  ),
@@ -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 };
@@ -15,11 +15,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
15
15
  LOG_DIR="$REPO_ROOT/.cache/agent-work-logs"
16
16
  ARCHIVE_DIR="$LOG_DIR/archive"
17
17
 
18
- # Retention periods
19
- STREAM_RETENTION_DAYS=30
20
- ERROR_RETENTION_DAYS=90
21
- SESSION_RETENTION_COUNT=100
22
- ARCHIVE_RETENTION_DAYS=180
18
+ # Retention periods (override via env)
19
+ STREAM_RETENTION_DAYS="${AGENT_WORK_STREAM_RETENTION_DAYS:-30}"
20
+ ERROR_RETENTION_DAYS="${AGENT_WORK_ERROR_RETENTION_DAYS:-90}"
21
+ SESSION_RETENTION_COUNT="${AGENT_WORK_SESSION_RETENTION_COUNT:-100}"
22
+ ARCHIVE_RETENTION_DAYS="${AGENT_WORK_ARCHIVE_RETENTION_DAYS:-180}"
23
+ METRICS_ROTATION_ENABLED="${AGENT_WORK_METRICS_ROTATION_ENABLED:-true}"
23
24
 
24
25
  # ── Functions ───────────────────────────────────────────────────────────────
25
26
 
@@ -111,10 +112,10 @@ if [ -f "$LOG_DIR/agent-alerts.jsonl" ]; then
111
112
  rotate_file "$LOG_DIR/agent-alerts.jsonl" "$ALERTS_ARCHIVE" "$STREAM_RETENTION_DAYS"
112
113
  fi
113
114
 
114
- # Metrics log is kept indefinitely (compressed monthly)
115
+ # Metrics log is kept indefinitely (compressed monthly unless disabled)
115
116
  if [ -f "$LOG_DIR/agent-metrics.jsonl" ]; then
116
117
  # Only rotate on first day of month
117
- if [ "$(date +%d)" = "01" ]; then
118
+ if [ "$METRICS_ROTATION_ENABLED" != "false" ] && [ "$(date +%d)" = "01" ]; then
118
119
  METRICS_ARCHIVE="agent-metrics-$(date -d 'last month' +%Y%m).jsonl.gz"
119
120
  rotate_file "$LOG_DIR/agent-metrics.jsonl" "$METRICS_ARCHIVE" ""
120
121
  fi