bosun 0.41.10 → 0.42.1

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.
Files changed (64) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-hooks.mjs +56 -1
  4. package/agent/agent-pool.mjs +34 -1
  5. package/agent/agent-work-report.mjs +89 -3
  6. package/agent/analyze-agent-work-helpers.mjs +14 -0
  7. package/agent/analyze-agent-work.mjs +23 -3
  8. package/agent/hook-library.mjs +1383 -0
  9. package/agent/hook-profiles.mjs +55 -3
  10. package/agent/primary-agent.mjs +142 -0
  11. package/bosun-tui.mjs +4 -3
  12. package/bosun.schema.json +1 -1
  13. package/config/config.mjs +58 -0
  14. package/config/workspace-health.mjs +36 -6
  15. package/git/diff-stats.mjs +550 -124
  16. package/github/github-app-auth.mjs +9 -5
  17. package/infra/library-manager.mjs +4 -2
  18. package/infra/maintenance.mjs +13 -6
  19. package/infra/monitor.mjs +398 -10
  20. package/infra/runtime-accumulator.mjs +9 -1
  21. package/infra/session-tracker.mjs +51 -1
  22. package/infra/tui-bridge.mjs +415 -0
  23. package/infra/worktree-recovery-state.mjs +159 -0
  24. package/kanban/kanban-adapter.mjs +41 -8
  25. package/package.json +140 -135
  26. package/server/ui-server.mjs +893 -65
  27. package/shell/codex-config.mjs +34 -8
  28. package/task/task-executor.mjs +1 -1
  29. package/telegram/telegram-bot.mjs +267 -18
  30. package/tools/vitest-runner.mjs +108 -0
  31. package/tui/app.mjs +252 -148
  32. package/tui/components/status-header.mjs +88 -131
  33. package/tui/lib/ws-bridge.mjs +125 -35
  34. package/tui/screens/agents-screen-helpers.mjs +219 -0
  35. package/tui/screens/agents.mjs +287 -270
  36. package/tui/screens/status.mjs +51 -189
  37. package/tui/screens/tasks.mjs +41 -253
  38. package/ui/app.js +52 -23
  39. package/ui/components/chat-view.js +263 -84
  40. package/ui/components/diff-viewer.js +324 -140
  41. package/ui/components/kanban-board.js +13 -9
  42. package/ui/components/session-list.js +111 -41
  43. package/ui/demo-defaults.js +281 -17
  44. package/ui/demo.html +6 -0
  45. package/ui/modules/session-api.js +320 -5
  46. package/ui/modules/stream-timeline.js +356 -0
  47. package/ui/modules/telegram.js +5 -2
  48. package/ui/modules/worktree-recovery.js +85 -0
  49. package/ui/styles.css +44 -0
  50. package/ui/tabs/chat.js +19 -4
  51. package/ui/tabs/dashboard.js +22 -0
  52. package/ui/tabs/infra.js +25 -0
  53. package/ui/tabs/tasks.js +119 -11
  54. package/ui/tabs/workflows.js +260 -10
  55. package/voice/voice-auth-manager.mjs +10 -5
  56. package/workflow/execution-ledger.mjs +140 -0
  57. package/workflow/workflow-engine.mjs +731 -27
  58. package/workflow/workflow-nodes.mjs +868 -53
  59. package/workflow/workflow-templates.mjs +4 -0
  60. package/workflow-templates/github.mjs +2 -1
  61. package/workflow-templates/planning.mjs +2 -1
  62. package/workflow-templates/task-batch.mjs +9 -8
  63. package/workflow-templates/task-lifecycle.mjs +51 -3
  64. package/workspace/shared-knowledge.mjs +409 -155
package/.env.example CHANGED
@@ -1039,6 +1039,18 @@ COPILOT_CLOUD_DISABLED=true
1039
1039
  # WORKFLOW_AUTOMATION_ENABLED=true
1040
1040
  # Optional dedup window to avoid event storms (milliseconds).
1041
1041
  # WORKFLOW_EVENT_DEDUP_WINDOW_MS=15000
1042
+ # Monitor self-healing retry policy for startup unstick operations
1043
+ # (stale dispatch polling and workflow-history resume).
1044
+ # Max bounded retry attempts before terminal escalation.
1045
+ # WORKFLOW_RECOVERY_MAX_ATTEMPTS=5
1046
+ # Escalation warning threshold for repeated failures (must be <= max attempts).
1047
+ # WORKFLOW_RECOVERY_ESCALATION_THRESHOLD=3
1048
+ # Exponential backoff base delay in ms.
1049
+ # WORKFLOW_RECOVERY_BACKOFF_BASE_MS=5000
1050
+ # Exponential backoff maximum delay in ms.
1051
+ # WORKFLOW_RECOVERY_BACKOFF_MAX_MS=60000
1052
+ # Random jitter ratio (0.0-0.9) applied to backoff to prevent retry storms.
1053
+ # WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO=0.2
1042
1054
 
