bosun 0.37.1 → 0.37.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
@@ -21,7 +21,6 @@ SHARED_STATE_STALE_THRESHOLD_MS=300000
21
21
  # Maximum retry attempts before permanently ignoring a task (default: 3)
22
22
  SHARED_STATE_MAX_RETRIES=3
23
23
  # Task claim owner staleness threshold in milliseconds (default: 600000 = 10 minutes)
24
- # Used by task-claims.mjs to detect stale local claims
25
24
  TASK_CLAIM_OWNER_STALE_TTL_MS=600000
26
25
 
27
26
  # ─── Project Identity ─────────────────────────────────────────────────────────
@@ -178,6 +177,10 @@ VOICE_VISION_MODEL=gpt-4.1-mini
178
177
  # Gemini provider mode (Tier 2 voice fallback + Gemini vision)
179
178
  # GEMINI_API_KEY=
180
179
  # GOOGLE_API_KEY=
180
+ # Transcription model used for audio-to-text (default: gpt-4o-transcribe)
181
+ # VOICE_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
182
+ # Enable/disable input audio transcription in realtime sessions (default: true)
183
+ # VOICE_TRANSCRIPTION_ENABLED=true
181
184
  # Voice output persona
182
185
  VOICE_ID=alloy
183
186
  # server_vad | semantic_vad | none
@@ -286,12 +286,23 @@ export function getEffectiveTools(rootDir, agentId) {
286
286
  const agentConfig = config.agents[agentId] || {};
287
287
  const disabledSet = new Set(agentConfig.disabledBuiltinTools || []);
288
288
  const defaultIds = new Set(config.defaults?.builtinTools || DEFAULT_BUILTIN_TOOLS.filter((t) => t.default).map((t) => t.id));
289
+ const builtinIdSet = new Set(DEFAULT_BUILTIN_TOOLS.map((tool) => tool.id));
290
+ const explicitEnabled = Array.isArray(agentConfig.enabledTools)
291
+ ? agentConfig.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
292
+ : null;
293
+ const explicitBuiltinEnabled = explicitEnabled
294
+ ? explicitEnabled.filter((id) => builtinIdSet.has(id))
295
+ : [];
296
+ const useBuiltinAllowlist = explicitBuiltinEnabled.length > 0;
297
+ const explicitBuiltinSet = new Set(explicitBuiltinEnabled);
289
298
 
290
299
  const builtinTools = DEFAULT_BUILTIN_TOOLS.map((tool) => ({
291
300
  ...tool,
292
- enabled: !disabledSet.has(tool.id) && (agentConfig.enabledTools === null || agentConfig.enabledTools === undefined
293
- ? defaultIds.has(tool.id)
294
- : agentConfig.enabledTools.includes(tool.id)),
301
+ enabled: !disabledSet.has(tool.id) && (
302
+ useBuiltinAllowlist
303
+ ? explicitBuiltinSet.has(tool.id)
304
+ : defaultIds.has(tool.id)
305
+ ),
295
306
  }));
296
307
 
297
308
  return {
package/bosun-skills.mjs CHANGED
@@ -16,8 +16,44 @@
16
16
  * scan quickly to decide which skill files to read.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
20
- import { resolve, basename } from "node:path";
19
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve, basename } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ // ── Analytics stream path (same file task-executor writes to) ────────────────
27
+ const _SKILL_STREAM_PATH = resolve(
28
+ __dirname,
29
+ ".cache",
30
+ "agent-work-logs",
31
+ "agent-work-stream.jsonl",
32
+ );
33
+
34
+ /**
35
+ * Best-effort: emit a skill_invoke event to the agent work stream so usage
36
+ * analytics can track which skills are loaded per task.
37
+ *
38
+ * @param {string} skillName
39
+ * @param {string} [skillTitle]
40
+ * @param {{ taskId?: string, executor?: string }} [opts]
41
+ */
42
+ function emitSkillInvokeEvent(skillName, skillTitle, opts = {}) {
43
+ try {
44
+ const event = {
45
+ timestamp: new Date().toISOString(),
46
+ event_type: "skill_invoke",
47
+ data: { skill_name: skillName, skill_title: skillTitle || skillName },
48
+ ...(opts.taskId ? { task_id: String(opts.taskId) } : {}),
49
+ ...(opts.executor ? { executor: String(opts.executor) } : {}),
50
+ };
51
+ mkdirSync(dirname(_SKILL_STREAM_PATH), { recursive: true });
52
+ appendFileSync(_SKILL_STREAM_PATH, JSON.stringify(event) + "\n", "utf8");
53
+ } catch {
54
+ /* best effort — never let analytics crash skill loading */
55
+ }
56
+ }
21
57
 
22
58
  // ── Built-in skill definitions ────────────────────────────────────────────────
23
59
 
@@ -911,14 +947,25 @@ export function loadSkillsIndex(bosunHome) {
911
947
  * @param {string} [taskDescription]
912
948
  * @returns {Array<{filename:string,title:string,tags:string[],content:string}>}
913
949
  */
914
- export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "") {
950
+ /**
951
+ * Find skills relevant to a given task by matching tags against the task title
952
+ * and description. Also emits `skill_invoke` analytics events for each matched
953
+ * skill so usage analytics can track skill popularity over time.
954
+ *
955
+ * @param {string} bosunHome
956
+ * @param {string} taskTitle
957
+ * @param {string} [taskDescription]
958
+ * @param {{ taskId?: string, executor?: string }} [opts] - Optional task context for analytics.
959
+ * @returns {Array<{filename:string,title:string,tags:string[],content:string}>}
960
+ */
961
+ export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "", opts = {}) {
915
962
  const index = loadSkillsIndex(bosunHome);
916
963
  if (!index?.skills?.length) return [];
917
964
 
918
965
  const searchText = `${taskTitle} ${taskDescription}`.toLowerCase();
919
966
  const skillsDir = getSkillsDir(bosunHome);
920
967
 
921
- return index.skills
968
+ const matched = index.skills
922
969
  .filter(({ tags }) =>
923
970
  tags.some((tag) => searchText.includes(tag)),
924
971
  )
@@ -930,4 +977,12 @@ export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "") {
930
977
  return { filename, title, tags, content };
931
978
  })
