bosun 0.41.0 → 0.41.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.
Files changed (64) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-event-bus.mjs +248 -6
  4. package/agent/agent-pool.mjs +125 -28
  5. package/agent/agent-work-analyzer.mjs +8 -16
  6. package/agent/retry-queue.mjs +164 -0
  7. package/bosun.config.example.json +25 -0
  8. package/bosun.schema.json +825 -183
  9. package/cli.mjs +59 -5
  10. package/config/config.mjs +130 -3
  11. package/infra/monitor.mjs +693 -67
  12. package/infra/runtime-accumulator.mjs +376 -84
  13. package/infra/session-tracker.mjs +82 -25
  14. package/lib/codebase-audit.mjs +133 -18
  15. package/package.json +23 -4
  16. package/server/setup-web-server.mjs +25 -0
  17. package/server/ui-server.mjs +248 -29
  18. package/setup.mjs +27 -24
  19. package/shell/codex-shell.mjs +34 -3
  20. package/shell/copilot-shell.mjs +50 -8
  21. package/task/msg-hub.mjs +193 -0
  22. package/task/pipeline.mjs +544 -0
  23. package/task/task-cli.mjs +38 -2
  24. package/task/task-executor-pipeline.mjs +143 -0
  25. package/task/task-executor.mjs +36 -27
  26. package/telegram/get-telegram-chat-id.mjs +57 -47
  27. package/ui/components/workspace-switcher.js +7 -7
  28. package/ui/demo-defaults.js +15694 -10573
  29. package/ui/modules/settings-schema.js +2 -0
  30. package/ui/modules/state.js +54 -57
  31. package/ui/modules/voice-client-sdk.js +375 -36
  32. package/ui/modules/voice-client.js +140 -31
  33. package/ui/setup.html +68 -2
  34. package/ui/styles/components.css +57 -0
  35. package/ui/styles.css +201 -1
  36. package/ui/tabs/dashboard.js +74 -0
  37. package/ui/tabs/logs.js +10 -0
  38. package/ui/tabs/settings.js +178 -99
  39. package/ui/tabs/tasks.js +31 -1
  40. package/ui/tabs/telemetry.js +34 -0
  41. package/ui/tabs/workflow-canvas-utils.mjs +8 -1
  42. package/ui/tabs/workflows.js +532 -275
  43. package/voice/voice-agents-sdk.mjs +1 -1
  44. package/voice/voice-relay.mjs +6 -6
  45. package/workflow/declarative-workflows.mjs +145 -0
  46. package/workflow/msg-hub.mjs +237 -0
  47. package/workflow/pipeline-workflows.mjs +287 -0
  48. package/workflow/pipeline.mjs +828 -315
  49. package/workflow/workflow-cli.mjs +128 -0
  50. package/workflow/workflow-engine.mjs +329 -17
  51. package/workflow/workflow-nodes/custom-loader.mjs +250 -0
  52. package/workflow/workflow-nodes.mjs +1955 -223
  53. package/workflow/workflow-templates.mjs +26 -8
  54. package/workflow-templates/agents.mjs +0 -1
  55. package/workflow-templates/bosun-native.mjs +212 -2
  56. package/workflow-templates/continuation-loop.mjs +339 -0
  57. package/workflow-templates/github.mjs +516 -40
  58. package/workflow-templates/planning.mjs +446 -17
  59. package/workflow-templates/reliability.mjs +65 -12
  60. package/workflow-templates/task-batch.mjs +24 -8
  61. package/workflow-templates/task-lifecycle.mjs +83 -6
  62. package/workspace/context-cache.mjs +66 -18
  63. package/workspace/workspace-manager.mjs +2 -1
  64. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -13,9 +13,11 @@
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs";
15
15
  import { resolve, dirname } from "node:path";
16
+ import { randomBytes } from "node:crypto";
16
17
  import { fileURLToPath } from "node:url";
17
18
  import { buildSessionInsights } from "../lib/session-insights.mjs";
18
19
  import { isTestRuntime } from "./test-runtime.mjs";
20
+ import { addCompletedSession } from "./runtime-accumulator.mjs";
19
21
 
20
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
23
  const SESSIONS_DIR = resolve(__dirname, "..", "logs", "sessions");
@@ -37,6 +39,15 @@ const MAX_MESSAGE_CHARS = 100_000;
37
39
 
38
40
  /** Maximum total sessions to keep in memory. */
39
41
  const MAX_SESSIONS = 100;