1043
1055
  # ─── GitHub Issue Reconciler ─────────────────────────────────────────────────
1044
1056
  # Periodically reconciles open GitHub issues against open/merged PRs.
package/README.md CHANGED
@@ -144,6 +144,8 @@ Set `primaryAgent` in `.bosun/bosun.config.json` or choose an executor preset du
144
144
  - `bosun --daemon --sentinel` starts daemon + sentinel together (recommended for unattended operation).
145
145
  - `bosun --terminate` is the clean reset command when you suspect stale/ghost processes.
146
146
 
147
+ Telegram operators can pull the weekly agent work summary with `/weekly [days]` or `/report weekly [days]`. To post it automatically once per week, set `TELEGRAM_WEEKLY_REPORT_ENABLED=true` together with `TELEGRAM_WEEKLY_REPORT_DAY`, `TELEGRAM_WEEKLY_REPORT_HOUR`, and optional `TELEGRAM_WEEKLY_REPORT_DAYS`.
148
+
147
149
  ## Documentation
148
150
 
149
151
  **Published docs (website):** https://bosun.engineer/docs/
@@ -144,7 +144,7 @@ export const HOOK_EVENTS = Object.freeze([
144
144
  * Canonical SDK names.
145
145
  * @type {readonly string[]}
146
146
  */
147
- const VALID_SDKS = Object.freeze(["codex", "copilot", "claude", "opencode"]);
147
+ const VALID_SDKS = Object.freeze(["codex", "copilot", "claude", "opencode", "gemini"]);
148
148
 
149
149
  /**
150
150
  * Wildcard indicating a hook applies to all SDKs.
@@ -1198,3 +1198,58 @@ function _truncate(str, maxLen) {
1198
1198
  if (!str || str.length <= maxLen) return str ?? "";
1199
1199
  return str.slice(0, maxLen) + "\n... (truncated)";
1200
1200
  }
1201
+
1202
+ // ── Hook Library Integration ────────────────────────────────────────────────
1203
+
1204
+ /**
1205
+ * Register hooks from the hook-library into the agent-hooks runtime registry.
1206
+ * This bridges the declarative hook catalog with the execution engine.
1207
+ *
1208
+ * @param {Record<string, Array<{id: string, command: string, description?: string, timeout?: number, blocking?: boolean, sdks?: string[], builtin?: boolean, retryable?: boolean, maxRetries?: number, env?: Record<string,string>}>>} hooksByEvent
1209
+ * - Output of getHooksForRegistration() from hook-library.mjs
1210
+ * @returns {{ registered: number, skipped: number }}
1211
+ */
1212
+ export function registerLibraryHooks(hooksByEvent) {
1213
+ let registered = 0;
1214
+ let skipped = 0;
1215
+
1216
+ if (!hooksByEvent || typeof hooksByEvent !== "object") {
1217
+ return { registered, skipped };
1218
+ }
1219
+
1220
+ for (const [event, hooks] of Object.entries(hooksByEvent)) {
1221
+ if (!HOOK_EVENTS.includes(event)) {
1222
+ console.warn(`${TAG} unknown hook event from library: ${event}`);
1223
+ skipped += hooks.length;
1224
+ continue;
1225
+ }
1226
+
1227
+ for (const hook of hooks) {
1228
+ // Skip if a hook with this ID is already registered (built-in takes priority)
1229
+ const existing = (_registry.get(event) ?? []).find((h) => h.id === hook.id);
1230
+ if (existing) {
1231
+ skipped++;
1232
+ continue;
1233
+ }
1234
+
1235
+ registerHook(event, {
1236
+ id: hook.id,
1237
+ command: hook.command,
1238
+ description: hook.description ?? "",
1239
+ timeout: hook.timeout ?? DEFAULT_TIMEOUT_MS,
1240
+ blocking: hook.blocking ?? false,
1241
+ sdks: hook.sdks ?? [SDK_WILDCARD],
1242
+ builtin: hook.builtin ?? false,
1243
+ retryable: hook.retryable ?? false,
1244
+ maxRetries: hook.maxRetries,
1245
+ env: hook.env,
1246
+ });
1247
+ registered++;
1248
+ }
1249
+ }
1250
+
1251
+ if (registered > 0) {
1252
+ console.log(`${TAG} registered ${registered} hook(s) from hook library`);
1253
+ }
1254
+ return { registered, skipped };
1255
+ }
@@ -2871,6 +2871,19 @@ function sdkSupportsPersistentThreads(sdkName) {
2871
2871
 
2872
2872
  /** @type {Map<string, ActiveSession>} */
2873
2873
  const activeSessions = new Map();
2874
+ const ACTIVE_SESSION_LISTENERS = new Set();
2875
+
2876
+ function notifyActiveSessionListeners(reason = "update", taskKey = null) {
2877
+ if (ACTIVE_SESSION_LISTENERS.size === 0) return;
2878
+ const snapshot = getActiveSessions();
2879
+ for (const listener of ACTIVE_SESSION_LISTENERS) {
2880
+ try {
2881
+ listener(snapshot, { reason, taskKey });
2882
+ } catch {
2883
+ /* best effort */
2884
+ }
2885
+ }
2886
+ }
2874
2887
 
2875
2888
  /** Threshold for approaching-exhaustion warning (80% of MAX_THREAD_TURNS). */
2876
2889
  const THREAD_EXHAUSTION_WARNING_THRESHOLD = Math.floor(MAX_THREAD_TURNS * 0.8);
@@ -2892,6 +2905,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
2892
2905
  send: sendFn,
2893
2906
  registeredAt: Date.now(),
2894
2907
  });
2908
+ notifyActiveSessionListeners("start", taskKey);
2895
2909
  }
