bosun 0.28.1 → 0.28.2

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-banner.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/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.28.1",
3
+ "version": "0.28.2",
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",
@@ -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
package/setup.mjs CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  } from "./hook-profiles.mjs";
47
47
  import { detectLegacySetup, applyAllCompatibility } from "./compat.mjs";
48
48
  import { DEFAULT_MODEL_PROFILES } from "./task-complexity.mjs";
49
+ import { pullWorkspaceRepos, listWorkspaces } from "./workspace-manager.mjs";
49
50
 
50
51
  const __dirname = dirname(fileURLToPath(import.meta.url));
51
52
 
@@ -729,6 +730,61 @@ function printExecutorModelReference() {
729
730
  console.log();
730
731
  }
731
732
 
733
+ function buildRepositoryChoices(configJson, repoRoot) {
734
+ const choices = [];
735
+ const seen = new Set();
736
+
737
+ const pushChoice = (input) => {
738
+ if (!input) return;
739
+ const name = String(input.name || "").trim();
740
+ const slug = String(input.slug || "").trim();
741
+ const workspace = String(input.workspace || input.workspaceId || "").trim();
742
+ if (!name && !slug) return;
743
+ const key = slug || name;
744
+ if (seen.has(`${workspace}:${key}`)) return;
745
+ seen.add(`${workspace}:${key}`);
746
+ const labelParts = [];
747
+ if (workspace) labelParts.push(`ws:${workspace}`);
748
+ labelParts.push(name || slug);
749
+ if (slug && name && slug !== name) labelParts.push(`(${slug})`);
750
+ const label = labelParts.join(" ");
751
+ choices.push({
752
+ label,
753
+ name,
754
+ slug,
755
+ workspace,
756
+ value: key,
757
+ });
758
+ };
759
+
760
+ if (Array.isArray(configJson?.workspaces)) {
761
+ for (const ws of configJson.workspaces) {
762
+ const wsId = String(ws?.id || "").trim();
763
+ const wsName = String(ws?.name || wsId || "").trim();
764
+ const wsLabel = wsName || wsId;
765
+ for (const repo of ws?.repos || []) {
766
+ pushChoice({
767
+ name: repo?.name,
768
+ slug: repo?.slug,
769
+ workspace: wsLabel,
770
+ });
771
+ }
772
+ }
773
+ }
774
+
775
+ if (Array.isArray(configJson?.repositories)) {
776
+ for (const repo of configJson.repositories) {
777
+ pushChoice(repo);
778
+ }
779
+ }
780
+
781
+ if (choices.length === 0 && repoRoot) {
782
+ pushChoice({ name: basename(repoRoot) });
783
+ }
784
+
785
+ return choices;
786
+ }
787
+
732
788
  function defaultVariantForExecutor(executor) {
733
789
  const normalized = String(executor || "").trim().toUpperCase();
734
790
  if (normalized === "CODEX") return "DEFAULT";
@@ -2113,6 +2169,7 @@ async function main() {
2113
2169
 
2114
2170
  const prompt = createPrompt();
2115
2171
  let aborted = false;
2172
+ let cloneWorkspacesAfterSetup = false;
2116
2173
 
2117
2174
  try {
2118
2175
  // ── Step 2: Setup Mode + Project Identity ─────────────
@@ -2220,6 +2277,11 @@ async function main() {
2220
2277
  if (configJson.workspaces.length > 0) {
2221
2278
  configJson.activeWorkspace = configJson.workspaces[0].id;
2222
2279
  }
2280
+
2281
+ cloneWorkspacesAfterSetup = await prompt.confirm(
2282
+ "Clone/pull workspace repos now (recommended)?",
2283
+ true,
2284
+ );
2223
2285
  } else {
2224
2286
  // Single-repo mode (classic) — still works as before
2225
2287
  const multiRepo = isAdvancedSetup
@@ -2725,6 +2787,52 @@ async function main() {
2725
2787
 
2726
2788
  // ── Step 7: Kanban + Execution ─────────────────────────
2727
2789
  headingStep(7, "Kanban & Execution", markSetupProgress);
2790
+ const repoChoices = buildRepositoryChoices(configJson, repoRoot);
2791
+ let selectedRepoChoice = repoChoices[0] || null;
2792
+ if (repoChoices.length > 1) {
2793
+ console.log(
2794
+ chalk.dim(
2795
+ " Multiple repositories detected. Select which repo this bosun instance should manage tasks for.",
2796
+ ),
2797
+ );
2798
+ const repoLabels = repoChoices.map((choice) => choice.label);
2799
+ repoLabels.push("Decide later (skip)");
2800
+ const defaultRepoIdx = (() => {
2801
+ const slugDefault =
2802
+ process.env.GITHUB_REPOSITORY || env.GITHUB_REPO || "";
2803
+ if (slugDefault) {
2804
+ const matchIdx = repoChoices.findIndex(
2805
+ (choice) => choice.slug === slugDefault,
2806
+ );
2807
+ if (matchIdx >= 0) return matchIdx;
2808
+ }
2809
+ const primaryIdx = repoChoices.findIndex(
2810
+ (choice) => choice.slug && choice.slug === configJson?.repositories?.find((repo) => repo.primary)?.slug,
2811
+ );
2812
+ return primaryIdx >= 0 ? primaryIdx : 0;
2813
+ })();
2814
+ const selectedIdx = await prompt.choose(
2815
+ "Primary repo for task board",
2816
+ repoLabels,
2817
+ Math.min(defaultRepoIdx, repoChoices.length - 1),
2818
+ );
2819
+ if (selectedIdx >= 0 && selectedIdx < repoChoices.length) {
2820
+ selectedRepoChoice = repoChoices[selectedIdx];
2821
+ if (selectedRepoChoice?.value) {
2822
+ configJson.defaultRepository = selectedRepoChoice.value;
2823
+ }
2824
+ } else {
2825
+ selectedRepoChoice = null;
2826
+ }
2827
+ console.log();
2828
+ info(
2829
+ "If you need different task backends per repo, run separate bosun instances with different configs.",
2830
+ );
2831
+ console.log();
2832
+ } else if (selectedRepoChoice?.value) {
2833
+ configJson.defaultRepository = selectedRepoChoice.value;
2834
+ }
2835
+
2728
2836
  const backendDefault = String(
2729
2837
  process.env.KANBAN_BACKEND || configJson.kanban?.backend || "internal",
2730
2838
  )
@@ -2918,7 +3026,9 @@ async function main() {
2918
3026
 
2919
3027
  if (selectedKanbanBackend === "github") {
2920
3028
  const primaryRepoSlug =
2921
- configJson.repositories?.find((repo) => repo.primary && repo.slug)?.slug || "";
3029
+ selectedRepoChoice?.slug ||
3030
+ configJson.repositories?.find((repo) => repo.primary && repo.slug)?.slug ||
3031
+ "";
2922
3032
  const repoSlugDefaults = [
2923
3033
  process.env.GITHUB_REPOSITORY,
2924
3034
  process.env.GITHUB_REPO,
@@ -2933,10 +3043,35 @@ async function main() {
2933
3043
  info(
2934
3044
  "Pick the repo that should receive tasks (issues/projects). If you have multiple orgs, use the owner for that repo.",
2935
3045
  );
2936
- const repoInput = await prompt.ask(
2937
- "GitHub repository for tasks (owner/repo or URL)",
2938
- repoSlugDefault,
2939
- );
3046
+ let repoInput = repoSlugDefault;
3047
+ if (repoChoices.length > 1) {
3048
+ const repoLabels = repoChoices.map((choice) => choice.label);
3049
+ repoLabels.push("Enter manually");
3050
+ const repoIdx = await prompt.choose(
3051
+ "Select GitHub repo for tasks",
3052
+ repoLabels,
3053
+ repoChoices.findIndex(
3054
+ (choice) => choice.slug && choice.slug === repoSlugDefault,
3055
+ ) >= 0
3056
+ ? repoChoices.findIndex(
3057
+ (choice) => choice.slug && choice.slug === repoSlugDefault,
3058
+ )
3059
+ : 0,
3060
+ );
3061
+ if (repoIdx >= 0 && repoIdx < repoChoices.length) {
3062
+ repoInput = repoChoices[repoIdx]?.slug || repoChoices[repoIdx]?.name || repoSlugDefault;
3063
+ } else {
3064
+ repoInput = await prompt.ask(
3065
+ "GitHub repository for tasks (owner/repo or URL)",
3066
+ repoSlugDefault,
3067
+ );
3068
+ }
3069
+ } else {
3070
+ repoInput = await prompt.ask(
3071
+ "GitHub repository for tasks (owner/repo or URL)",
3072
+ repoSlugDefault,
3073
+ );
3074
+ }
2940
3075
  const parsedRepoSlug = parseRepoSlugFromUrl(repoInput || repoSlugDefault);
2941
3076
  if (parsedRepoSlug) {
2942
3077
  const [repoOwner, repoName] = parsedRepoSlug.split("/", 2);
@@ -4153,6 +4288,28 @@ async function main() {
4153
4288
  normalizeSetupConfiguration({ env, configJson, repoRoot, slug, configDir });
4154
4289
  await writeConfigFiles({ env, configJson, repoRoot, configDir });
4155
4290
  clearSetupProgress(configDir);
4291
+
4292
+ if (cloneWorkspacesAfterSetup && Array.isArray(configJson.workspaces) && configJson.workspaces.length > 0) {
4293
+ heading("Cloning Workspace Repos");
4294
+ for (const ws of configJson.workspaces) {
4295
+ const wsId = ws?.id;
4296
+ if (!wsId) continue;
4297
+ try {
4298
+ const results = pullWorkspaceRepos(configDir, wsId);
4299
+ for (const result of results) {
4300
+ if (result.success) {
4301
+ success(`Workspace ${wsId}: ${result.name} ready`);
4302
+ } else {
4303
+ warn(
4304
+ `Workspace ${wsId}: ${result.name} ${result.error ? `— ${result.error}` : "failed"}`,
4305
+ );
4306
+ }
4307
+ }
4308
+ } catch (err) {
4309
+ warn(`Workspace ${wsId}: clone/pull failed — ${err.message || err}`);
4310
+ }
4311
+ }
4312
+ }
4156
4313
  }
4157
4314
 
4158
4315
  // ── Non-Interactive Mode ─────────────────────────────────────────────────────
package/task-executor.mjs CHANGED
@@ -536,6 +536,11 @@ const agentWorkSessionStarts = new Map();
536
536
  const attemptTelemetry = new Map();
537
537
  const anomalyAbortTargets = new Map();
538
538
  const internalAnomalyEnabled = process.env.BOSUN_INTERNAL_ANOMALY !== "false";
539
+ const TELEMETRY_SAMPLE_RATE = (() => {
540
+ const raw = Number(process.env.BOSUN_TELEMETRY_SAMPLE_RATE || "1");
541
+ if (!Number.isFinite(raw)) return 1;
542
+ return Math.min(1, Math.max(0, raw));
543
+ })();
539
544
  const anomalyDetector = internalAnomalyEnabled
540
545
  ? createAnomalyDetector({
541
546
  onAnomaly: (anomaly) => {
@@ -581,6 +586,14 @@ function ensureAgentWorkDirs() {
581
586
  }
582
587
 
583
588
  function writeAgentWorkEvent(entry) {
589
+ const eventType = String(entry?.event_type || "");
590
+ const shouldSample =
591
+ TELEMETRY_SAMPLE_RATE < 1 &&
592
+ ["agent_output", "tool_call", "tool_result", "usage"].includes(eventType);
593
+ if (shouldSample && Math.random() > TELEMETRY_SAMPLE_RATE) {
594
+ return;
595
+ }
596
+
584
597
  ensureAgentWorkDirs();
585
598
  const line = JSON.stringify(entry);
586
599
  appendFileSync(AGENT_WORK_STREAM, `${line}\n`, "utf8");
@@ -3897,9 +3910,6 @@ class TaskExecutor {
3897
3910
  taskMeta,
3898
3911
  gitContext,
3899
3912
  metrics: {
3900
- tool_calls: Array.isArray(validatedResult?.items)
3901
- ? validatedResult.items.length
3902
- : undefined,
3903
3913
  success: !!validatedResult?.success,
3904
3914
  retry_count: Math.max(0, (validatedResult?.attempts || 1) - 1),
3905
3915
  attempts: validatedResult?.attempts || 1,
package/telegram-bot.mjs CHANGED
@@ -132,6 +132,18 @@ const statusBoardStatePath = resolve(
132
132
  const fwCooldownPath = resolve(repoRoot, ".cache", "ve-fw-cooldown.json");
133
133
  const FW_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
134
134
 
135
+ // ── Message History Auto-Cleanup ──────────────────────────────────────────
136
+ const msgHistoryPath = resolve(repoRoot, ".cache", "ve-message-history.json");
137
+ // Days to keep bot messages in chat. 0 = disabled. Default: 3 days.
138
+ const HISTORY_RETENTION_DAYS = (() => {
139
+ const v = Number(process.env.TELEGRAM_HISTORY_RETENTION_DAYS ?? "3");
140
+ return Number.isFinite(v) && v > 0 ? v : 0;
141
+ })();
142
+ const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
143
+ const HISTORY_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // run every 6 hours
144
+ const HISTORY_INITIAL_DELAY_MS = 2 * 60 * 1000; // wait 2 min after boot
145
+ const HISTORY_MAX_TRACKED = 10_000; // safety cap
146
+
135
147
  function resolveVeKanbanPs1Path() {
136
148
  const modulePath = resolve(BosunDir, "ve-kanban.ps1");
137
149
  if (existsSync(modulePath)) return modulePath;
@@ -1117,6 +1129,7 @@ async function sendDirect(chatId, text, options = {}) {
1117
1129
  const data = await res.json();
1118
1130
  if (data.ok && data.result?.message_id) {
1119
1131
  lastMessageId = data.result.message_id;
1132
+ recordSentMessage(chatId, lastMessageId);
1120
1133
  }
1121
1134
  } catch (err) {
1122
1135
  console.warn(`[telegram-bot] send JSON parse error: ${err.message}`);
@@ -1215,6 +1228,85 @@ async function deleteDirect(chatId, messageId) {
1215
1228
  }
1216
1229
  }
1217
1230
 
1231
+ // ── Message History Helpers ───────────────────────────────────────────────────
1232
+
1233
+ /** Lazy-loaded in-memory list of sent message records. */
1234
+ let _msgHistory = null;
1235
+ let _msgHistoryDirty = false;
1236
+
1237
+ function _loadMsgHistory() {
1238
+ if (_msgHistory !== null) return;
1239
+ try {
1240
+ const raw = readFileSync(msgHistoryPath, "utf8");
1241
+ const data = JSON.parse(raw);
1242
+ _msgHistory = Array.isArray(data.messages) ? data.messages : [];
1243
+ } catch {
1244
+ _msgHistory = [];
1245
+ }
1246
+ }
1247
+
1248
+ function _saveMsgHistory() {
1249
+ if (!_msgHistory) return;
1250
+ try {
1251
+ mkdirSync(resolve(repoRoot, ".cache"), { recursive: true });
1252
+ writeFileSync(
1253
+ msgHistoryPath,
1254
+ JSON.stringify({ messages: _msgHistory }, null, 2),
1255
+ "utf8",
1256
+ );
1257
+ _msgHistoryDirty = false;
1258
+ } catch {
1259
+ /* best effort */
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Record a newly sent message ID so it can be cleaned up later.
1265
+ * No-op when HISTORY_RETENTION_MS is 0 (feature disabled).
1266
+ */
1267
+ function recordSentMessage(chatId, messageId) {
1268
+ if (!HISTORY_RETENTION_MS || !messageId) return;
1269
+ _loadMsgHistory();
1270
+ _msgHistory.push({ chat_id: chatId, message_id: messageId, sent_at: Date.now() });
1271
+ if (_msgHistory.length > HISTORY_MAX_TRACKED) {
1272
+ _msgHistory = _msgHistory.slice(-HISTORY_MAX_TRACKED);
1273
+ }
1274
+ _msgHistoryDirty = true;
1275
+ }
1276
+
1277
+ /**
1278
+ * Delete all tracked bot messages older than HISTORY_RETENTION_MS.
1279
+ * Failures are silently swallowed — Telegram may refuse deletes for messages
1280
+ * older than 48 h in private chats; we just skip those gracefully.
1281
+ */
1282
+ async function pruneMessageHistory() {
1283
+ if (!HISTORY_RETENTION_MS) return;
1284
+ _loadMsgHistory();
1285
+ if (_msgHistoryDirty) _saveMsgHistory();
1286
+
1287
+ const cutoff = Date.now() - HISTORY_RETENTION_MS;
1288
+ const toDelete = _msgHistory.filter((m) => m.sent_at < cutoff);
1289
+ if (toDelete.length === 0) return;
1290
+
1291
+ console.log(
1292
+ `[telegram-bot] auto-cleanup: deleting ${toDelete.length} message(s) older than ${HISTORY_RETENTION_DAYS}d`,
1293
+ );
1294
+
1295
+ let i = 0;
1296
+ for (const m of toDelete) {
1297
+ await deleteDirect(m.chat_id, m.message_id);
1298
+ i++;
1299
+ // Pace at ~20 deletes/s to stay well under Telegram’s 30 msg/s limit
1300
+ if (i % 20 === 0) await new Promise((r) => setTimeout(r, 1000));
1301
+ }
1302
+
1303
+ _msgHistory = _msgHistory.filter((m) => m.sent_at >= cutoff);
1304
+ _saveMsgHistory();
1305
+ console.log(
1306
+ `[telegram-bot] auto-cleanup: done. ${_msgHistory.length} messages remaining in history.`,
1307
+ );
1308
+ }
1309
+
1218
1310
  /**
1219
1311
  * Answer a Telegram callback query (required to dismiss the "loading" indicator).
1220
1312
  * @param {string} callbackQueryId - The callback_query.id from the update
@@ -10378,6 +10470,32 @@ export async function startTelegramBot() {
10378
10470
  console.error(`[telegram-bot] fatal poll loop error: ${err.message}`);
10379
10471
  polling = false;
10380
10472
  });
10473
+
10474
+ // ── Message history auto-cleanup ──
10475
+ if (HISTORY_RETENTION_MS) {
10476
+ // Flush in-memory buffer to disk every 5 minutes
10477
+ const flushTimer = setInterval(() => {
10478
+ if (_msgHistoryDirty) _saveMsgHistory();
10479
+ }, 5 * 60 * 1000);
10480
+ flushTimer.unref();
10481
+
10482
+ // Run first prune after HISTORY_INITIAL_DELAY_MS (2 min), then every 6 h
10483
+ setTimeout(() => {
10484
+ pruneMessageHistory().catch((err) =>
10485
+ console.warn(`[telegram-bot] message history cleanup error: ${err.message}`),
10486
+ );
10487
+ const cleanupTimer = setInterval(
10488
+ () =>
10489
+ pruneMessageHistory().catch((err) =>
10490
+ console.warn(
10491
+ `[telegram-bot] message history cleanup error: ${err.message}`,
10492
+ ),
10493
+ ),
10494
+ HISTORY_CLEANUP_INTERVAL_MS,
10495
+ );
10496
+ cleanupTimer.unref();
10497
+ }, HISTORY_INITIAL_DELAY_MS);
10498
+ }
10381
10499
  }
10382
10500
 
10383
10501
  /**
package/ui/app.js CHANGED
@@ -82,6 +82,7 @@ import { AgentsTab } from "./tabs/agents.js";
82
82
  import { InfraTab } from "./tabs/infra.js";
83
83
  import { ControlTab } from "./tabs/control.js";
84
84
  import { LogsTab } from "./tabs/logs.js";
85
+ import { TelemetryTab } from "./tabs/telemetry.js";
85
86
  import { SettingsTab } from "./tabs/settings.js";
86
87
 
87
88
  /* ── Placeholder signals for connection quality (may be provided by api.js) ── */
@@ -203,6 +204,7 @@ const TAB_COMPONENTS = {
203
204
  infra: InfraTab,
204
205
  control: ControlTab,
205
206
  logs: LogsTab,
207
+ telemetry: TelemetryTab,
206
208
  settings: SettingsTab,
207
209
  };
208
210
 
package/ui/demo.html CHANGED
@@ -669,6 +669,14 @@
669
669
  return { data: STATE.status };
670
670
  if (route === '/api/executor')
671
671
  return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors } };
672
+ if (route === '/api/telemetry/summary')
673
+ return { data: { status: 'ok', updatedAt: Date.now(), totals: { tasks: STATE.tasks.length, executors: STATE.executors.length } } };
674
+ if (route === '/api/telemetry/errors')
675
+ return { data: [] };
676
+ if (route === '/api/telemetry/executors')
677
+ return { data: STATE.executors.map((e) => ({ ...e, status: e.enabled ? 'active' : 'disabled' })) };
678
+ if (route === '/api/telemetry/alerts')
679
+ return { data: [] };
672
680
  if (route === '/api/executor/pause') {
673
681
  STATE.paused = true; addLog('info', 'executor', 'Executor paused');
674
682
  return { ok: true, paused: true };
@@ -88,6 +88,20 @@ export const ICONS = {
88
88
  <circle cx="19" cy="12" r="1.6" />
89
89
  </svg>`,
90
90
 
91
+ chart: html`<svg
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ stroke-width="2"
96
+ stroke-linecap="round"
97
+ stroke-linejoin="round"
98
+ >
99
+ <line x1="4" y1="19" x2="20" y2="19" />
100
+ <rect x="6" y="10" width="3" height="9" />
101
+ <rect x="11" y="6" width="3" height="13" />
102
+ <rect x="16" y="13" width="3" height="6" />
103
+ </svg>`,
104
+
91
105
  /* ── Status / Feedback ── */
92
106
  check: html`<svg
93
107
  viewBox="0 0 24 24"
@@ -82,5 +82,6 @@ export const TAB_CONFIG = [
82
82
  { id: "chat", label: "Chat", icon: "chat" },
83
83
  { id: "infra", label: "Infra", icon: "server" },
84
84
  { id: "logs", label: "Logs", icon: "terminal" },
85
+ { id: "telemetry", label: "Telemetry", icon: "chart" },
85
86
  { id: "settings", label: "Settings", icon: "settings" },
86
87
  ];
@@ -53,6 +53,7 @@ export const SETTINGS_SCHEMA = [
53
53
  { key: "TELEGRAM_API_BASE_URL", label: "API Base URL", category: "telegram", type: "string", defaultVal: "https://api.telegram.org", description: "Override for Telegram API proxy.", advanced: true, validate: "^https?://" },
54
54
  { key: "TELEGRAM_HTTP_TIMEOUT_MS", label: "HTTP Timeout", category: "telegram", type: "number", defaultVal: 15000, min: 5000, max: 60000, unit: "ms", description: "Per-request timeout for Telegram API calls.", advanced: true },
55
55
  { key: "TELEGRAM_RETRY_ATTEMPTS", label: "Retry Attempts", category: "telegram", type: "number", defaultVal: 4, min: 0, max: 10, description: "Number of retry attempts for transient Telegram API failures.", advanced: true },
56
+ { key: "TELEGRAM_HISTORY_RETENTION_DAYS", label: "Auto-Delete History", category: "telegram", type: "number", defaultVal: 3, min: 0, max: 365, unit: "days", description: "Automatically delete bot messages older than this many days to keep the chat tidy. 0 = disabled. Note: Telegram may silently skip messages older than 48 h in private chats.", restart: false },
56
57
  { key: "PROJECT_NAME", label: "Project Name", category: "telegram", type: "string", description: "Display name used in Telegram messages and logs. Auto-detected from package.json if not set." },
57
58
 
58
59
  // ── Mini App / UI Server ──────────────────────────────────────
@@ -112,6 +113,14 @@ export const SETTINGS_SCHEMA = [
112
113
  { key: "TASK_PLANNER_DEDUP_HOURS", label: "Planner Dedup Window", category: "kanban", type: "number", defaultVal: 6, min: 1, max: 72, unit: "hours", description: "Hours to look back for duplicate task detection.", advanced: true },
113
114
  { key: "BOSUN_PROMPT_PLANNER", label: "Planner Prompt Path", category: "advanced", type: "string", description: "Override the task planner prompt file path.", advanced: true },
114
115
 
116
+ // ── Logging / Telemetry ──────────────────────────────────────
117
+ { key: "BOSUN_TELEMETRY_SAMPLE_RATE", label: "Telemetry Sample Rate", category: "logging", type: "number", defaultVal: 1, min: 0, max: 1, description: "Sampling rate for high-volume telemetry events (agent_output/tool events). 1 = full, 0.1 = 10% sample.", advanced: true },
118
+ { key: "AGENT_WORK_STREAM_RETENTION_DAYS", label: "Stream Retention", category: "logging", type: "number", defaultVal: 30, min: 1, max: 365, unit: "days", description: "How long to keep agent-work stream logs before rotation.", advanced: true },
119
+ { key: "AGENT_WORK_ERROR_RETENTION_DAYS", label: "Error Retention", category: "logging", type: "number", defaultVal: 90, min: 1, max: 365, unit: "days", description: "How long to keep error logs before rotation.", advanced: true },
120
+ { key: "AGENT_WORK_SESSION_RETENTION_COUNT", label: "Session Retention Count", category: "logging", type: "number", defaultVal: 100, min: 10, max: 10000, description: "Number of session log files to keep.", advanced: true },
121
+ { key: "AGENT_WORK_ARCHIVE_RETENTION_DAYS", label: "Archive Retention", category: "logging", type: "number", defaultVal: 180, min: 30, max: 3650, unit: "days", description: "Retention window for compressed log archives.", advanced: true },
122
+ { key: "AGENT_WORK_METRICS_ROTATION_ENABLED", label: "Rotate Metrics Log", category: "logging", type: "boolean", defaultVal: true, description: "Rotate metrics log monthly to keep file size bounded.", advanced: true },
123
+
115
124
  // ── GitHub / Git ─────────────────────────────────────────
116
125
  { key: "GITHUB_TOKEN", label: "GitHub Token", category: "github", type: "secret", sensitive: true, description: "Personal access token or fine-grained token for GitHub API. Required for GitHub kanban backend." },
117
126
  { key: "GITHUB_REPOSITORY", label: "Repository", category: "github", type: "string", description: "GitHub repository in owner/repo format. Auto-detected from git remote if not set.", validate: "^[\\w.-]+/[\\w.-]+$" },
@@ -43,6 +43,7 @@ const CACHE_TTL = {
43
43
  threads: 5000, logs: 15000, worktrees: 30000, workspaces: 30000,
44
44
  presence: 30000, config: 60000, projects: 60000, git: 20000,
45
45
  infra: 30000,
46
+ telemetry: 15000,
46
47
  };
47
48
 
48
49
  function _cacheKey(url) { return url; }
@@ -111,6 +112,12 @@ export const agentLogLines = signal(200);
111
112
  export const agentLogQuery = signal("");
112
113
  export const agentContext = signal(null);
113
114
 
115
+ // ── Telemetry
116
+ export const telemetrySummary = signal(null);
117
+ export const telemetryErrors = signal([]);
118
+ export const telemetryExecutors = signal({});
119
+ export const telemetryAlerts = signal([]);
120
+
114
121
  // ── Config (routing, regions, etc.)
115
122
  export const configData = signal(null);
116
123
 
@@ -590,6 +597,50 @@ export async function loadConfig() {
590
597
  _markFresh("config");
591
598
  }
592
599
 
600
+ export async function loadTelemetrySummary() {
601
+ const url = "/api/telemetry/summary";
602
+ if (_cacheFresh(url, "telemetry")) return;
603
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
604
+ ok: false,
605
+ }));
606
+ telemetrySummary.value = res?.data ?? res ?? null;
607
+ _cacheSet(url, telemetrySummary.value);
608
+ _markFresh("telemetry");
609
+ }
610
+
611
+ export async function loadTelemetryErrors() {
612
+ const url = "/api/telemetry/errors";
613
+ if (_cacheFresh(url, "telemetry")) return;
614
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
615
+ ok: false,
616
+ }));
617
+ telemetryErrors.value = res?.data ?? res ?? [];
618
+ _cacheSet(url, telemetryErrors.value);
619
+ _markFresh("telemetry");
620
+ }
621
+
622
+ export async function loadTelemetryExecutors() {
623
+ const url = "/api/telemetry/executors";
624
+ if (_cacheFresh(url, "telemetry")) return;
625
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
626
+ ok: false,
627
+ }));
628
+ telemetryExecutors.value = res?.data ?? res ?? {};
629
+ _cacheSet(url, telemetryExecutors.value);
630
+ _markFresh("telemetry");
631
+ }
632
+
633
+ export async function loadTelemetryAlerts() {
634
+ const url = "/api/telemetry/alerts";
635
+ if (_cacheFresh(url, "telemetry")) return;
636
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({
637
+ ok: false,
638
+ }));
639
+ telemetryAlerts.value = res?.data ?? res ?? [];
640
+ _cacheSet(url, telemetryAlerts.value);
641
+ _markFresh("telemetry");
642
+ }
643
+
593
644
  /* ═══════════════════════════════════════════════════════════════
594
645
  * TAB REFRESH — map tab names to their required loaders
595
646
  * ═══════════════════════════════════════════════════════════════ */
@@ -609,6 +660,13 @@ const TAB_LOADERS = {
609
660
  control: () => Promise.all([loadExecutor(), loadConfig()]),
610
661
  logs: () =>
611
662
  Promise.all([loadLogs(), loadAgentLogFileList(), loadAgentLogTailData()]),
663
+ telemetry: () =>
664
+ Promise.all([
665
+ loadTelemetrySummary(),
666
+ loadTelemetryErrors(),
667
+ loadTelemetryExecutors(),
668
+ loadTelemetryAlerts(),
669
+ ]),
612
670
  settings: () => Promise.all([loadStatus(), loadConfig()]),
613
671
  };
614
672
 
@@ -687,6 +745,7 @@ const WS_CHANNEL_MAP = {
687
745
  infra: ["worktrees", "workspaces", "presence"],
688
746
  control: ["executor", "overview"],
689
747
  logs: ["*"],
748
+ telemetry: ["*"],
690
749
  settings: ["overview"],
691
750
  };
692
751
 
package/ui/styles.css CHANGED
@@ -139,3 +139,91 @@
139
139
  font-size: 12px;
140
140
  text-align: center;
141
141
  }
142
+
143
+ /* ─── Telemetry Tab ─── */
144
+ .telemetry-tab {
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 18px;
148
+ }
149
+
150
+ .telemetry-tab .section-header {
151
+ display: flex;
152
+ align-items: center;
153
+ justify-content: space-between;
154
+ gap: 12px;
155
+ }
156
+
157
+ .telemetry-summary .metric-grid {
158
+ display: grid;
159
+ gap: 14px;
160
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
161
+ }
162
+
163
+ .metric-label {
164
+ font-size: 11px;
165
+ text-transform: uppercase;
166
+ letter-spacing: 0.08em;
167
+ color: var(--text-hint);
168
+ }
169
+
170
+ .metric-value {
171
+ font-size: 20px;
172
+ font-weight: 600;
173
+ color: var(--text-primary);
174
+ }
175
+
176
+ .telemetry-grid {
177
+ display: grid;
178
+ gap: 16px;
179
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
180
+ }
181
+
182
+ .telemetry-list {
183
+ list-style: none;
184
+ padding: 0;
185
+ margin: 0;
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 10px;
189
+ }
190
+
191
+ .telemetry-list li {
192
+ display: flex;
193
+ align-items: center;
194
+ justify-content: space-between;
195
+ gap: 12px;
196
+ font-size: 13px;
197
+ }
198
+
199
+ .telemetry-label {
200
+ color: var(--text-secondary);
201
+ }
202
+
203
+ .telemetry-count {
204
+ font-weight: 600;
205
+ color: var(--text-primary);
206
+ }
207
+
208
+ .telemetry-alerts {
209
+ list-style: none;
210
+ padding: 0;
211
+ margin: 0;
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 12px;
215
+ }
216
+
217
+ .telemetry-alert-title {
218
+ display: flex;
219
+ align-items: center;
220
+ gap: 8px;
221
+ font-weight: 600;
222
+ font-size: 13px;
223
+ }
224
+
225
+ .telemetry-alert-meta {
226
+ font-size: 12px;
227
+ color: var(--text-hint);
228
+ margin-top: 4px;
229
+ }
@@ -0,0 +1,167 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Telemetry — analytics, quality signals, alerts
3
+ * ────────────────────────────────────────────────────────────── */
4
+ import { h } from "preact";
5
+ import { useMemo } from "preact/hooks";
6
+ import htm from "htm";
7
+
8
+ const html = htm.bind(h);
9
+
10
+ import {
11
+ telemetrySummary,
12
+ telemetryErrors,
13
+ telemetryExecutors,
14
+ telemetryAlerts,
15
+ loadTelemetrySummary,
16
+ loadTelemetryErrors,
17
+ loadTelemetryExecutors,
18
+ loadTelemetryAlerts,
19
+ scheduleRefresh,
20
+ } from "../modules/state.js";
21
+ import { Card, EmptyState, SkeletonCard, Badge } from "../components/shared.js";
22
+
23
+ function formatCount(value) {
24
+ if (value == null) return "–";
25
+ return String(value);
26
+ }
27
+
28
+ function formatSeconds(value) {
29
+ if (!value && value !== 0) return "–";
30
+ if (value >= 60) return `${Math.round(value / 60)}m`;
31
+ return `${value}s`;
32
+ }
33
+
34
+ function severityBadge(sev = "medium") {
35
+ const normalized = String(sev).toLowerCase();
36
+ if (normalized === "high" || normalized === "critical") return "danger";
37
+ if (normalized === "medium") return "warning";
38
+ return "info";
39
+ }
40
+
41
+ export function TelemetryTab() {
42
+ const summary = telemetrySummary.value;
43
+ const errors = telemetryErrors.value || [];
44
+ const executors = telemetryExecutors.value || {};
45
+ const alerts = telemetryAlerts.value || [];
46
+
47
+ const hasSummary = summary && summary.total > 0;
48
+
49
+ const executorRows = useMemo(
50
+ () => Object.entries(executors).sort((a, b) => b[1] - a[1]),
51
+ [executors],
52
+ );
53
+
54
+ const alertRows = useMemo(
55
+ () => alerts.slice(-10).reverse(),
56
+ [alerts],
57
+ );
58
+
59
+ return html`
60
+ <section class="telemetry-tab">
61
+ <div class="section-header">
62
+ <h2>Telemetry</h2>
63
+ <button
64
+ class="btn btn-ghost btn-sm"
65
+ onClick=${() => {
66
+ loadTelemetrySummary();
67
+ loadTelemetryErrors();
68
+ loadTelemetryExecutors();
69
+ loadTelemetryAlerts();
70
+ scheduleRefresh(4000);
71
+ }}
72
+ >
73
+ Refresh
74
+ </button>
75
+ </div>
76
+
77
+ ${!hasSummary
78
+ ? html`<${EmptyState}
79
+ title="No telemetry yet"
80
+ description="Telemetry appears here once agents start running."
81
+ />`
82
+ : html`<${Card} title="Summary" class="telemetry-summary">
83
+ <div class="metric-grid">
84
+ <div>
85
+ <div class="metric-label">Sessions</div>
86
+ <div class="metric-value">${formatCount(summary.total)}</div>
87
+ </div>
88
+ <div>
89
+ <div class="metric-label">Success</div>
90
+ <div class="metric-value">
91
+ ${formatCount(summary.success)} (${summary.successRate}%)
92
+ </div>
93
+ </div>
94
+ <div>
95
+ <div class="metric-label">Avg Duration</div>
96
+ <div class="metric-value">${formatSeconds(summary.avgDuration)}</div>
97
+ </div>
98
+ <div>
99
+ <div class="metric-label">Errors</div>
100
+ <div class="metric-value">${formatCount(summary.totalErrors)}</div>
101
+ </div>
102
+ </div>
103
+ </${Card}>`}
104
+
105
+ <div class="telemetry-grid">
106
+ <${Card} title="Top Errors">
107
+ ${errors.length === 0
108
+ ? html`<${EmptyState}
109
+ title="No errors logged"
110
+ description="Errors appear here when failures are detected."
111
+ />`
112
+ : html`<ul class="telemetry-list">
113
+ ${errors.slice(0, 8).map(
114
+ (err) => html`<li>
115
+ <span class="telemetry-label">${err.fingerprint}</span>
116
+ <span class="telemetry-count">${err.count}</span>
117
+ </li>`,
118
+ )}
119
+ </ul>`}
120
+ </${Card}>
121
+
122
+ <${Card} title="Executors">
123
+ ${executorRows.length === 0
124
+ ? html`<${EmptyState}
125
+ title="No executor data"
126
+ description="Run tasks to populate executor usage."
127
+ />`
128
+ : html`<ul class="telemetry-list">
129
+ ${executorRows.map(
130
+ ([name, count]) => html`<li>
131
+ <span class="telemetry-label">${name}</span>
132
+ <span class="telemetry-count">${count}</span>
133
+ </li>`,
134
+ )}
135
+ </ul>`}
136
+ </${Card}>
137
+ </div>
138
+
139
+ <${Card} title="Recent Alerts">
140
+ ${alertRows.length === 0
141
+ ? html`<${EmptyState}
142
+ title="No alerts"
143
+ description="Analyzer alerts will show up here."
144
+ />`
145
+ : html`<ul class="telemetry-alerts">
146
+ ${alertRows.map(
147
+ (alert) => html`<li>
148
+ <div>
149
+ <div class="telemetry-alert-title">
150
+ ${alert.type || "alert"}
151
+ <${Badge} tone=${severityBadge(alert.severity)}>${
152
+ String(alert.severity || "medium").toUpperCase()
153
+ }</${Badge}>
154
+ </div>
155
+ <div class="telemetry-alert-meta">
156
+ ${alert.attempt_id || "unknown"}
157
+ ${alert.executor ? html` · ${alert.executor}` : ""}
158
+ </div>
159
+ </div>
160
+ </li>`,
161
+ )}
162
+ </ul>`}
163
+ </${Card}>
164
+ </section>
165
+ `;
166
+ }
167
+
package/ui-server.mjs CHANGED
@@ -600,6 +600,7 @@ const SETTINGS_KNOWN_KEYS = [
600
600
  "TELEGRAM_COMMAND_CONCURRENCY", "TELEGRAM_VERBOSITY", "TELEGRAM_BATCH_NOTIFICATIONS",
601
601
  "TELEGRAM_BATCH_INTERVAL_SEC", "TELEGRAM_BATCH_MAX_SIZE", "TELEGRAM_IMMEDIATE_PRIORITY",
602
602
  "TELEGRAM_API_BASE_URL", "TELEGRAM_HTTP_TIMEOUT_MS", "TELEGRAM_RETRY_ATTEMPTS",
603
+ "TELEGRAM_HISTORY_RETENTION_DAYS",
603
604
  "PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
604
605
  "TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
605
606
  "TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
@@ -2213,6 +2214,60 @@ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
2213
2214
  };
2214
2215
  }
2215
2216
 
2217
+ async function readJsonlTail(filePath, maxLines = 2000) {
2218
+ if (!existsSync(filePath)) return [];
2219
+ const tail = await tailFile(filePath, maxLines);
2220
+ return (tail.lines || [])
2221
+ .map((line) => {
2222
+ try {
2223
+ return JSON.parse(line);
2224
+ } catch {
2225
+ return null;
2226
+ }
2227
+ })
2228
+ .filter(Boolean);
2229
+ }
2230
+
2231
+ function withinDays(entry, days) {
2232
+ if (!days) return true;
2233
+ const ts = Date.parse(entry?.timestamp || "");
2234
+ if (!Number.isFinite(ts)) return true;
2235
+ return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
2236
+ }
2237
+
2238
+ function summarizeTelemetry(metrics, days) {
2239
+ const filtered = metrics.filter((m) => withinDays(m, days));
2240
+ if (filtered.length === 0) return null;
2241
+ const total = filtered.length;
2242
+ const success = filtered.filter(
2243
+ (m) => m.outcome?.status === "completed" || m.metrics?.success === true,
2244
+ ).length;
2245
+ const durations = filtered.map((m) => m.metrics?.duration_ms || 0);
2246
+ const avgDuration =
2247
+ durations.length > 0
2248
+ ? Math.round(
2249
+ durations.reduce((a, b) => a + b, 0) / durations.length / 1000,
2250
+ )
2251
+ : 0;
2252
+ const totalErrors = filtered.reduce(
2253
+ (sum, m) => sum + (m.error_summary?.total_errors || m.metrics?.errors || 0),
2254
+ 0,
2255
+ );
2256
+ const executors = {};
2257
+ for (const m of filtered) {
2258
+ const exec = m.executor || "unknown";
2259
+ executors[exec] = (executors[exec] || 0) + 1;
2260
+ }
2261
+ return {
2262
+ total,
2263
+ success,
2264
+ successRate: total > 0 ? Math.round((success / total) * 100) : 0,
2265
+ avgDuration,
2266
+ totalErrors,
2267
+ executors,
2268
+ };
2269
+ }
2270
+
2216
2271
  async function listAgentLogFiles(query = "", limit = 60) {
2217
2272
  const entries = [];
2218
2273
  const files = await readdir(agentLogsDir).catch(() => []);
@@ -3183,6 +3238,79 @@ async function handleApi(req, res, url) {
3183
3238
  return;
3184
3239
  }
3185
3240
 
3241
+ if (path === "/api/telemetry/summary") {
3242
+ try {
3243
+ const days = Number(url.searchParams.get("days") || "7");
3244
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3245
+ const metricsPath = resolve(logDir, "agent-metrics.jsonl");
3246
+ const metrics = await readJsonlTail(metricsPath, 3000);
3247
+ const summary = summarizeTelemetry(metrics, days);
3248
+ jsonResponse(res, 200, { ok: true, data: summary });
3249
+ } catch (err) {
3250
+ jsonResponse(res, 500, { ok: false, error: err.message });
3251
+ }
3252
+ return;
3253
+ }
3254
+
3255
+ if (path === "/api/telemetry/errors") {
3256
+ try {
3257
+ const days = Number(url.searchParams.get("days") || "7");
3258
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3259
+ const errorsPath = resolve(logDir, "agent-errors.jsonl");
3260
+ const errors = (await readJsonlTail(errorsPath, 2000)).filter((e) =>
3261
+ withinDays(e, days),
3262
+ );
3263
+ const byFingerprint = new Map();
3264
+ for (const e of errors) {
3265
+ const fp = e.data?.error_fingerprint || e.data?.error_message || "unknown";
3266
+ byFingerprint.set(fp, (byFingerprint.get(fp) || 0) + 1);
3267
+ }
3268
+ const top = [...byFingerprint.entries()]
3269
+ .sort((a, b) => b[1] - a[1])
3270
+ .slice(0, 20)
3271
+ .map(([fingerprint, count]) => ({ fingerprint, count }));
3272
+ jsonResponse(res, 200, { ok: true, data: top });
3273
+ } catch (err) {
3274
+ jsonResponse(res, 500, { ok: false, error: err.message });
3275
+ }
3276
+ return;
3277
+ }
3278
+
3279
+ if (path === "/api/telemetry/executors") {
3280
+ try {
3281
+ const days = Number(url.searchParams.get("days") || "7");
3282
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3283
+ const metricsPath = resolve(logDir, "agent-metrics.jsonl");
3284
+ const metrics = await readJsonlTail(metricsPath, 3000);
3285
+ const summary = summarizeTelemetry(metrics, days);
3286
+ jsonResponse(res, 200, {
3287
+ ok: true,
3288
+ data: summary?.executors || {},
3289
+ });
3290
+ } catch (err) {
3291
+ jsonResponse(res, 500, { ok: false, error: err.message });
3292
+ }
3293
+ return;
3294
+ }
3295
+
3296
+ if (path === "/api/telemetry/alerts") {
3297
+ try {
3298
+ const days = Number(url.searchParams.get("days") || "7");
3299
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
3300
+ const alertsPath = resolve(logDir, "agent-alerts.jsonl");
3301
+ const alerts = (await readJsonlTail(alertsPath, 500)).filter((a) =>
3302
+ withinDays(a, days),
3303
+ );
3304
+ jsonResponse(res, 200, {
3305
+ ok: true,
3306
+ data: alerts.slice(-50),
3307
+ });
3308
+ } catch (err) {
3309
+ jsonResponse(res, 500, { ok: false, error: err.message });
3310
+ }
3311
+ return;
3312
+ }
3313
+
3186
3314
  if (path === "/api/agent-logs/context") {
3187
3315
  try {
3188
3316
  const query = url.searchParams.get("query") || "";