bosun 0.42.2 → 0.42.4

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 (49) hide show
  1. package/.env.example +9 -0
  2. package/agent/agent-event-bus.mjs +10 -0
  3. package/agent/agent-supervisor.mjs +20 -0
  4. package/bosun-tui.mjs +107 -105
  5. package/cli.mjs +10 -0
  6. package/config/config.mjs +25 -0
  7. package/config/executor-config.mjs +124 -1
  8. package/infra/container-runner.mjs +565 -1
  9. package/infra/monitor.mjs +18 -0
  10. package/infra/tracing.mjs +544 -240
  11. package/infra/tui-bridge.mjs +13 -1
  12. package/kanban/kanban-adapter.mjs +128 -4
  13. package/lib/repo-map.mjs +114 -3
  14. package/package.json +11 -4
  15. package/server/ui-server.mjs +3 -0
  16. package/task/task-archiver.mjs +18 -6
  17. package/task/task-attachments.mjs +14 -10
  18. package/task/task-cli.mjs +24 -4
  19. package/task/task-executor.mjs +19 -0
  20. package/task/task-store.mjs +194 -37
  21. package/telegram/telegram-bot.mjs +4 -1
  22. package/tui/app.mjs +131 -171
  23. package/tui/components/status-header.mjs +178 -75
  24. package/tui/lib/header-config.mjs +68 -0
  25. package/tui/lib/ws-bridge.mjs +61 -9
  26. package/tui/screens/agents.mjs +127 -0
  27. package/tui/screens/tasks.mjs +1 -48
  28. package/ui/app.js +8 -5
  29. package/ui/components/kanban-board.js +65 -3
  30. package/ui/components/session-list.js +18 -32
  31. package/ui/demo-defaults.js +52 -2
  32. package/ui/modules/session-api.js +100 -0
  33. package/ui/modules/state.js +71 -15
  34. package/ui/tabs/workflows.js +25 -1
  35. package/ui/tui/App.js +298 -0
  36. package/ui/tui/TasksScreen.js +564 -0
  37. package/ui/tui/constants.js +55 -0
  38. package/ui/tui/tasks-screen-helpers.js +301 -0
  39. package/ui/tui/useTasks.js +61 -0
  40. package/ui/tui/useWebSocket.js +166 -0
  41. package/ui/tui/useWorkflows.js +30 -0
  42. package/workflow/workflow-engine.mjs +412 -7
  43. package/workflow/workflow-nodes.mjs +616 -75
  44. package/workflow-templates/agents.mjs +3 -0
  45. package/workflow-templates/planning.mjs +7 -0
  46. package/workflow-templates/sub-workflows.mjs +5 -0
  47. package/workflow-templates/task-execution.mjs +3 -0
  48. package/workspace/command-diagnostics.mjs +1 -1
  49. package/workspace/context-cache.mjs +182 -9
@@ -18,6 +18,9 @@ const RATE_LIMIT_BUCKET_SCHEMA = {
18
18
  primary: { type: ["number", "null"] },
19
19
  secondary: { type: ["number", "null"] },
20
20
  credits: { type: ["number", "null"] },
21
+ primaryLimit: { type: ["number", "null"] },
22
+ secondaryLimit: { type: ["number", "null"] },
23
+ creditsLimit: { type: ["number", "null"] },
21
24
  unit: { type: "string", minLength: 1 },
22
25
  },
23
26
  };