2896
2910
 
2897
2911
  /**
@@ -2900,6 +2914,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
2900
2914
  */
2901
2915
  function unregisterActiveSession(taskKey) {
2902
2916
  activeSessions.delete(taskKey);
2917
+ notifyActiveSessionListeners("end", taskKey);
2903
2918
  }
2904
2919
 
2905
2920
  /**
@@ -2949,15 +2964,31 @@ export function hasActiveSession(taskKey) {
2949
2964
  * Get info about which tasks have active (steerable) sessions.
2950
2965
  * @returns {Array<{ taskKey: string, sdk: string, threadId: string|null, age: number }>}
2951
2966
  */
2967
+ export function addActiveSessionListener(listener) {
2968
+ if (typeof listener !== "function") return () => {};
2969
+ ACTIVE_SESSION_LISTENERS.add(listener);
2970
+ return () => ACTIVE_SESSION_LISTENERS.delete(listener);
2971
+ }
2972
+
2952
2973
  export function getActiveSessions() {
2953
2974
  const now = Date.now();
2954
2975
  const result = [];
2955
2976
  for (const [key, session] of activeSessions) {
2977
+ const record = threadRegistry.get(key) || null;
2978
+ const createdAt = Number(record?.createdAt || session.registeredAt || now);
2979
+ const lastUsedAt = Number(record?.lastUsedAt || now);
2956
2980
  result.push({
2981
+ id: key,
2982
+ taskId: key,
2957
2983
  taskKey: key,
2958
2984
  sdk: session.sdk,
2959
2985
  threadId: session.threadId,
2960
- age: now - session.registeredAt,
2986
+ type: "task",
2987
+ status: "active",
2988
+ turnCount: Number(record?.turnCount || 0),
2989
+ createdAt: new Date(createdAt).toISOString(),
2990
+ lastActiveAt: new Date(lastUsedAt).toISOString(),
2991
+ age: Math.max(0, now - createdAt),
2961
2992
  });
2962
2993
  }
2963
2994
  return result;
@@ -3973,3 +4004,5 @@ export function getActiveThreads() {
3973
4004
  }
3974
4005
  return result;
3975
4006
  }
4007
+
4008
+
@@ -1,5 +1,6 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { resolveRepoRoot } from "../config/repo-root.mjs";
4
5
 
5
6
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -89,6 +90,12 @@ function defaultLogPaths() {
89
90
  };
90
91
  }
91
92
 