42
+ const TERMINAL_SESSION_STATUSES = new Set(["completed", "failed", "idle", "archived"]);
43
+
44
+ function isTerminalSessionStatus(status) {
45
+ return TERMINAL_SESSION_STATUSES.has(String(status || "").trim().toLowerCase());
46
+ }
47
+
48
+ function randomToken(length = 8) {
49
+ return randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
50
+ }
40
51
 
41
52
  function resolveSessionTrackerPersistDir(options = {}) {
42
53
  if (options.persistDir !== undefined) {
@@ -182,6 +193,7 @@ export class SessionTracker {
182
193
  taskId,
183
194
  taskTitle,
184
195
  id: taskId,
196
+ sessionKey: `${taskId}:${Date.now()}:${randomToken(8)}`,
185
197
  type: "task",
186
198
  maxMessages: this.#maxMessages,
187
199
  startedAt: Date.now(),
@@ -192,6 +204,7 @@ export class SessionTracker {
192
204
  totalEvents: 0,
193
205
  turnCount: 0,
194
206
  status: "active",
207
+ accumulatedAt: null,
195
208
  lastActivityAt: Date.now(),
196
209
  metadata: {},
197
210
  insights: buildSessionInsights({ messages: [] }),
@@ -255,7 +268,7 @@ export class SessionTracker {
255
268
  // Direct message format (role/content)
256
269
  if (event && event.role && event.content !== undefined) {
257
270
  const msg = {
258
- id: event.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
271
+ id: event.id || `msg-${Date.now()}-${randomToken(6)}`,
259
272
  type: event.type || undefined,
260
273
  role: event.role,
261
274
  content: String(event.content).slice(0, MAX_MESSAGE_CHARS),
@@ -312,25 +325,8 @@ export class SessionTracker {
312
325
  session.endedAt = Date.now();
313
326
  session.status = status;
314
327
  this.#refreshDerivedState(session);
328
+ this.#accumulateCompletedSession(session, taskId);
315
329
  this.#markDirty(taskId);
316
-
317
- // Lazy import to avoid top-level await issues
318
- import("./runtime-accumulator.mjs")
319
- .then((module) => {
320
- if (module.addCompletedSession) {
321
- module.addCompletedSession({
322
- id: taskId,
323
- taskId: taskId,
324
- taskTitle: session.taskTitle,
325
- executor: session.executor,
326
- model: session.model,
327
- startedAt: session.startedAt,
328
- endedAt: session.endedAt,
329
- status: status,
330
- });
331
- }
332
- })
333
- .catch(() => {});
334
330
  }
335
331
 
336
332
  /**
@@ -494,6 +490,8 @@ export class SessionTracker {
494
490
  * @param {string} taskId
495
491
  */
496
492
  removeSession(taskId) {
493
+ const session = this.#sessions.get(taskId);
494
+ this.#accumulateCompletedSession(session, taskId);
497
495
  this.#sessions.delete(taskId);
498
496
  this.#dirty.delete(taskId);
499
497
  // Remove persisted session file if it exists
@@ -525,7 +523,7 @@ export class SessionTracker {
525
523
  * Create a new session with explicit options.
526
524
  * @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
527
525
  */
528
- createSession({ id, type = "manual", taskId, metadata = {}, maxMessages }) {
526
+ createSession({ id, type = "manual", taskId, metadata = {}, maxMessages, sessionKey }) {
529
527
  // Evict oldest non-active sessions if at capacity
530
528
  if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(id)) {
531
529
  this.#evictOldest();
@@ -542,6 +540,9 @@ export class SessionTracker {
542
540
  id,
543
541
  taskId: taskId || id,
544
542
  taskTitle: metadata.title || id,
543
+ sessionKey:
544
+ String(sessionKey || "").trim() ||
545
+ `${taskId || id}:${Date.now()}:${randomToken(8)}`,
545
546
  type,
546
547
  status: "active",
547
548
  createdAt: now,
@@ -552,6 +553,7 @@ export class SessionTracker {
552
553
  totalEvents: 0,
553
554
  turnCount: 0,
554
555
  lastActivityAt: Date.now(),
556
+ accumulatedAt: null,
555
557
  metadata,
556
558
  maxMessages: resolvedMax,
557
559
  insights: buildSessionInsights({ messages: [] }),
@@ -620,10 +622,11 @@ export class SessionTracker {
620
622
  const session = this.#sessions.get(sessionId);
621
623
  if (!session) return;
622
624
  session.status = status;
623
- if (status === "completed" || status === "archived") {
625
+ if (status === "completed" || status === "archived" || status === "failed" || status === "idle") {
624
626
  session.endedAt = Date.now();
625
627
  }
626
628
  this.#refreshDerivedState(session);
629
+ this.#accumulateCompletedSession(session, sessionId);
627
630
  this.#markDirty(sessionId);
628
631
  }
629
632
 
@@ -696,7 +699,7 @@ export class SessionTracker {
696
699
  return { ok: false, error: "Only user messages can be edited" };
697
700
  }
698
701
 
699
- target.id = target.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
702
+ target.id = target.id || `msg-${Date.now()}-${randomToken(6)}`;
700
703
  target.content = nextContent.slice(0, MAX_MESSAGE_CHARS);
701
704
  target.edited = true;
702
705
  target.editedAt = new Date().toISOString();
@@ -816,6 +819,7 @@ export class SessionTracker {
816
819
  const type = event._sessionType || "task";
817
820
  this.createSession({
818
821
  id: taskId,
822
+ sessionKey: `${taskId}:${Date.now()}:${randomToken(8)}`,
819
823
  type,
820
824
  taskId,
821
825
  metadata: { autoCreated: true },
@@ -837,7 +841,8 @@ export class SessionTracker {
837
841
  return (a[1].lastActivityAt || a[1].startedAt) - (b[1].lastActivityAt || b[1].startedAt);
838
842
  });
839
843
  const toEvict = sorted.slice(0, evictCount);
840
- for (const [id] of toEvict) {
844
+ for (const [id, session] of toEvict) {
845
+ this.#accumulateCompletedSession(session, id);
841
846
  this.#sessions.delete(id);
842
847
  }
843
848
  }
@@ -857,6 +862,7 @@ export class SessionTracker {
857
862
  session.status = "completed";
858
863
  session.endedAt = now;
859
864
  this.#refreshDerivedState(session);
865
+ this.#accumulateCompletedSession(session, id);
860
866
  this.#markDirty(id);
861
867
  reaped++;
862
868
  }
@@ -880,6 +886,43 @@ export class SessionTracker {
880
886
  }
881
887
  }
882
888
 
889
+ #accumulateCompletedSession(session, fallbackTaskId = "") {
890
+ if (!session || session.accumulatedAt) return false;
891
+ if (!isTerminalSessionStatus(session.status)) return false;
892
+ const taskId = String(session.taskId || session.id || fallbackTaskId || "").trim();
893
+ if (!taskId) return false;
894
+
895
+ const now = Date.now();
896
+ const endedAt = Number.isFinite(Number(session.endedAt)) && Number(session.endedAt) > 0
897
+ ? Number(session.endedAt)
898
+ : now;
899
+ const startedAt = Number.isFinite(Number(session.startedAt))
900
+ ? Number(session.startedAt)
901
+ : endedAt;
902
+ const tokenUsage = session.insights?.tokenUsage || null;
903
+
904
+ addCompletedSession({
905
+ id: session.id || taskId,
906
+ sessionId: session.id || taskId,
907
+ sessionKey: session.sessionKey || `${taskId}:${startedAt}:${endedAt}`,
908
+ taskId,
909
+ taskTitle: session.taskTitle,
910
+ executor: session.executor,
911
+ model: session.model,
912
+ startedAt,
913
+ endedAt,
914
+ durationMs: Math.max(0, endedAt - startedAt),
915
+ tokenCount: tokenUsage?.totalTokens || 0,
916
+ inputTokens: tokenUsage?.inputTokens || 0,
917
+ outputTokens: tokenUsage?.outputTokens || 0,
918
+ tokenUsage,
919
+ insights: session.insights || null,
920
+ status: String(session.status || "completed"),
921
+ });
922
+ session.accumulatedAt = new Date().toISOString();
923
+ return true;
924
+ }
925
+
883
926
  #refreshDerivedState(session) {
884
927
  if (!session) return;
885
928
  try {
@@ -917,10 +960,14 @@ export class SessionTracker {
917
960
  taskId: session.taskId,
918
961
  title: session.taskTitle || session.title || null,
919
962
  taskTitle: session.taskTitle || null,
963
+ sessionKey: session.sessionKey || null,
920
964
  type: session.type || "task",
921
965
  status: session.status,
922
966
  createdAt: session.createdAt || new Date(session.startedAt).toISOString(),
923
967
  lastActiveAt: session.lastActiveAt || new Date(session.lastActivityAt).toISOString(),
968
+ startedAt: session.startedAt || null,
969
+ endedAt: session.endedAt || null,
970
+ accumulatedAt: session.accumulatedAt || null,
924
971
  turnCount: session.turnCount || 0,
925
972
  messages: session.messages || [],
926
973
  metadata: session.metadata || {},
@@ -987,20 +1034,30 @@ export class SessionTracker {
987
1034
  this.#sessions.set(id, {
988
1035
  id,
989
1036
  taskId: data.taskId || id,
990
- taskTitle: data.metadata?.title || id,
1037
+ taskTitle: data.taskTitle || data.title || data.metadata?.title || id,
1038
+ sessionKey:
1039
+ String(data.sessionKey || "").trim() ||
1040
+ `${data.taskId || id}:${data.startedAt || Date.now()}:${endedAt || data.startedAt || Date.now()}`,
991
1041
  type: data.type || "task",
992
1042
  status,
993
1043
  createdAt: data.createdAt || new Date().toISOString(),
994
1044
  lastActiveAt: data.lastActiveAt || new Date().toISOString(),
995
- startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
1045
+ startedAt: data.startedAt || (data.createdAt ? new Date(data.createdAt).getTime() : Date.now()),
996
1046
  endedAt,
997
1047
  messages: data.messages || [],
998
1048
  totalEvents: (data.messages || []).length,
999
1049
  turnCount: data.turnCount || 0,
1050
+ accumulatedAt: data.accumulatedAt || null,
1000
1051
  lastActivityAt: lastActive || Date.now(),
1001
1052
  metadata: data.metadata || {},
1002
1053
  insights: data.insights || buildSessionInsights({ messages: data.messages || [] }),
1003
1054
  });
1055
+ const restored = this.#sessions.get(id);
1056
+ if (restored && isTerminalSessionStatus(restored.status) && !restored.accumulatedAt) {
1057
+ if (this.#accumulateCompletedSession(restored, id)) {
1058
+ this.#markDirty(id);
1059
+ }
1060
+ }
1004
1061
  }
1005
1062
  } catch {
1006
1063
  // Directory read failed — proceed without disk data
@@ -23,8 +23,6 @@ const SOURCE_TYPES = new Map([
23
23
 
24
24
  const SUMMARY_MARKERS = ["CLAUDE:SUMMARY", "BOSUN:SUMMARY"];
25
25
  const WARN_MARKERS = ["CLAUDE:WARN", "BOSUN:WARN"];
26
- const SUMMARY_LINE_RE = /^\s*(?:\/\/|#)\s*(?:CLAUDE|BOSUN):SUMMARY\b/i;
27
- const WARN_LINE_RE = /^\s*(?:\/\/|#)\s*(?:CLAUDE|BOSUN):WARN\b/i;
28
26
  const GENERATED_PATTERNS = [
29
27
  /^\.git(?:\/|$)/,
30
28
  /^node_modules(?:\/|$)/,
@@ -151,6 +149,101 @@ function getSourceType(pathValue) {
151
149
  return SOURCE_TYPES.get(extname(pathValue).toLowerCase()) || null;
152
150
  }
153
151
 
152
+ function parseImportSpecifiers(content, language) {
153
+ if (language !== "javascript" && language !== "typescript") return [];
154
+ const specs = new Set();
155
+ const importRegex = /import\s+(?:[^"'`]+?\s+from\s+)?["'`]([^"'`]+)["'`]/g;
156
+ const dynamicImportRegex = /import\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
157
+ const requireRegex = /require\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
158
+ for (const pattern of [importRegex, dynamicImportRegex, requireRegex]) {
159
+ let match;
160
+ while ((match = pattern.exec(content)) !== null) {
161
+ if (match[1]) specs.add(match[1]);
162
+ }
163
+ pattern.lastIndex = 0;
164
+ }
165
+ return [...specs];
166
+ }
167
+
168
+ function resolveLocalImportPath(repoRoot, fromFile, specifier) {
169
+ if (!specifier || !specifier.startsWith(".")) return "";
170
+ const fromDir = dirname(fromFile);
171
+ const absoluteBase = resolve(fromDir, specifier);
172
+ const candidates = [
173
+ absoluteBase,
174
+ `${absoluteBase}.js`,
175
+ `${absoluteBase}.mjs`,
176
+ `${absoluteBase}.cjs`,
177
+ `${absoluteBase}.ts`,
178
+ `${absoluteBase}.tsx`,
179
+ `${absoluteBase}.jsx`,
180
+ resolve(absoluteBase, "index.js"),
181
+ resolve(absoluteBase, "index.mjs"),
182
+ resolve(absoluteBase, "index.cjs"),
183
+ resolve(absoluteBase, "index.ts"),
184
+ resolve(absoluteBase, "index.tsx"),
185
+ resolve(absoluteBase, "index.jsx"),
186
+ ];
187
+ for (const candidate of candidates) {
188
+ const info = safeStat(candidate);
189
+ if (info?.isFile()) return toPosix(relative(repoRoot, candidate));
190
+ }
191
+ return "";
192
+ }
193
+
194
+ function findCycleMembers(edgesByFile) {
195
+ const states = new Map();
196
+ const stack = [];
197
+ const inCycles = new Set();
198
+
199
+ function visit(node) {
200
+ const state = states.get(node) || 0;
201
+ if (state === 2) return;
202
+ if (state === 1) {
203
+ const cycleStart = stack.lastIndexOf(node);
204
+ if (cycleStart >= 0) {
205
+ for (let index = cycleStart; index < stack.length; index += 1) inCycles.add(stack[index]);
206
+ inCycles.add(node);
207
+ }
208
+ return;
209
+ }
210
+ states.set(node, 1);
211
+ stack.push(node);
212
+ for (const dep of edgesByFile.get(node) || []) visit(dep);
213
+ stack.pop();
214
+ states.set(node, 2);
215
+ }
216
+
217
+ for (const node of edgesByFile.keys()) visit(node);
218
+ return inCycles;
219
+ }
220
+
221
+ function appendCircularDependencyWarnings(files, repoRoot, warningKinds) {
222
+ const sourceSet = new Set(files.map((file) => file.path));
223
+ const edgesByFile = new Map();
224
+ for (const file of files) {
225
+ if (file.language !== "javascript" && file.language !== "typescript") continue;
226
+ const deps = [];
227
+ for (const specifier of file.importSpecifiers || []) {
228
+ const resolved = resolveLocalImportPath(repoRoot, file.absolutePath, specifier);
229
+ if (resolved && sourceSet.has(resolved)) deps.push(resolved);
230
+ }
231
+ edgesByFile.set(file.path, deps);
232
+ }
233
+ const cycleMembers = findCycleMembers(edgesByFile);
234
+ for (const file of files) {
235
+ if (!cycleMembers.has(file.path)) continue;
236
+ if (file.warnings.some((warning) => warning.kind === "circular-deps")) continue;
237
+ file.warnings.push({
238
+ kind: "circular-deps",
239
+ text: "Module participates in circular dependency chains; avoid reordering imports or eager top-level side effects.",
240
+ functionName: "__module__",
241
+ lineIndex: file.firstFunctionLine,
242
+ });
243
+ warningKinds["circular-deps"] = (warningKinds["circular-deps"] || 0) + 1;
244
+ }
245
+ }
246
+
154
247
  function detectCategory(relPath) {
155
248
  if (/(^|\/)(tests?|__tests__|fixtures?|sandbox)(\/|$)|\.(test|spec)\./i.test(relPath)) return "test";
156
249
  if (/(^|\/)(config|configs)(\/|$)|(^|\/)(AGENTS|CLAUDE)\.md$/i.test(relPath)) return "config";
@@ -159,15 +252,20 @@ function detectCategory(relPath) {
159
252
  return "core";
160
253
  }
161
254
 
162
- function hasMarker(content, markers) {
163
- const matcher = markers === SUMMARY_MARKERS ? SUMMARY_LINE_RE : WARN_LINE_RE;
164
- return content.split(/\r?\n/).some((line) => matcher.test(line));
255
+ function isAnnotationLine(line, markers) {
256
+ const trimmed = String(line || "").trim();
257
+ if (!trimmed.startsWith("//") && !trimmed.startsWith("#")) return false;
258
+ return markers.some((marker) => new RegExp(`\\b${marker}\\b`).test(trimmed));
165
259
  }
166
260
 
167
- function extractAnnotationLines(content) {
261
+ function extractAnnotationLines(content, markers = SUMMARY_MARKERS) {
168
262
  const lines = content.split(/\r?\n/);
169
- const summaryLine = lines.find((line) => SUMMARY_LINE_RE.test(line)) || "";
170
- const warnLines = lines.filter((line) => WARN_LINE_RE.test(line));
263
+ return lines.filter((line) => isAnnotationLine(line, markers));
264
+ }
265
+
266
+ function collectAnnotations(content) {
267
+ const summaryLine = extractAnnotationLines(content, SUMMARY_MARKERS)[0] || "";
268
+ const warnLines = extractAnnotationLines(content, WARN_MARKERS);
171
269
  return { summaryLine, warnLines };
172
270
  }
173
271
 
@@ -210,7 +308,8 @@ export function scanRepository(rootDir, options = {}) {
210
308
  if (isGeneratedPath(relPath)) return;
211
309
 
212
310
  const content = readText(absolutePath);
213
- const annotations = extractAnnotationLines(content);
311
+ const contentLines = content.split(/\r?\n/);
312
+ const annotations = collectAnnotations(content);
214
313
  const functionMatches = findFunctionMatches(content, sourceType.language);
215
314
  const warnings = analyzeFileWarnings(content, sourceType.language, functionMatches);
216
315
  for (const warning of warnings) {
@@ -223,12 +322,14 @@ export function scanRepository(rootDir, options = {}) {
223
322
  language: sourceType.language,
224
323
  extension: extname(absolutePath).toLowerCase(),
225
324
  comment: sourceType.comment,
226
- lines: content === "" ? 0 : content.split(/\r?\n/).length,
325
+ lines: content === "" ? 0 : contentLines.length,
227
326
  category: detectCategory(relPath),
228
- hasSummary: hasMarker(content, SUMMARY_MARKERS),
229
- hasWarn: hasMarker(content, WARN_MARKERS),
327
+ hasSummary: annotations.summaryLine !== "",
328
+ hasWarn: annotations.warnLines.length > 0,
230
329
  summaryLine: annotations.summaryLine,
231
330
  warnLines: annotations.warnLines,
331
+ importSpecifiers: parseImportSpecifiers(content, sourceType.language),
332
+ firstFunctionLine: functionMatches[0]?.lineIndex ?? findInsertionIndex(contentLines),
232
333
  warnings,
233
334
  });
234
335
  }
@@ -261,6 +362,7 @@ export function scanRepository(rootDir, options = {}) {
261
362
  const relPath = toPosix(relative(repoRoot, targetDir));
262
363
  if (getSourceType(targetDir) && !isGeneratedPath(relPath)) addFile(targetDir);
263
364
  }
365
+ appendCircularDependencyWarnings(files, repoRoot, warningKinds);
264
366
 
265
367
  const result = {
266
368
  rootDir: repoRoot,
@@ -432,7 +534,7 @@ function hasNearbyWarn(lines, lineIndex) {
432
534
  const start = Math.max(0, lineIndex - 2);
433
535
  const end = Math.min(lines.length - 1, lineIndex + 1);
434
536
  for (let index = start; index <= end; index += 1) {
435
- if (WARN_LINE_RE.test(lines[index])) return true;
537
+ if (isAnnotationLine(lines[index], WARN_MARKERS)) return true;
436
538
  }
437
539
  return false;
438
540
  }
@@ -447,8 +549,9 @@ export function generateWarnings(rootDir, options = {}) {
447
549
  const inserts = [];
448
550
  for (const warning of file.warnings) {
449
551
  if (hasNearbyWarn(lines, warning.lineIndex)) continue;
552
+ const preferredIndex = Number.isInteger(warning.lineIndex) ? warning.lineIndex : findInsertionIndex(lines);
450
553
  inserts.push({
451
- index: warning.functionName ? warning.lineIndex : findInsertionIndex(lines),
554
+ index: warning.functionName ? preferredIndex : findInsertionIndex(lines),
452
555
  text: buildCommentLine(file.comment, "CLAUDE:WARN", warning.text),
453
556
  });
454
557
  }
@@ -618,7 +721,8 @@ function findStaleWarnings(content, language) {
618
721
  const matcher = patterns[language];
619
722
  if (!matcher) return stale;
620
723
  for (let index = 0; index < lines.length; index += 1) {
621
- if (!WARN_LINE_RE.test(lines[index])) continue;
724
+ if (!isAnnotationLine(lines[index], WARN_MARKERS)) continue;
725
+ if (/circular dependency/i.test(lines[index])) continue;
622
726
  const window = lines.slice(index + 1, index + 5).join("\n");
623
727
  if (!matcher.test(window)) stale.push(index + 1);
624
728
  }
@@ -666,9 +770,21 @@ export function runConformity(rootDir, options = {}) {
666
770
 
667
771
  export function migrateAnnotations(rootDir, options = {}) {
668
772
  const scan = scanRepository(rootDir, options);
773
+ const migrateLegacyAnnotationMarkers = (content) =>
774
+ content
775
+ .replace(/BOSUN:SUMMARY/g, "CLAUDE:SUMMARY")
776
+ .replace(/BOSUN:WARN/g, "CLAUDE:WARN")
777
+ .replace(
778
+ /^(\s*(?:\/\/|#)\s*)(?:LEGACY:)?SUMMARY\s*[:\-]\s*/gim,
779
+ "$1CLAUDE:SUMMARY ",
780
+ )
781
+ .replace(
782
+ /^(\s*(?:\/\/|#)\s*)(?:LEGACY:)?WARN(?:ING)?\s*[:\-]\s*/gim,
783
+ "$1CLAUDE:WARN ",
784
+ );
669
785
  const changed = updateFiles(
670
- scan.files.filter((file) => file.hasSummary || file.hasWarn),
671
- (file, content) => content.replace(/BOSUN:SUMMARY/g, "CLAUDE:SUMMARY").replace(/BOSUN:WARN/g, "CLAUDE:WARN"),
786
+ scan.files,
787
+ (file, content) => migrateLegacyAnnotationMarkers(content),
672
788
  options,
673
789
  );
674
790
  return {
@@ -804,4 +920,3 @@ export async function runAuditCli(argv, io = {}) {
804
920
  const shouldFail = command === "conformity" || Boolean(flags.ci);
805
921
  return { exitCode: shouldFail && result.ok === false ? 1 : 0, result };
806
922
  }
807
-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.41.0",
3
+ "version": "0.41.2",
4
4
  "description": "Bosun Autonomous Engineering — manages AI agent executors with failover, extremely powerful workflow builder, and a massive amount of included default workflow templates for autonomous engineering, 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",
@@ -71,10 +71,17 @@
71
71
  "./container-runner": "./infra/container-runner.mjs",
72
72
  "./compat": "./compat.mjs",
73
73
  "./task-cli": "./task/task-cli.mjs",
74
+ "./task-pipeline": "./task/pipeline.mjs",
75
+ "./task-msg-hub": "./task/msg-hub.mjs",
76
+ "./workflow-cli": "./workflow/workflow-cli.mjs",
77
+ "./pipeline-workflows": "./workflow/pipeline-workflows.mjs",
74
78
  "./github-auth-manager": "./github/github-auth-manager.mjs",
75
79
  "./git-commit-helpers": "./git/git-commit-helpers.mjs",
76
80
  "./opencode-shell": "./shell/opencode-shell.mjs",
77
- "./context-indexer": "./workspace/context-indexer.mjs"
81
+ "./context-indexer": "./workspace/context-indexer.mjs",
82
+ "./msg-hub": "./workflow/msg-hub.mjs",
83
+ "./declarative-workflows": "./workflow/declarative-workflows.mjs",
84
+ "./pipeline": "./workflow/pipeline.mjs"
78
85
  },
79
86
  "bin": {
80
87
  "bosun": "cli.mjs",
@@ -108,6 +115,8 @@
108
115
  "test:vitest": "node --max-old-space-size=4096 node_modules/vitest/vitest.mjs run --config vitest.config.mjs",
109
116
  "test:node": "node --import ./tests/node-test-bootstrap.mjs --test tests/*.node.test.mjs",
110
117
  "test:all": "npm run test:vitest && npm run test:node",
118
+ "test:e2e": "npx playwright test server/playwright-ui-e2e.mjs",
119
+ "test:e2e:all": "npx playwright test server/playwright-ui-e2e.mjs server/playwright-ui-smoke.mjs",
111
120
  "test:voice-provider-smoke": "vitest run --config vitest.config.mjs tests/voice-provider-smoke.test.mjs",
112
121
  "check:native-call-parity": "vitest run --config vitest.config.mjs tests/voice-provider-smoke.test.mjs tests/native-call-parity-checklist.test.mjs",
113
122
  "test:watch": "vitest",
@@ -147,6 +156,7 @@
147
156
  "agent/agent-custom-tools.mjs",
148
157
  "agent/agent-endpoint.mjs",
149
158
  "agent/agent-event-bus.mjs",
159
+ "agent/retry-queue.mjs",
150
160
  "agent/agent-pool.mjs",
151
161
  "agent/agent-prompts.mjs",
152
162
  "agent/agent-sdk.mjs",
@@ -200,7 +210,8 @@
200
210
  "infra/library-manager.mjs",
201
211
  "infra/maintenance.mjs",
202
212
  "workflow/manual-flows.mjs",
203
- "workflow-templates/code-quality.mjs",
213
+ "workflow/pipeline-workflows.mjs",
214
+ "workflow/workflow-cli.mjs",
204
215
  "workflow/mcp-discovery-proxy.mjs",
205
216
  "workflow/mcp-workflow-adapter.mjs",
206
217
  "workflow/mcp-registry.mjs",
@@ -242,7 +253,10 @@
242
253
  "task/task-claims.mjs",
243
254
  "task/task-context.mjs",
244
255
  "task/task-attachments.mjs",
256
+ "task/pipeline.mjs",
257
+ "task/msg-hub.mjs",
245
258
  "task/task-executor.mjs",
259
+ "task/task-executor-pipeline.mjs",
246
260
  "task/task-store.mjs",
247
261
  "telegram/telegram-bot.mjs",
248
262
  "server/ui-server.mjs",
@@ -290,6 +304,7 @@
290
304
  "workflow/workflow-engine.mjs",
291
305
  "workflow/workflow-migration.mjs",
292
306
  "workflow/workflow-nodes.mjs",
307
+ "workflow/workflow-nodes/custom-loader.mjs",
293
308
  "workflow/project-detection.mjs",
294
309
  "workflow/workflow-templates.mjs",
295
310
  "workflow/workflow-contract.mjs",
@@ -308,11 +323,15 @@
308
323
  "workflow-templates/task-execution.mjs",
309
324
  "workflow-templates/task-lifecycle.mjs",
310
325
  "workflow-templates/issue-continuation.mjs",
326
+ "workflow-templates/continuation-loop.mjs",
327
+ "workflow-templates/code-quality.mjs",
311
328
  "workflow-templates/bosun-native.mjs",
312
329
  "bosun-tui.mjs",
313
330
  "tui/",
314
331
  "tools/",
315
- "ui/vendor/"
332
+ "ui/vendor/",
333
+ "workflow/msg-hub.mjs",
334
+ "workflow/declarative-workflows.mjs"
316
335
  ],
317
336
  "dependencies": {
318
337
  "@anthropic-ai/claude-agent-sdk": "latest",
@@ -29,6 +29,7 @@ import {
29
29
  resolveWorkflowTemplateIds,
30
30
  normalizeTemplateOverridesById,
31
31
  } from "../workflow/workflow-templates.mjs";
32
+ import { discoverTelegramChats } from "../telegram/get-telegram-chat-id.mjs";
32
33
 
33
34
  const __dirname = dirname(fileURLToPath(import.meta.url));
34
35
 
@@ -1981,6 +1982,20 @@ function handleValidate(body) {
1981
1982
  return { ok: true, valid: Object.keys(errors).length === 0, errors };
1982
1983
  }
1983
1984
 
1985
+ async function handleTelegramChatIdLookup(body) {
1986
+ const token = String(body?.token || "").trim();
1987
+ if (!token) {
1988
+ return { ok: false, status: 400, error: "TELEGRAM_BOT_TOKEN is required" };
1989
+ }
1990
+
1991
+ try {
1992
+ const { chats, message } = await discoverTelegramChats(token);
1993
+ return { ok: true, status: 200, chats, message };
1994
+ } catch (err) {
1995
+ return { ok: false, status: 500, error: err.message || String(err) };
1996
+ }
1997
+ }
1998
+
1984
1999
  function handleApply(body) {
1985
2000
  try {
1986
2001
  const { env = {}, configJson = {} } = body || {};
@@ -2579,6 +2594,15 @@ async function handleRequest(req, res) {
2579
2594
  }
2580
2595
  jsonResponse(res, 200, handleValidate(await readBody(req)));
2581
2596
  return;
2597
+ case "telegram-chat-id": {
2598
+ if (req.method !== "POST") {
2599
+ jsonResponse(res, 405, { ok: false, error: "POST required" });
2600
+ return;
2601
+ }
2602
+ const result = await handleTelegramChatIdLookup(await readBody(req));
2603
+ jsonResponse(res, result.status, result);
2604
+ return;
2605
+ }
2582
2606
  case "apply":
2583
2607
  if (req.method !== "POST") {
2584
2608
  jsonResponse(res, 405, { ok: false, error: "POST required" });
@@ -2984,6 +3008,7 @@ export async function startSetupServer(options = {}) {
2984
3008
  export {
2985
3009
  applyTelegramMiniAppSetupEnv,
2986
3010
  applyNonBlockingSetupEnvDefaults,
3011
+ handleTelegramChatIdLookup,
2987
3012
  normalizeWorkflowTemplateOverrides,
2988
3013
  normalizeTelegramUiPort,
2989
3014
  normalizeRepoConfigEntry,