@@ -74,6 +77,7 @@ export const TUI_EVENT_SCHEMAS = Object.freeze({
74
77
  "tokensIn",
75
78
  "tokensOut",
76
79
  "tokensTotal",
80
+ "totalTokens",
77
81
  "throughputTps",
78
82
  "uptimeMs",
79
83
  "rateLimits",
@@ -85,6 +89,7 @@ export const TUI_EVENT_SCHEMAS = Object.freeze({
85
89
  tokensIn: { type: "number", minimum: 0 },
86
90
  tokensOut: { type: "number", minimum: 0 },
87
91
  tokensTotal: { type: "number", minimum: 0 },
92
+ totalTokens: { type: "number", minimum: 0 },
88
93
  throughputTps: { type: "number", minimum: 0 },
89
94
  uptimeMs: { type: "number", minimum: 0 },
90
95
  rateLimits: {
@@ -208,6 +213,9 @@ function normalizeRateLimits(rateLimits = {}) {
208
213
  primary: Number.isFinite(Number(bucket.primary)) ? Number(bucket.primary) : null,
209
214
  secondary: Number.isFinite(Number(bucket.secondary)) ? Number(bucket.secondary) : null,
210
215
  credits: bucket.credits == null ? null : (Number.isFinite(Number(bucket.credits)) ? Number(bucket.credits) : null),
216
+ primaryLimit: bucket.primaryLimit == null ? null : (Number.isFinite(Number(bucket.primaryLimit)) ? Number(bucket.primaryLimit) : null),
217
+ secondaryLimit: bucket.secondaryLimit == null ? null : (Number.isFinite(Number(bucket.secondaryLimit)) ? Number(bucket.secondaryLimit) : null),
218
+ creditsLimit: bucket.creditsLimit == null ? null : (Number.isFinite(Number(bucket.creditsLimit)) ? Number(bucket.creditsLimit) : null),
211
219
  unit: String(bucket.unit || "count").trim() || "count",
212
220
  };
213
221
  }
@@ -237,7 +245,10 @@ export function buildMonitorStatsPayload({ agentPool, runtimeStats = {}, uptimeM
237
245
  agentPoolStats.tokensOut,
238
246
  runtimeStats.totalOutputTokens ?? runtimeTokenTotals.tokensOut,
239
247
  );
240
- const tokensTotal = nonNegativeNumber(agentPoolStats.tokensTotal, tokensIn + tokensOut);
248
+ const tokensTotal = nonNegativeNumber(
249
+ agentPoolStats.tokensTotal ?? agentPoolStats.totalTokens,
250
+ tokensIn + tokensOut,
251
+ );
241
252
  const resolvedUptimeMs = nonNegativeNumber(
242
253
  uptimeMs,
243
254
  runtimeStats.startedAt ? Date.now() - Number(runtimeStats.startedAt) : 0,
@@ -252,6 +263,7 @@ export function buildMonitorStatsPayload({ agentPool, runtimeStats = {}, uptimeM
252
263
  tokensIn,
253
264
  tokensOut,
254
265
  tokensTotal,
266
+ totalTokens: tokensTotal,
255
267
  throughputTps,
256
268
  uptimeMs: resolvedUptimeMs,
257
269
  rateLimits: normalizeRateLimits(agentPoolStats.rateLimits),
@@ -62,6 +62,7 @@ import {
62
62
  setTaskStatus as setInternalTaskStatus,
63
63
  removeTask as removeInternalTask,
64
64
  updateTask as patchInternalTask,
65
+ waitForStoreWrites,
65
66
  } from "../task/task-store.mjs";
66
67
  import {
67
68
  listTaskAttachments,
@@ -673,6 +674,101 @@ function extractMarkdownLinks(text) {
673
674
  return results;
674
675
  }
675
676
 
677
+ function normalizePrLinkageEntry(entry = {}, fallback = {}) {
678
+ const safeEntry = entry && typeof entry === "object" ? entry : {};
679
+ const safeFallback = fallback && typeof fallback === "object" ? fallback : {};
680
+ const branchName = typeof (safeEntry.branchName ?? safeFallback.branchName) === "string"
681
+ ? String(safeEntry.branchName ?? safeFallback.branchName).trim()
682
+ : "";
683
+ const prUrl = typeof (safeEntry.prUrl ?? safeFallback.prUrl) === "string"
684
+ ? String(safeEntry.prUrl ?? safeFallback.prUrl).trim()
685
+ : "";
686
+ const parsedPrNumber = Number.parseInt(String(safeEntry.prNumber ?? safeFallback.prNumber ?? ""), 10);
687
+ const prNumber = Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null;
688
+ if (!branchName && !prUrl && !prNumber) return null;
689
+ return {
690
+ branchName: branchName || null,
691
+ prUrl: prUrl || null,
692
+ prNumber,
693
+ source: typeof (safeEntry.source ?? safeFallback.source) === "string" && String(safeEntry.source ?? safeFallback.source).trim()
694
+ ? String(safeEntry.source ?? safeFallback.source).trim()
695
+ : null,
696
+ freshness: typeof (safeEntry.freshness ?? safeFallback.freshness) === "string" && String(safeEntry.freshness ?? safeFallback.freshness).trim()
697
+ ? String(safeEntry.freshness ?? safeFallback.freshness).trim()
698
+ : null,
699
+ linkedAt: typeof (safeEntry.linkedAt ?? safeFallback.linkedAt) === "string" && String(safeEntry.linkedAt ?? safeFallback.linkedAt).trim()
700
+ ? String(safeEntry.linkedAt ?? safeFallback.linkedAt).trim()
701
+ : null,
702
+ updatedAt: typeof (safeEntry.updatedAt ?? safeFallback.updatedAt) === "string" && String(safeEntry.updatedAt ?? safeFallback.updatedAt).trim()
703
+ ? String(safeEntry.updatedAt ?? safeFallback.updatedAt).trim()
704
+ : null,
705
+ };
706
+ }
707
+
708
+ function mergePrLinkageRecords(...sources) {
709
+ const merged = [];
710
+ const indexByKey = new Map();
711
+ const buildKey = (entry) => {
712
+ const branchName = String(entry?.branchName || "").trim().toLowerCase();
713
+ const prUrl = String(entry?.prUrl || "").trim().toLowerCase();
714
+ const prNumber = Number.isFinite(entry?.prNumber) ? entry.prNumber : "";
715
+ return [branchName, prNumber, prUrl].join("|");
716
+ };
717
+ for (const source of sources) {
718
+ const entries = Array.isArray(source) ? source : [];
719
+ for (const rawEntry of entries) {
720
+ const entry = normalizePrLinkageEntry(rawEntry);
721
+ if (!entry) continue;
722
+ const key = buildKey(entry);
723
+ if (!key) continue;
724
+ if (indexByKey.has(key)) {
725
+ const idx = indexByKey.get(key);
726
+ merged[idx] = normalizePrLinkageEntry({ ...merged[idx], ...entry }, merged[idx]);
727
+ continue;
728
+ }
729
+ indexByKey.set(key, merged.length);
730
+ merged.push(entry);
731
+ }
732
+ }
733
+ return merged;
734
+ }
735
+
736
+ function buildPrLinkagePatch(options = {}, currentTask = null) {
737
+ const branchName = typeof options?.branchName === "string" ? options.branchName.trim() : "";
738
+ const prUrl = typeof options?.prUrl === "string" ? options.prUrl.trim() : "";
739
+ const prNumber = options?.prNumber == null || options?.prNumber === ""
740
+ ? null
741
+ : Number.parseInt(String(options.prNumber), 10);
742
+ const source = typeof options?.source === "string" && options.source.trim() ? options.source.trim() : null;
743
+ const freshness = typeof options?.freshness === "string" && options.freshness.trim() ? options.freshness.trim() : null;
744
+ const currentMeta = currentTask?.meta && typeof currentTask.meta === "object" ? currentTask.meta : {};
745
+ const currentLinkage = mergePrLinkageRecords(currentTask?.prLinkage, currentMeta?.prLinkage);
746
+ const nextEntry = normalizePrLinkageEntry({
747
+ branchName,
748
+ prUrl,
749
+ prNumber: Number.isFinite(prNumber) && prNumber > 0 ? prNumber : null,
750
+ source,
751
+ freshness,
752
+ updatedAt: new Date().toISOString(),
753
+ linkedAt: currentLinkage[0]?.linkedAt || new Date().toISOString(),
754
+ }, currentTask || {});
755
+ if (!nextEntry) return {};
756
+ const prLinkage = mergePrLinkageRecords(currentLinkage, [nextEntry]);
757
+ return {
758
+ ...(nextEntry.branchName ? { branchName: nextEntry.branchName } : {}),
759
+ ...(nextEntry.prUrl ? { prUrl: nextEntry.prUrl } : {}),
760
+ ...(Number.isFinite(nextEntry.prNumber) && nextEntry.prNumber > 0 ? { prNumber: nextEntry.prNumber } : {}),
761
+ prLinkage,
762
+ meta: {
763
+ ...currentMeta,
764
+ prLinkage,
765
+ prLinkageSource: nextEntry.source || currentMeta.prLinkageSource || null,
766
+ prLinkageFreshness: nextEntry.freshness || currentMeta.prLinkageFreshness || null,
767
+ prLinkageUpdatedAt: nextEntry.updatedAt || currentMeta.prLinkageUpdatedAt || null,
768
+ },
769
+ };
770
+ }
771
+
676
772
  function extractAttachmentsFromText(text, meta = {}) {
677
773
  const links = extractMarkdownLinks(text);
678
774
  const attachments = [];
@@ -792,6 +888,8 @@ class InternalAdapter {
792
888
  existingAttachments,
793
889
  localAttachments,
794
890
  );
891
+ const prLinkage = mergePrLinkageRecords(task.prLinkage, task.meta?.prLinkage, [normalizePrLinkageEntry(task, task)]);
892
+ const primaryPrLinkage = prLinkage[0] || null;
795
893
  return {
796
894
  id: String(task.id || ""),
797
895
  title: recoveredTitle || "",
@@ -816,9 +914,10 @@ class InternalAdapter {
816
914
  ? task.meta.repositories
817
915
  : [],
818
916
  baseBranch,
819
- branchName: task.branchName || null,
820
- prNumber: task.prNumber || null,
821
- prUrl: task.prUrl || null,
917
+ branchName: task.branchName || primaryPrLinkage?.branchName || null,
918
+ prLinkage,
919
+ prNumber: task.prNumber || primaryPrLinkage?.prNumber || null,
920
+ prUrl: task.prUrl || primaryPrLinkage?.prUrl || null,
822
921
  taskUrl: task.taskUrl || null,
823
922
  createdAt: task.createdAt || null,
824
923
  updatedAt: task.updatedAt || null,
@@ -836,6 +935,10 @@ class InternalAdapter {
836
935
  statusHistory: Array.isArray(task.statusHistory) ? task.statusHistory : (Array.isArray(task.meta?.statusHistory) ? task.meta.statusHistory : []),
837
936
  comments: normalizedComments,
838
937
  attachments: mergedAttachments,
938
+ prLinkage,
939
+ prLinkageSource: primaryPrLinkage?.source || task.meta?.prLinkageSource || null,
940
+ prLinkageFreshness: primaryPrLinkage?.freshness || task.meta?.prLinkageFreshness || null,
941
+ prLinkageUpdatedAt: primaryPrLinkage?.updatedAt || task.meta?.prLinkageUpdatedAt || null,
839
942
  },
840
943
  };
841
944
  }
@@ -895,7 +998,8 @@ class InternalAdapter {
895
998
  if (!updated) {
896
999
  throw new Error(`[kanban] internal task not found: ${normalizedId}`);
897
1000
  }
898
- const linkagePatch = {};
1001
+ const current = getInternalTask(normalizedId);
1002
+ const linkagePatch = buildPrLinkagePatch(options, current);
899
1003
  const branchName =
900
1004
  typeof options?.branchName === "string" ? options.branchName.trim() : "";
901
1005
  const prUrl = typeof options?.prUrl === "string" ? options.prUrl.trim() : "";
@@ -908,8 +1012,10 @@ class InternalAdapter {
908
1012
  if (Number.isFinite(prNumber) && prNumber > 0) linkagePatch.prNumber = prNumber;
909
1013
  if (Object.keys(linkagePatch).length > 0) {
910
1014
  const patched = patchInternalTask(normalizedId, linkagePatch);
1015
+ await waitForStoreWrites();
911
1016
  if (patched) return this._normalizeTask(patched);
912
1017
  }
1018
+ await waitForStoreWrites();
913
1019
  return this._normalizeTask(updated);
914
1020
  }
915
1021
 
@@ -1018,10 +1124,26 @@ class InternalAdapter {
1018
1124
  baseBranch,
1019
1125
  };
1020
1126
  }
1127
+ const directLinkage = mergePrLinkageRecords(current?.prLinkage, current?.meta?.prLinkage, patch.prLinkage, patch.meta?.prLinkage, [normalizePrLinkageEntry(patch, patch)]);
1128
+ if (directLinkage.length > 0) {
1129
+ const primaryPrLinkage = directLinkage[0] || null;
1130
+ updates.prLinkage = directLinkage;
1131
+ updates.branchName = updates.branchName ?? primaryPrLinkage?.branchName ?? null;
1132
+ updates.prUrl = updates.prUrl ?? primaryPrLinkage?.prUrl ?? null;
1133
+ updates.prNumber = updates.prNumber ?? primaryPrLinkage?.prNumber ?? null;
1134
+ updates.meta = {
1135
+ ...(updates.meta || current?.meta || {}),
1136
+ prLinkage: directLinkage,
1137
+ prLinkageSource: primaryPrLinkage?.source || updates.meta?.prLinkageSource || current?.meta?.prLinkageSource || null,
1138
+ prLinkageFreshness: primaryPrLinkage?.freshness || updates.meta?.prLinkageFreshness || current?.meta?.prLinkageFreshness || null,
1139
+ prLinkageUpdatedAt: primaryPrLinkage?.updatedAt || updates.meta?.prLinkageUpdatedAt || current?.meta?.prLinkageUpdatedAt || null,
1140
+ };
1141
+ }
1021
1142
  const updated = patchInternalTask(normalizedId, updates);
1022
1143
  if (!updated) {
1023
1144
  throw new Error(`[kanban] internal task not found: ${normalizedId}`);
1024
1145
  }
1146
+ await waitForStoreWrites();
1025
1147
  return this._normalizeTask(updated);
1026
1148
  }
1027
1149
 
@@ -1108,6 +1230,7 @@ class InternalAdapter {
1108
1230
  if (!created) {
1109
1231
  throw new Error("[kanban] internal task creation failed");
1110
1232
  }
1233
+ await waitForStoreWrites();
1111
1234
  return this._normalizeTask(created);
1112
1235
  }
1113
1236
 
@@ -6198,3 +6321,4 @@ export async function unmarkTaskIgnored(taskId) {
6198
6321
  );
6199
6322
  return false;
6200
6323
  }
6324
+
package/lib/repo-map.mjs CHANGED
@@ -3,6 +3,7 @@ import { extname, join, resolve } from "node:path";
3
3
 
4
4
  const DEFAULT_FILE_LIMIT = 12;
5
5
  const DEFAULT_MAX_SYMBOLS = 4;
6
+ const DEFAULT_TOPOLOGY_SUMMARY_LIMIT = 96;
6
7
  const QUERY_STOP_WORDS = new Set([
7
8
  "about", "after", "again", "agent", "along", "also", "architect", "before", "being", "bosun",
8
9
  "build", "changes", "check", "code", "create", "debug", "editor", "ensure", "feature", "files",
@@ -35,6 +36,24 @@ function uniqueStrings(values) {
35
36
  return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value || "").trim()).filter(Boolean))];
36
37
  }
37
38
 
39
+ function truncateText(value, limit = DEFAULT_TOPOLOGY_SUMMARY_LIMIT) {
40
+ const text = String(value || "").trim();
41
+ const max = toPositiveInt(limit, DEFAULT_TOPOLOGY_SUMMARY_LIMIT);
42
+ if (!text || text.length <= max) return text;
43
+ if (max <= 3) return text.slice(0, max);
44
+ const ellipsis = "...";
45
+ const sliceLength = Math.max(1, max - ellipsis.length);
46
+ const nextWordBreak = text.indexOf(" ", sliceLength);
47
+ if (nextWordBreak > sliceLength && nextWordBreak - sliceLength <= 12) {
48
+ return `${text.slice(0, nextWordBreak).trimEnd()}${ellipsis}`;
49
+ }
50
+ const truncated = text.slice(0, sliceLength);
51
+ const lastWordBreak = truncated.lastIndexOf(" ");
52
+ const prefersWordBoundary = lastWordBreak >= Math.floor(sliceLength * 0.6);
53
+ const display = prefersWordBoundary ? truncated.slice(0, lastWordBreak) : truncated;
54
+ return `${display.trimEnd()}${ellipsis}`;
55
+ }
56
+
38
57
  function isTokenWordChar(char) {
39
58
  if (!char) return false;
40
59
  const code = char.charCodeAt(0);
@@ -132,6 +151,92 @@ export function formatRepoMap(repoMap, opts = {}) {
132
151
  return lines.join("\n");
133
152
  }
134
153
 
154
+ function inferRepoOwner(pathValue) {
155
+ const normalizedPath = String(pathValue || "").trim().replace(/\\/g, "/");
156
+ if (!normalizedPath) return "root";
157
+ const segments = normalizedPath.split("/");
158
+ if (segments.length === 1) return "root";
159
+ const [owner] = segments;
160
+ return owner || "root";
161
+ }
162
+
163
+ function normalizeAdjacencyStem(pathValue) {
164
+ return String(pathValue || "")
165
+ .trim()
166
+ .replace(/\\/g, "/")
167
+ .split("/")
168
+ .pop()?.toLowerCase()
169
+ .replace(/(\.node)?\.test\.[^.]+$/i, "")
170
+ .replace(/\.spec\.[^.]+$/i, "")
171
+ .replace(/\.runtime\.[^.]+$/i, "")
172
+ .replace(/\.[^.]+$/i, "")
173
+ || "";
174
+ }
175
+
176
+ function scoreRepoAdjacency(left, right) {
177
+ if (!left || !right || left.path === right.path) return -1;
178
+ const leftPath = String(left.path || "").replace(/\\/g, "/");
179
+ const rightPath = String(right.path || "").replace(/\\/g, "/");
180
+ const leftDir = leftPath.includes("/") ? leftPath.split("/").slice(0, -1).join("/") : "";
181
+ const rightDir = rightPath.includes("/") ? rightPath.split("/").slice(0, -1).join("/") : "";
182
+ const leftOwner = inferRepoOwner(leftPath);
183
+ const rightOwner = inferRepoOwner(rightPath);
184
+ const leftStem = normalizeAdjacencyStem(leftPath);
185
+ const rightStem = normalizeAdjacencyStem(rightPath);
186
+ let score = 0;
187
+ if (leftDir && leftDir === rightDir) score += 60;
188
+ if (leftOwner && leftOwner === rightOwner) score += 25;
189
+ if (leftStem && leftStem === rightStem) score += 40;
190
+ if ((leftOwner === "tests" || rightOwner === "tests") && leftStem && leftStem === rightStem) score += 30;
191
+ return score;
192
+ }
193
+
194
+ export function hasRepoMapContext(value = "") {
195
+ const text = String(value || "");
196
+ return text.includes("## Repo Topology") || text.includes("## Repo Map");
197
+ }
198
+
199
+ export function formatRepoTopology(repoMap, opts = {}) {
200
+ const normalized = normalizeRepoMap(repoMap, opts);
201
+ if (!normalized) return "";
202
+ const files = Array.isArray(normalized.files) ? normalized.files : [];
203
+ const lines = [String(opts.title || "## Repo Topology")];
204
+ const summaryLimit = toPositiveInt(opts.repoMapSummaryLimit || opts.summaryLimit, DEFAULT_TOPOLOGY_SUMMARY_LIMIT);
205
+ if (normalized.root) lines.push(`- Root: ${normalized.root}`);
206
+ if (files.length === 0) return lines.join("\n");
207
+
208
+ const areaCounts = new Map();
209
+ for (const file of files) {
210
+ const owner = inferRepoOwner(file.path);
211
+ areaCounts.set(owner, (areaCounts.get(owner) || 0) + 1);
212
+ }
213
+ const areas = [...areaCounts.entries()]
214
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
215
+ .map(([owner, count]) => `${owner} (${count})`);
216
+ if (areas.length) lines.push(`- Areas: ${areas.join(", ")}`);
217
+
218
+ const adjacencyLimit = toPositiveInt(opts.repoMapAdjacencyLimit || opts.adjacencyLimit, 2);
219
+ for (const file of files) {
220
+ const owner = inferRepoOwner(file.path);
221
+ const summary = truncateText(file.summary, summaryLimit);
222
+ const adjacent = files
223
+ .map((candidate) => ({ path: candidate.path, score: scoreRepoAdjacency(file, candidate) }))
224
+ .filter((candidate) => candidate.score > 0)
225
+ .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path))
226
+ .slice(0, adjacencyLimit)
227
+ .map((candidate) => candidate.path);
228
+ const parts = [file.path, `owner: ${owner}`];
229
+ if (summary) parts.push(summary);
230
+ if (adjacent.length) parts.push(`adjacent: ${adjacent.join(", ")}`);
231
+ lines.push(`- ${parts.join(" — ")}`);
232
+ }
233
+ return lines.join("\n");
234
+ }
235
+
236
+ export function buildRepoTopologyContext(options = {}) {
237
+ return formatRepoTopology(buildRepoMap(options), options);
238
+ }
239
+
135
240
  function resolveRootDir(options = {}) {
136
241
  const explicit = String(options.rootDir || options.repoRoot || options.cwd || "").trim();
137
242
  if (explicit) return explicit.replace(/\\/g, "/");
@@ -378,7 +483,9 @@ export function inferExecutionRole(options = {}, effectiveMode = "agent") {
378
483
 
379
484
  export function buildArchitectEditorFrame(options = {}, effectiveMode = "agent") {
380
485
  const executionRole = inferExecutionRole(options, effectiveMode);
381
- const repoMapBlock = formatRepoMap(buildRepoMap(options), options);
486
+ const repoMapBlock = options.includeRepoMap === false
487
+ ? ""
488
+ : formatRepoTopology(buildRepoMap(options), options);
382
489
  const architectPlan = String(options.architectPlan || options.planSummary || "").trim();
383
490
  const lines = ["## Architect/Editor Execution"];
384
491
 
@@ -386,14 +493,14 @@ export function buildArchitectEditorFrame(options = {}, effectiveMode = "agent")
386
493
  lines.push(
387
494
  "You are the architect phase.",
388
495
  "Do not implement code changes in this phase.",
389
- "Use the repo map to produce a compact structural plan that an editor can execute and validate.",
496
+ "Use the repo topology to produce a compact structural plan that an editor can execute and validate.",
390
497
  "Editor handoff: include ordered implementation steps, touched files, risks, and validation guidance.",
391
498
  );
392
499
  } else if (executionRole === "editor") {
393
500
  lines.push(
394
501
  "You are the editor phase.",
395
502
  "Implement the approved plan with focused edits and verification.",
396
- "Prefer the supplied repo map over broad rediscovery unless validation reveals drift.",
503
+ "Prefer the supplied repo topology over broad rediscovery unless validation reveals drift.",
397
504
  );
398
505
  if (architectPlan) {
399
506
  lines.push("", "## Architect Plan", architectPlan);
@@ -409,3 +516,7 @@ export function buildArchitectEditorFrame(options = {}, effectiveMode = "agent")
409
516
  return lines.join("\n");
410
517
  }
411
518
 
519
+
520
+
521
+
522
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.42.2",
3
+ "version": "0.42.4",
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",
@@ -383,7 +383,16 @@
383
383
  "preact": "10.25.4",
384
384
  "qrcode-terminal": "^0.12.0",
385
385
  "vibe-kanban": "latest",
386
- "ws": "^8.19.0"
386
+ "ws": "^8.19.0",
387
+ "@opentelemetry/api": "^1.9.0",
388
+ "@opentelemetry/exporter-trace-otlp-http": "^0.206.0",
389
+ "@opentelemetry/resources": "^2.1.0",
390
+ "@opentelemetry/sdk-metrics": "^2.1.0",
391
+ "@opentelemetry/sdk-trace-base": "^2.1.0",
392
+ "@opentelemetry/semantic-conventions": "^1.37.0",
393
+ "figures": "^6.1.0",
394
+ "react": "^18.3.1",
395
+ "react-dom": "^18.3.1"
387
396
  },
388
397
  "devDependencies": {
389
398
  "@emotion/react": "^11.14.0",
@@ -391,8 +400,6 @@
391
400
  "@mui/material": "^5.18.0",
392
401
  "@playwright/test": "^1.58.2",
393
402
  "playwright": "^1.58.2",
394
- "react": "^18.3.1",
395
- "react-dom": "^18.3.1",
396
403
  "vitest": "^4.0.18"
397
404
  },
398
405
  "engines": {
@@ -11988,6 +11988,9 @@ async function listReplayableAgentRuns(options = {}) {
11988
11988
  async function readReplayableAgentRun(attemptId) {
11989
11989
  const normalizedAttemptId = String(attemptId || "").trim();
11990
11990
  if (!normalizedAttemptId) return null;
11991
+ // Ensure attemptId cannot perform path traversal or escape the intended directory.
11992
+ // Only allow simple identifiers composed of letters, digits, underscore, and dash.
11993
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalizedAttemptId)) return null;
11991
11994
  const filePath = resolve(resolveAgentWorkLogDir(), "agent-sessions", `${normalizedAttemptId}.jsonl`);
11992
11995
  if (!existsSync(filePath)) return null;
11993
11996
  const entries = await readJsonlTail(filePath, 20000, 25000000).catch(() => []);
@@ -31,6 +31,7 @@ import { existsSync } from "node:fs";
31
31
  import { resolve, dirname } from "node:path";
32
32
  import { fileURLToPath } from "node:url";
33
33
  import { randomBytes } from "node:crypto";
34
+ import { normalizeTaskStorageRecord } from "./task-store.mjs";
34
35
 
35
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
36
37
 
@@ -173,7 +174,7 @@ export async function archiveTaskToFile(
173
174
  }
174
175
 
175
176
  const archiveEntry = {
176
- task,
177
+ task: normalizeTaskStorageRecord(task),
177
178
  attempt: attemptData,
178
179
  archived_at: new Date().toISOString(),
179
180
  archiver_version: 3,
@@ -470,7 +471,7 @@ export async function migrateLegacyArchives(archiveDir = ARCHIVE_DIR) {
470
471
  for (const legacyFile of fileNames) {
471
472
  try {
472
473
  const raw = await readFile(resolve(archiveDir, legacyFile), "utf8");
473
- const entry = JSON.parse(raw);
474
+ const entry = normalizeArchiveEntry(JSON.parse(raw));
474
475
  const taskId = entry?.task?.id;
475
476
 
476
477
  if (taskId && existingIds.has(taskId)) {
@@ -663,8 +664,9 @@ export async function loadArchivedTasks(options = {}) {
663
664
  // Daily grouped file: array of entries
664
665
  if (Array.isArray(data)) {
665
666
  for (const entry of data) {
666
- if (matchesFilters(entry, since, until, statusFilter)) {
667
- archivedTasks.push(entry);
667
+ const normalizedEntry = normalizeArchiveEntry(entry);
668
+ if (matchesFilters(normalizedEntry, since, until, statusFilter)) {
669
+ archivedTasks.push(normalizedEntry);
668
670
  }
669
671
  }
670
672
  continue;
@@ -672,8 +674,9 @@ export async function loadArchivedTasks(options = {}) {
672
674
 
673
675
  // Legacy single-task file: object with { task, archived_at, ... }
674
676
  if (data && typeof data === "object" && data.task) {
675
- if (matchesFilters(data, since, until, statusFilter)) {
676
- archivedTasks.push(data);
677
+ const normalizedEntry = normalizeArchiveEntry(data);
678
+ if (matchesFilters(normalizedEntry, since, until, statusFilter)) {
679
+ archivedTasks.push(normalizedEntry);
677
680
  }
678
681
  }
679
682
  } catch {
@@ -701,6 +704,15 @@ function matchesFilters(entry, since, until, statusFilter) {
701
704
  return true;
702
705
  }
703
706
 
707
+ function normalizeArchiveEntry(entry) {
708
+ if (!entry || typeof entry !== "object") return null;
709
+ const normalized = { ...entry };
710
+ if (normalized.task && typeof normalized.task === "object") {
711
+ normalized.task = normalizeTaskStorageRecord(normalized.task);
712
+ }
713
+ return normalized;
714
+ }
715
+
704
716
  /**
705
717
  * Generate sprint review report from archived tasks.
706
718
  * @param {object[]} archivedTasks
@@ -16,6 +16,11 @@ import {
16
16
  renameSync,
17
17
  } from "node:fs";
18
18
  import { resolveRepoRoot } from "../config/repo-root.mjs";
19
+ import {
20
+ getTaskAttachmentCanonicalKey,
21
+ normalizeTaskAttachmentRecord,
22
+ normalizeTaskAttachments,
23
+ } from "./task-store.mjs";
19
24
 
20
25
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
26
  const TAG = "[task-attachments]";
@@ -70,17 +75,12 @@ function taskKey(taskId, backend) {
70
75
  }
71
76
 
72
77
  function attachmentKey(att) {
73
- if (!att) return "";
74
- if (att.url) return `url:${att.url}`;
75
- if (att.filePath) return `file:${att.filePath}`;
76
- if (att.path) return `path:${att.path}`;
77
- if (att.id) return `id:${att.id}`;
78
- return `raw:${JSON.stringify(att)}`;
78
+ return getTaskAttachmentCanonicalKey(att);
79
79
  }
80
80
 
81
81
  function normalizeAttachment(att, backend) {
82
- if (!att || typeof att !== "object") return null;
83
- const normalized = { ...att };
82
+ const normalized = normalizeTaskAttachmentRecord(att);
83
+ if (!normalized || typeof normalized !== "object") return null;
84
84
  if (!normalized.id) normalized.id = randomUUID();
85
85
  if (!normalized.createdAt) normalized.createdAt = nowIso();
86
86
  if (!normalized.source) normalized.source = "upload";
@@ -133,6 +133,11 @@ export function loadStore() {
133
133
  if (!_store || typeof _store !== "object") {
134
134
  _store = defaultStore();
135
135
  }
136
+ const tasks = _store.tasks && typeof _store.tasks === "object" ? _store.tasks : {};
137
+ for (const entry of Object.values(tasks)) {
138
+ if (!entry || typeof entry !== "object") continue;
139
+ entry.attachments = normalizeTaskAttachments(entry.attachments);
140
+ }
136
141
  } catch (err) {
137
142
  console.warn(`${TAG} failed to read store: ${err.message || err}`);
138
143
  _store = defaultStore();
@@ -162,7 +167,7 @@ export function listTaskAttachments(taskId, backend = "internal") {
162
167
  if (!key) return [];
163
168
  const entry = store.tasks?.[key];
164
169
  const attachments = Array.isArray(entry?.attachments) ? entry.attachments : [];
165
- return attachments.slice();
170
+ return normalizeTaskAttachments(attachments);
166
171
  }
167
172
 
168
173
  export function addTaskAttachment(taskId, backend, attachment) {
@@ -184,4 +189,3 @@ export function addTaskAttachment(taskId, backend, attachment) {
184
189
  saveStore();
185
190
  return normalized;
186
191
  }
187
-
package/task/task-cli.mjs CHANGED
@@ -306,8 +306,7 @@ function isDebugModeEnabled(args = []) {
306
306
  * Create a new task. Accepts a plain object with task fields.
307
307
  * Returns the created task or throws on error.
308
308
  */
309
- export async function taskCreate(data) {
310
- const store = await initStore();
309
+ function buildTaskInput(data, store) {
311
310
  const normalizeKey = store.normalizeWorkspaceStorageKey || normalizeStoreScopeKey;
312
311
  const normalizeKeys =
313
312
  store.normalizeWorkspaceStorageKeys
@@ -388,6 +387,22 @@ export async function taskCreate(data) {
388
387
  taskData.description = parts.join("\n");
389
388
  }
390
389
 
390
+ return taskData;
391
+ }
392
+
393
+ function buildImportedTaskInput(data, store) {
394
+ const taskData = buildTaskInput(data, store);
395
+ return {
396
+ ...data,
397
+ ...taskData,
398
+ meta: taskData.meta,
399
+ };
400
+ }
401
+
402
+ export async function taskCreate(data) {
403
+ const store = await initStore();
404
+ const taskData = buildTaskInput(data, store);
405
+
391
406
  const result = store.addTask(taskData);
392
407
  if (!result) {
393
408
  throw new Error(`Failed to create task — addTask returned null`);
@@ -1099,10 +1114,16 @@ export async function taskImport(source) {
1099
1114
  let created = 0;
1100
1115
  let failed = 0;
1101
1116
  const errors = [];
1117
+ const store = await initStore();
1102
1118
 
1103
1119
  for (const t of tasks) {
1104
1120
  try {
1105
- await taskCreate(t);
1121
+ const importedTask = buildImportedTaskInput(t, store);
1122
+ const result = store.addTask(importedTask);
1123
+ if (!result) {
1124
+ throw new Error("Failed to create task — addTask returned null");
1125
+ }
1126
+ await flushStoreWrites(store);
1106
1127
  created++;
1107
1128
  } catch (err) {
1108
1129
  failed++;
@@ -1822,4 +1843,3 @@ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
1822
1843
  process.exit(1);
1823
1844
  });
1824
1845
  }
1825
-
@@ -66,6 +66,7 @@ import {
66
66
  } from "./task-store.mjs";
67
67
  import { createErrorDetector } from "../infra/error-detector.mjs";
68
68
  import { getSessionTracker } from "../infra/session-tracker.mjs";
69
+ import { addSpanLink } from "../infra/tracing.mjs";
69
70
  import {
70
71
  getCompactDiffSummary,
71
72
  getRecentCommits,
@@ -4365,6 +4366,24 @@ class TaskExecutor {
4365
4366
  };
4366
4367
  }
4367
4368
 
4369
+ getTuiStats() {
4370
+ const tokenTotals = Array.from(attemptTelemetry.values()).reduce((acc, telemetry) => {
4371
+ acc.tokensIn += Math.max(0, Number(telemetry?.prompt_tokens || 0) || 0);
4372
+ acc.tokensOut += Math.max(0, Number(telemetry?.completion_tokens || 0) || 0);
4373
+ acc.tokensTotal += Math.max(0, Number(telemetry?.total_tokens || 0) || 0);
4374
+ return acc;
4375
+ }, { tokensIn: 0, tokensOut: 0, tokensTotal: 0 });
4376
+
4377
+ return {
4378
+ activeAgents: this._activeSlots.size,
4379
+ maxAgents: this.maxParallel,
4380
+ tokensIn: tokenTotals.tokensIn,
4381
+ tokensOut: tokenTotals.tokensOut,
4382
+ tokensTotal: tokenTotals.tokensTotal || (tokenTotals.tokensIn + tokenTotals.tokensOut),
4383
+ rateLimits: {},
4384
+ };
4385
+ }
4386
+
4368
4387
  getBacklogReplenishmentConfig() {
4369
4388
  return {
4370
4389
  ...this._backlogReplenishment,