93
+ export function getWeeklyReportStatePath(options = {}) {
94
+ if (options?.statePath) return resolve(String(options.statePath));
95
+ const repoRoot = resolveRepoRoot();
96
+ return resolve(repoRoot, ".cache", "agent-work-logs", "weekly-report-state.json");
97
+ }
98
+
92
99
  async function defaultLoadMetrics() {
93
100
  const { metricsPath } = defaultLogPaths();
94
101
  return await readJsonlFile(metricsPath);
@@ -99,6 +106,44 @@ async function defaultLoadErrors() {
99
106
  return await readJsonlFile(errorsPath);
100
107
  }
101
108
 
109
+ export async function readWeeklyReportScheduleState(options = {}) {
110
+ const statePath = getWeeklyReportStatePath(options);
111
+ try {
112
+ const raw = await readFile(statePath, "utf8");
113
+ const parsed = JSON.parse(raw);
114
+ const lastSentAt = String(parsed?.lastSentAt || "").trim();
115
+ if (!lastSentAt) {
116
+ return { statePath, lastSentAt: null };
117
+ }
118
+ const lastSentMs = Date.parse(lastSentAt);
119
+ return {
120
+ statePath,
121
+ lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
122
+ };
123
+ } catch (err) {
124
+ if (err?.code === "ENOENT") {
125
+ return { statePath, lastSentAt: null };
126
+ }
127
+ return { statePath, lastSentAt: null };
128
+ }
129
+ }
130
+
131
+ export async function writeWeeklyReportScheduleState(lastSentAt, options = {}) {
132
+ const statePath = getWeeklyReportStatePath(options);
133
+ const normalized = String(lastSentAt || "").trim();
134
+ const lastSentMs = Date.parse(normalized);
135
+ const payload = {
136
+ lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
137
+ updatedAt: new Date().toISOString(),
138
+ };
139
+ await mkdir(dirname(statePath), { recursive: true });
140
+ await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
141
+ return {
142
+ statePath,
143
+ lastSentAt: payload.lastSentAt,
144
+ };
145
+ }
146
+
102
147
  export function buildWeeklyAgentWorkSummary(options = {}) {
103
148
  const now = toSafeDate(options.now);
104
149
  const days = toPositiveDays(options.days, DEFAULT_DAYS);
@@ -359,4 +404,45 @@ export function shouldSendWeeklyReport(options = {}) {
359
404
  return true;
360
405
  }
361
406
  return lastSentMs < scheduledThisWeek.getTime();
362
- }
407
+ }
408
+
409
+ export async function runWeeklyAgentWorkReportCli(options = {}) {
410
+ const argv = Array.isArray(options.argv) ? options.argv : process.argv.slice(2);
411
+ const stdout = options.stdout || process.stdout;
412
+ const stderr = options.stderr || process.stderr;
413
+ const rawDays = String(argv[0] || "").trim();
414
+ const parsedDays = Number.parseInt(rawDays, 10);
415
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : undefined;
416
+
417
+ try {
418
+ const report = await generateWeeklyAgentWorkReport({
419
+ ...(days ? { days } : {}),
420
+ ...(options.now ? { now: options.now } : {}),
421
+ ...(typeof options.loadMetrics === "function"
422
+ ? { loadMetrics: options.loadMetrics }
423
+ : {}),
424
+ ...(typeof options.loadErrors === "function"
425
+ ? { loadErrors: options.loadErrors }
426
+ : {}),
427
+ });
428
+ stdout.write(`${report.text}\n`);
429
+ if (Array.isArray(report.warnings) && report.warnings.length > 0) {
430
+ stderr.write(`Warnings: ${report.warnings.join(" | ")}\n`);
431
+ }
432
+ return 0;
433
+ } catch (err) {
434
+ stderr.write(`Failed to generate weekly agent work report: ${err?.message || err}\n`);
435
+ return 1;
436
+ }
437
+ }
438
+
439
+ const isDirectRun =
440
+ process.argv[1] &&
441
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
442
+
443
+ if (isDirectRun) {
444
+ const exitCode = await runWeeklyAgentWorkReportCli();
445
+ if (exitCode !== 0) {
446
+ process.exitCode = exitCode;
447
+ }
448
+ }
@@ -203,8 +203,10 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
203
203
  count: 0,
204
204
  task_ids: new Set(),
205
205
  by_executor: {},
206
+ by_model: {},
206
207
  by_size: {},
207
208
  by_complexity: {},
209
+ avg_task_duration_ms: 0,
208
210
  sample_message: "",
209
211
  first_seen_ts: null,
210
212
  last_seen_ts: null,
@@ -240,13 +242,23 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
240
242
  extractSizeLabelFromTitle(error.task_title),
241
243
  );
242
244
  const executor = resolveKnownValue(profile?.executor, error.executor);
245
+ const model = resolveKnownValue(profile?.model, error.model);
243
246
  const complexity = resolveKnownValue(profile?.complexity);
244
247
 
245
248
  incrementCounter(entry.by_size, sizeLabel);
246
249
  incrementCounter(entry.by_executor, executor);
250
+ incrementCounter(entry.by_model, model);
247
251
  incrementCounter(entry.by_complexity, complexity);
248
252
  }