932
979
  .filter(({ content }) => !!content);
980
+
981
+ // Emit analytics events for each loaded skill
982
+ for (const skill of matched) {
983
+ const skillName = skill.filename.replace(/\.md$/i, "");
984
+ emitSkillInvokeEvent(skillName, skill.title, opts);
985
+ }
986
+
987
+ return matched;
933
988
  }
@@ -17,6 +17,13 @@ const chromeSandbox = resolve(
17
17
 
18
18
  process.title = "bosun-desktop-launcher";
19
19
 
20
+ function hasGuiEnvironment() {
21
+ if (process.platform !== "linux") return true;
22
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true;
23
+ if (process.env.XDG_SESSION_TYPE && process.env.XDG_SESSION_TYPE !== "tty") return true;
24
+ return false;
25
+ }
26
+
20
27
  function shouldDisableSandbox() {
21
28
  if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
22
29
  if (process.platform !== "linux") return false;
@@ -49,6 +56,17 @@ function ensureElectronInstalled() {
49
56
  }
50
57
 
51
58
  function launch() {
59
+ if (!hasGuiEnvironment()) {
60
+ console.error(
61
+ [
62
+ "[desktop] No GUI display server detected.",
63
+ "Cannot launch Electron portal without DISPLAY/WAYLAND.",
64
+ "Run Bosun in daemon/web mode instead (for example: `bosun --daemon`).",
65
+ ].join(" "),
66
+ );
67
+ process.exit(1);
68
+ }
69
+
52
70
  if (!ensureElectronInstalled()) {
53
71
  process.exit(1);
54
72
  }
package/desktop/main.mjs CHANGED
@@ -89,6 +89,7 @@ let shuttingDown = false;
89
89
  let uiServerStarted = false;
90
90
  let uiOrigin = null;
91
91
  let uiApi = null;
92
+ let desktopAuthHeaderBridgeInstalled = false;
92
93
  let runtimeConfigLoaded = false;
93
94
  /** True when the app is running as a persistent background / tray resident. */
94
95
  let trayMode = false;
@@ -154,6 +155,38 @@ function isTrustedCaptureOrigin(originLike) {
154
155
  }
155
156
  }
156
157
 
158
+ function isTrustedDesktopRequestUrl(urlLike) {
159
+ return isTrustedCaptureOrigin(urlLike);
160
+ }
161
+
162
+ function installDesktopAuthHeaderBridge() {
163
+ if (desktopAuthHeaderBridgeInstalled) return;
164
+ const ses = session.defaultSession;
165
+ if (!ses) return;
166
+ ses.webRequest.onBeforeSendHeaders((details, callback) => {
167
+ try {
168
+ const desktopKey = String(process.env.BOSUN_DESKTOP_API_KEY || "").trim();
169
+ if (!desktopKey) {
170
+ callback({ requestHeaders: details.requestHeaders });
171
+ return;
172
+ }
173
+ if (!isTrustedDesktopRequestUrl(details?.url || "")) {
174
+ callback({ requestHeaders: details.requestHeaders });
175
+ return;
176
+ }
177
+ const headers = { ...(details.requestHeaders || {}) };
178
+ const existingAuth = String(headers.Authorization || headers.authorization || "").trim();
179
+ if (!existingAuth) {
180
+ headers.Authorization = `Bearer ${desktopKey}`;
181
+ }
182
+ callback({ requestHeaders: headers });
183
+ } catch {
184
+ callback({ requestHeaders: details.requestHeaders });
185
+ }
186
+ });
187
+ desktopAuthHeaderBridgeInstalled = true;
188
+ }
189
+
157
190
  function installDesktopMediaHandlers() {
158
191
  const ses = session.defaultSession;
159
192
  if (!ses) return;
@@ -583,20 +616,25 @@ async function fetchWorkspaces({ force = false } = {}) {
583
616
  * @param {string} workspaceId
584
617
  */
585
618
  async function switchWorkspace(workspaceId) {
586
- if (!uiOrigin || !workspaceId) return;
619
+ if (!uiOrigin || !workspaceId) return { ok: false, error: "workspace unavailable" };
587
620
  const body = JSON.stringify({ workspaceId });
588
621
  const data = await uiServerRequest(`${uiOrigin}/api/workspaces/active`, {
589
622
  method: "POST",
590
623
  body,
591
624
  });
592
- if (data?.ok) {
593
- _cachedActiveWorkspaceId = workspaceId;
594
- _workspaceCacheAt = 0; // force re-fetch next time
625
+ if (!data?.ok) {
626
+ return {
627
+ ok: false,
628
+ error: String(data?.error || "workspace switch failed"),
629
+ };
595
630
  }
631
+ _cachedActiveWorkspaceId = String(data?.activeId || workspaceId);
632
+ _workspaceCacheAt = 0; // force re-fetch next time
596
633
  await fetchWorkspaces({ force: true });
597
634
  Menu.setApplicationMenu(buildAppMenu());
598
635
  refreshTrayMenu();
599
636
  navigateMainWindow("/");
637
+ return { ok: true, activeId: _cachedActiveWorkspaceId };
600
638
  }
601
639
 
602
640
  /**
@@ -1265,14 +1303,15 @@ async function openFollowWindow(detail = {}) {
1265
1303
  const win = await createFollowWindow();
1266
1304
  const baseUiUrl = await buildUiUrl();
1267
1305
  const target = buildFollowWindowUrl(baseUiUrl, detail);
1306
+ // Append a cache-buster timestamp so every Call press produces a unique URL.
1307
+ // Without this, a second Call press with the same parameters would match
1308
+ // followWindowLaunchSignature and skip loadURL — leaving the follow window
1309
+ // in its previous dead state (launch params already scrubbed, voice overlay
1310
+ // closed, useEffect([], []) already fired and won't re-run).
1311
+ target.searchParams.set("t", String(Date.now()));
1268
1312
  const signature = target.toString();
1269
- if (!win.webContents.getURL() || followWindowLaunchSignature !== signature) {
1270
- followWindowLaunchSignature = signature;
1271
- await win.loadURL(signature);
1272
- anchorFollowWindow(win);
1273
- setWindowVisible(win);
1274
- return;
1275
- }
1313
+ followWindowLaunchSignature = signature;
1314
+ await win.loadURL(signature);
1276
1315
  anchorFollowWindow(win);
1277
1316
  setWindowVisible(win);
1278
1317
  }
@@ -1727,8 +1766,7 @@ function registerDesktopIpc() {
1727
1766
  */
1728
1767
  ipcMain.handle("bosun:workspaces:switch", async (_event, { workspaceId } = {}) => {
1729
1768
  if (!workspaceId) return { ok: false, error: "workspaceId required" };
1730
- await switchWorkspace(workspaceId);
1731
- return { ok: true, activeId: workspaceId };
1769
+ return switchWorkspace(workspaceId);
1732
1770
  });
1733
1771
  }
1734
1772
 
@@ -1745,6 +1783,7 @@ async function bootstrap() {
1745
1783
  }
1746
1784
  callback(-3); // -3 = use Chromium default chain verification
1747
1785
  });
1786
+ installDesktopAuthHeaderBridge();
1748
1787
  installDesktopMediaHandlers();
1749
1788
 
1750
1789
  if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
@@ -36,6 +36,24 @@ import {
36
36
 
37
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
38
 
39
+ // ── Workflow Event Bridge ────────────────────────────────────────────────────
40
+ // Lazy-import queueWorkflowEvent from monitor.mjs — cached at module scope.
41
+ let _queueWorkflowEvent = null;
42
+ function emitFleetEvent(eventType, eventData = {}, opts = {}) {
43
+ if (!_queueWorkflowEvent) {
44
+ import("./monitor.mjs")
45
+ .then((mod) => {
46
+ if (typeof mod.queueWorkflowEvent === "function") {
47
+ _queueWorkflowEvent = mod.queueWorkflowEvent;
48
+ _queueWorkflowEvent(eventType, eventData, opts);
49
+ }
50
+ })
51
+ .catch(() => {});
52
+ return;
53
+ }
54
+ _queueWorkflowEvent(eventType, eventData, opts);
55
+ }
56
+
39
57
  // ── Repo Fingerprinting ──────────────────────────────────────────────────────
40
58
 
41
59
  function buildGitEnv() {
@@ -434,13 +452,21 @@ export function assignTasksToWorkstations(waves, peers, taskMap = new Map()) {
434
452
  assignments.push(...waveAssignments);
435
453
  }
436
454
 
437
- return {
455
+ const result = {
438
456
  assignments,
439
457
  totalTasks: assignments.length,
440
458
  totalPeers: peers.length,
441
459
  waveCount: waves.length,
442
460
  createdAt: new Date().toISOString(),
443
461
  };
462
+
463
+ emitFleetEvent("fleet.tasks_assigned", {
464
+ totalTasks: result.totalTasks,
465
+ totalPeers: result.totalPeers,
466
+ waveCount: result.waveCount,
467
+ }, { dedupKey: `fleet-assign-${result.createdAt}` });
468
+
469
+ return result;
444
470
  }
445
471
 
446
472
  // ── Backlog Depth Calculator ─────────────────────────────────────────────────
@@ -502,6 +528,10 @@ export function detectMaintenanceMode(status) {
502
528
 
503
529
  // Maintenance mode: nothing to do AND nothing in progress
504
530
  if (backlog === 0 && todo === 0 && running === 0 && review === 0) {
531
+ emitFleetEvent("fleet.maintenance_mode", {
532
+ isMaintenanceMode: true,
533
+ reason: "all tasks completed — no backlog, no active work",
534
+ });
505
535
  return {
506
536
  isMaintenanceMode: true,
507
537
  reason: "all tasks completed — no backlog, no active work",
@@ -722,6 +752,9 @@ export function shouldAutoGenerateTasks({
722
752
  */
723
753
  export function markAutoGenTriggered() {
724
754
  lastAutoGenTimestamp = Date.now();
755
+ emitFleetEvent("fleet.auto_gen_triggered", {
756
+ triggeredAt: new Date(lastAutoGenTimestamp).toISOString(),
757
+ });
725
758
  }
726
759
 
727
760
  /**
@@ -5823,6 +5823,23 @@ export function getKanbanBackendName() {
5823
5823
  // Convenience exports: direct task operations via active adapter
5824
5824
  // ---------------------------------------------------------------------------
5825
5825
 
5826
+ // ── Workflow Event Bridge (lazy-loaded from monitor.mjs) ──────────────────
5827
+ let _kanbanQueueWorkflowEvent = null;
5828
+ function emitKanbanEvent(eventType, eventData = {}) {
5829
+ if (!_kanbanQueueWorkflowEvent) {
5830
+ import("./monitor.mjs")
5831
+ .then((mod) => {
5832
+ if (typeof mod.queueWorkflowEvent === "function") {
5833
+ _kanbanQueueWorkflowEvent = mod.queueWorkflowEvent;
5834
+ _kanbanQueueWorkflowEvent(eventType, eventData);
5835
+ }
5836
+ })
5837
+ .catch(() => {});
5838
+ return;
5839
+ }
5840
+ _kanbanQueueWorkflowEvent(eventType, eventData);
5841
+ }
5842
+
5826
5843
  export async function listProjects() {
5827
5844
  return getKanbanAdapter().listProjects();
5828
5845
  }
@@ -5836,7 +5853,9 @@ export async function getTask(taskId) {
5836
5853
  }
5837
5854
 
5838
5855
  export async function updateTaskStatus(taskId, status, options) {
5839
- return getKanbanAdapter().updateTaskStatus(taskId, status, options);
5856
+ const result = await getKanbanAdapter().updateTaskStatus(taskId, status, options);
5857
+ emitKanbanEvent("task.status_updated", { taskId, status, options });
5858
+ return result;
5840
5859
  }
5841
5860
 
5842
5861
  export async function updateTask(taskId, patch) {
@@ -5851,11 +5870,19 @@ export async function updateTask(taskId, patch) {
5851
5870
  }
5852
5871
 
5853
5872
  export async function createTask(projectId, taskData) {
5854
- return getKanbanAdapter().createTask(projectId, taskData);
5873
+ const result = await getKanbanAdapter().createTask(projectId, taskData);
5874
+ emitKanbanEvent("task.created", {
5875
+ projectId,
5876
+ taskId: result?.id || null,
5877
+ title: taskData?.title || null,
5878
+ });
5879
+ return result;
5855
5880
  }
5856
5881
 
5857
5882
  export async function deleteTask(taskId) {
5858
- return getKanbanAdapter().deleteTask(taskId);
5883
+ const result = await getKanbanAdapter().deleteTask(taskId);
5884
+ emitKanbanEvent("task.deleted", { taskId });
5885
+ return result;
5859
5886
  }
5860
5887
 
5861
5888
  export async function addComment(taskId, body) {
@@ -633,6 +633,7 @@ export const BUILTIN_AGENT_PROFILES = [
633
633
  hookProfile: null,
634
634
  env: {},
635
635
  tags: ["ui", "frontend", "portal", "css", "web"],
636
+ agentType: "task",
636
637
  },
637
638
  {
638
639
  id: "backend-agent",
@@ -647,6 +648,7 @@ export const BUILTIN_AGENT_PROFILES = [
647
648
  hookProfile: null,
648
649
  env: {},
649
650
  tags: ["api", "server", "backend", "database"],
651
+ agentType: "task",
650
652
  },
651
653
  {
652
654
  id: "devops-agent",
@@ -661,6 +663,7 @@ export const BUILTIN_AGENT_PROFILES = [
661
663
  hookProfile: null,
662
664
  env: {},
663
665
  tags: ["ci", "cd", "build", "deploy", "infra", "devops"],
666
+ agentType: "task",
664
667
  },
665
668
  {
666
669
  id: "docs-agent",
@@ -675,6 +678,7 @@ export const BUILTIN_AGENT_PROFILES = [
675
678
  hookProfile: null,
676
679
  env: {},
677
680
  tags: ["docs", "documentation", "readme", "markdown"],
681
+ agentType: "task",
678
682
  },
679
683
  {
680
684
  id: "test-agent",
@@ -689,6 +693,47 @@ export const BUILTIN_AGENT_PROFILES = [
689
693
  hookProfile: null,
690
694
  env: {},
691
695
  tags: ["test", "testing", "e2e", "unit", "coverage"],
696
+ agentType: "task",
697
+ },
698
+ {
699
+ id: "voice-agent-female",
700
+ name: "Voice Agent (Female)",
701
+ description: "Conversational voice specialist with concise guidance and call-friendly pacing.",
702
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
703
+ scopes: ["voice", "assistant"],
704
+ sdk: null,
705
+ model: null,
706
+ promptOverride: null,
707
+ skills: ["concise-voice-guidance", "conversation-memory"],
708
+ hookProfile: null,
709
+ env: {},
710
+ tags: ["voice", "assistant", "realtime", "female", "default", "audio-agent"],
711
+ agentType: "voice",
712
+ voiceAgent: true,
713
+ voicePersona: "female",
714
+ voiceInstructions: "You are Nova, a female voice agent. Be concise, warm, and practical. Use tools for facts and execution. Keep spoken responses short and clear.",
715
+ enabledTools: null,
716
+ enabledMcpServers: [],
717
+ },
718
+ {
719
+ id: "voice-agent-male",
720
+ name: "Voice Agent (Male)",
721
+ description: "Operational voice specialist focused on diagnostics and execution.",
722
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
723
+ scopes: ["voice", "assistant"],
724
+ sdk: null,
725
+ model: null,
726
+ promptOverride: null,
727
+ skills: ["ops-diagnostics", "task-execution"],
728
+ hookProfile: null,
729
+ env: {},
730
+ tags: ["voice", "assistant", "realtime", "male", "default", "audio-agent"],
731
+ agentType: "voice",
732
+ voiceAgent: true,
733
+ voicePersona: "male",
734
+ voiceInstructions: "You are Atlas, a male voice agent. Be direct and execution-oriented. Prefer actionable status updates. Use tools proactively for diagnostics.",
735
+ enabledTools: null,
736
+ enabledMcpServers: [],
692
737
  },
693
738
  {
694
739
  id: "voice-agent",
@@ -703,6 +748,9 @@ export const BUILTIN_AGENT_PROFILES = [
703
748
  hookProfile: null,
704
749
  env: {},
705
750
  tags: ["voice", "assistant", "realtime", "default"],
751
+ agentType: "voice",
752
+ voiceAgent: true,
753
+ voicePersona: "neutral",
706
754
  enabledTools: null,
707
755
  enabledMcpServers: [],
708
756
  },
package/maintenance.mjs CHANGED
@@ -28,6 +28,26 @@ import {
28
28
  } from "./worktree-manager.mjs";
29
29
 
30
30
  const isWindows = process.platform === "win32";
31
+
32
+ // ── Workflow event bridge ─────────────────────────────────────────────────
33
+ // Lazy-loaded to avoid circular imports (monitor → maintenance → monitor).
34
+ let _queueWorkflowEvent = null;
35
+ function emitMaintenanceEvent(eventType, data = {}) {
36
+ if (_queueWorkflowEvent) {
37
+ _queueWorkflowEvent(eventType, data);
38
+ return;
39
+ }
40
+ import("./monitor.mjs")
41
+ .then((mod) => {
42
+ if (typeof mod.queueWorkflowEvent === "function") {
43
+ _queueWorkflowEvent = mod.queueWorkflowEvent;
44
+ _queueWorkflowEvent(eventType, data);
45
+ }
46
+ })
47
+ .catch(() => {
48
+ /* best-effort — monitor not available during tests */
49
+ });
50
+ }
31
51
  const BRANCH_SYNC_LOG_THROTTLE_MS = Math.max(
32
52
  5_000,
33
53
  Number(process.env.BRANCH_SYNC_LOG_THROTTLE_MS || "300000") || 300000,
@@ -1318,11 +1338,7 @@ export async function runMaintenanceSweep(opts = {}) {
1318
1338
  /* best-effort */
1319
1339
  }
1320
1340
 
1321
- console.log(
1322
- `[maintenance] sweep complete: ${staleKilled} stale orchestrators, ${pushesReaped} stuck pushes, ${worktreesPruned} worktrees pruned, ${branchesSynced} branches synced, ${branchesDeleted} stale branches deleted`,
1323
- );
1324
-
1325
- return {
1341
+ const result = {
1326
1342
  staleKilled,
1327
1343
  pushesReaped,
1328
1344
  worktreesPruned,
@@ -1330,4 +1346,13 @@ export async function runMaintenanceSweep(opts = {}) {
1330
1346
  tasksArchived,
1331
1347
  branchesDeleted,
1332
1348
  };
1349
+
1350
+ console.log(
1351
+ `[maintenance] sweep complete: ${staleKilled} stale orchestrators, ${pushesReaped} stuck pushes, ${worktreesPruned} worktrees pruned, ${branchesSynced} branches synced, ${branchesDeleted} stale branches deleted`,
1352
+ );
1353
+
1354
+ // Emit workflow event so event-driven workflows can react to sweep results
1355
+ emitMaintenanceEvent("maintenance.sweep_complete", result);
1356
+
1357
+ return result;
1333
1358
  }
package/monitor.mjs CHANGED
@@ -12235,12 +12235,66 @@ runGuarded("startup-maintenance-sweep", () =>
12235
12235
  safeSetInterval("flush-error-queue", () => flushErrorQueue(), 60 * 1000);
12236
12236
 
12237
12237
  // ── Periodic maintenance: every 5 min, reap stuck pushes & prune worktrees ──
12238
+ // If a workflow replaces maintenance.mjs, skip the direct call and let
12239
+ // the workflow engine handle it via trigger.schedule evaluation.
12238
12240
  const maintenanceIntervalMs = 5 * 60 * 1000;
12239
12241
  safeSetInterval("maintenance-sweep", () => {
12242
+ if (isWorkflowReplacingModule("maintenance.mjs")) return;
12240
12243
  const childPid = currentChild ? currentChild.pid : undefined;
12241
12244
  return runMaintenanceSweep({ repoRoot, childPid });
12242
12245
  }, maintenanceIntervalMs);
12243
12246
 
12247
+ // ── Workflow schedule trigger polling ───────────────────────────────────────
12248
+ // Check all installed workflows that use trigger.schedule and fire any whose
12249
+ // interval has elapsed. This makes schedule-based templates (workspace hygiene,
12250
+ // nightly reports, etc.) actually execute without hardcoded safeSetInterval calls.
12251
+ const scheduleCheckIntervalMs = 60 * 1000; // check every 60s
12252
+ safeSetInterval("workflow-schedule-check", async () => {
12253
+ try {
12254
+ const engine = await ensureWorkflowAutomationEngine();
12255
+ if (!engine?.evaluateScheduleTriggers) return;
12256
+
12257
+ const triggered = engine.evaluateScheduleTriggers();
12258
+ if (!Array.isArray(triggered) || triggered.length === 0) return;
12259
+
12260
+ for (const match of triggered) {
12261
+ const workflowId = String(match?.workflowId || "").trim();
12262
+ if (!workflowId) continue;
12263
+ void engine
12264
+ .execute(workflowId, {
12265
+ _triggerSource: "schedule-poll",
12266
+ _triggeredBy: match?.triggeredBy || null,
12267
+ _lastRunAt: Date.now(),
12268
+ repoRoot,
12269
+ })
12270
+ .then((ctx) => {
12271
+ const runId = ctx?.id || "unknown";
12272
+ const runStatus =
12273
+ Array.isArray(ctx?.errors) && ctx.errors.length > 0
12274
+ ? "failed"
12275
+ : "completed";
12276
+ console.log(
12277
+ `[workflows] schedule-run ${runStatus} workflow=${workflowId} runId=${runId}`,
12278
+ );
12279
+ })
12280
+ .catch((err) => {
12281
+ console.warn(
12282
+ `[workflows] schedule-run failed workflow=${workflowId}: ${err?.message || err}`,
12283
+ );
12284
+ });
12285
+ }
12286
+
12287
+ if (triggered.length > 0) {
12288
+ console.log(
12289
+ `[workflows] schedule poll triggered ${triggered.length} workflow run(s)`,
12290
+ );
12291
+ }
12292
+ } catch (err) {
12293
+ // Schedule evaluation must not crash the monitor
12294
+ console.warn(`[workflows] schedule-check error: ${err?.message || err}`);
12295
+ }
12296
+ }, scheduleCheckIntervalMs);
12297
+
12244
12298
  // ── Periodic merged PR check: every 10 min, move merged PRs to done ─────────
12245
12299
  const mergedPRCheckIntervalMs = 10 * 60 * 1000;
12246
12300
  safeSetInterval("merged-pr-check", () => checkMergedPRsAndUpdateTasks(), mergedPRCheckIntervalMs);
@@ -13307,4 +13361,6 @@ export {
13307
13361
  // Container runner re-exports
13308
13362
  getContainerStatus,
13309
13363
  isContainerEnabled,
13364
+ // Workflow event bridge — for fleet/kanban modules to emit events
13365
+ queueWorkflowEvent,
13310
13366
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.37.1",
3
+ "version": "0.37.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",
@@ -262,6 +262,7 @@
262
262
  "workflow-templates/planning.mjs",
263
263
  "workflow-templates/reliability.mjs",
264
264
  "workflow-templates/security.mjs",
265
+ "workflow-templates/task-batch.mjs",
265
266
  "workflow-templates/task-lifecycle.mjs",
266
267
  "ui/vendor/"
267
268
  ],