249
253
 
254
+ for (const entry of correlations.values()) {
255
+ const durations = [...entry.task_ids]
256
+ .map((taskId) => taskProfiles.get(taskId)?.avg_duration_ms || 0)
257
+ .filter((duration) => duration > 0);
258
+ entry.avg_task_duration_ms =
259
+ durations.length > 0 ? average(durations) : 0;
260
+ }
261
+
250
262
  return correlations;
251
263
  }
252
264
 
@@ -300,7 +312,9 @@ export function buildErrorCorrelationJsonPayload(summary, { now } = {}) {
300
312
  last_seen: entry.last_seen_ts
301
313
  ? new Date(entry.last_seen_ts).toISOString()
302
314
  : null,
315
+ avg_task_duration_ms: entry.avg_task_duration_ms || 0,
303
316
  by_executor: buildDistribution(entry.by_executor, entry.count),
317
+ by_model: buildDistribution(entry.by_model, entry.count),
304
318
  by_size: buildDistribution(entry.by_size, entry.count),
305
319
  by_complexity: buildDistribution(entry.by_complexity, entry.count),
306
320
  })),
@@ -21,7 +21,7 @@
21
21
  import { readFile, readdir } from "fs/promises";
22
22
  import { createReadStream, existsSync } from "fs";
23
23
  import { createInterface } from "readline";
24
- import { resolve, dirname } from "path";
24
+ import { resolve, dirname, isAbsolute } from "path";
25
25
  import { fileURLToPath } from "url";
26
26
  import {
27
27
  buildErrorClusters,
@@ -44,8 +44,16 @@ const __filename = fileURLToPath(import.meta.url);
44
44
  const __dirname = dirname(__filename);
45
45
  const repoRoot = resolve(__dirname, "../..");
46
46
 
47
+ function resolveAgentWorkLogDir() {
48
+ const configured = String(process.env.AGENT_WORK_LOG_DIR || "").trim();
49
+ if (!configured) {
50
+ return resolve(repoRoot, ".cache/agent-work-logs");
51
+ }
52
+ return isAbsolute(configured) ? configured : resolve(repoRoot, configured);
53
+ }
54
+
47
55
  // ── Log Paths ───────────────────────────────────────────────────────────────
48
- const LOG_DIR = resolve(repoRoot, ".cache/agent-work-logs");
56
+ const LOG_DIR = resolveAgentWorkLogDir();
49
57
  const STREAM_LOG = resolve(LOG_DIR, "agent-work-stream.jsonl");
50
58
  const ERRORS_LOG = resolve(LOG_DIR, "agent-errors.jsonl");
51
59
  const METRICS_LOG = resolve(LOG_DIR, "agent-metrics.jsonl");
@@ -339,6 +347,14 @@ function formatDistribution(counts, total, limit = 5) {
339
347
  .join(", ");
340
348
  }
341
349
 
350
+ function formatDurationMs(durationMs) {
351
+ const value = Number(durationMs) || 0;
352
+ if (value <= 0) return "unknown";
353
+ if (value < 1000) return `${value.toFixed(0)}ms`;
354
+ if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
355
+ return `${(value / 60_000).toFixed(1)}m`;
356
+ }
357
+
342
358
  function topN(obj, n) {
343
359
  return Object.entries(obj)
344
360
  .sort((a, b) => b[1] - a[1])
@@ -478,7 +494,7 @@ async function correlateErrors(options) {
478
494
  const errors = await loadErrors({ days: windowDays });
479
495
 
480
496
  if (errors.length === 0) {
481
- const message = "No error data found";
497
+ const message = "No data found for selected window";
482
498
  if (useJson) {
483
499
  console.log(
484
500
  JSON.stringify(
@@ -522,10 +538,14 @@ async function correlateErrors(options) {
522
538
  console.log(
523
539
  ` Executors: ${formatDistribution(entry.by_executor, entry.count)}`,
524
540
  );
541
+ console.log(` Models: ${formatDistribution(entry.by_model, entry.count)}`);
525
542
  console.log(` Sizes: ${formatDistribution(entry.by_size, entry.count)}`);
526
543
  console.log(
527
544
  ` Complexity: ${formatDistribution(entry.by_complexity, entry.count)}`,
528
545
  );
546
+ console.log(
547
+ ` Avg task duration: ${formatDurationMs(entry.avg_task_duration_ms)}`,
548
+ );
529
549
  if (entry.sample_message) {
530
550
  console.log(
531
551
  ` Sample: ${entry.sample_message.slice(0, 100)}${entry.sample_message.length > 100 ? "..." : ""}`,