bosun 0.33.2 → 0.33.3

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
@@ -979,6 +979,11 @@ COPILOT_CLOUD_DISABLED=true
979
979
  # ─── Merge Strategy / Conflict Resolution ────────────────────────────────────
980
980
  # Merge strategy mode: "smart" or "smart+codexsdk" (enables Codex conflict resolution)
981
981
  # MERGE_STRATEGY_MODE=smart
982
+ # Flow primary mode (default: true). When enabled, merge actions are gated by
983
+ # Flow sequencing rules instead of immediate merge-strategy execution.
984
+ # BOSUN_FLOW_PRIMARY=true
985
+ # Require review approval before any merge action can be enabled (default: true).
986
+ # BOSUN_FLOW_REQUIRE_REVIEW=true
982
987
  # Codex conflict resolution timeout in ms
983
988
  # MERGE_CONFLICT_RESOLUTION_TIMEOUT_MS=600000
984
989
 
package/README.md CHANGED
@@ -69,6 +69,7 @@ Key references:
69
69
  - [GitHub Projects v2 monitoring](_docs/GITHUB_PROJECTS_V2_MONITORING.md)
70
70
  - [GitHub Projects v2 checklist](_docs/GITHUB_PROJECTS_V2_IMPLEMENTATION_CHECKLIST.md)
71
71
  - [Jira integration](_docs/JIRA_INTEGRATION.md)
72
+ - [Workflows](_docs/WORKFLOWS.md)
72
73
  - [Agent logging quickstart](docs/agent-logging-quickstart.md)
73
74
  - [Agent logging design](docs/agent-work-logging-design.md)
74
75
  - [Agent logging summary](docs/AGENT_LOGGING_SUMMARY.md)
@@ -607,6 +607,8 @@ export function resetMergeStrategyDedup() {
607
607
  * @param {number} [opts.timeoutMs] Timeout for agent operations (default: 15 min)
608
608
  * @param {number} [opts.maxRetries] Max retries for re_attempt (default: 2)
609
609
  * @param {object} [opts.promptTemplates] Optional prompt template overrides
610
+ * @param {function} [opts.canEnableMerge] Optional async gate for merge action.
611
+ * Signature: ({ decision, ctx }) => Promise<{ allowed: boolean, reason?: string }>
610
612
  * @returns {Promise<ExecutionResult>}
611
613
  */
612
614
  export async function executeDecision(decision, ctx, opts = {}) {
@@ -616,6 +618,7 @@ export async function executeDecision(decision, ctx, opts = {}) {
616
618
  timeoutMs = 15 * 60 * 1000,
617
619
  maxRetries = 2,
618
620
  promptTemplates = {},
621
+ canEnableMerge,
619
622
  } = opts;
620
623
 
621
624
  const tag = `merge-exec(${ctx.shortId})`;
@@ -648,7 +651,11 @@ export async function executeDecision(decision, ctx, opts = {}) {
648
651
  });
649
652
 
650
653
  case "merge_after_ci_pass":
651
- return await executeMergeAction(decision, ctx, { tag, onTelegram });
654
+ return await executeMergeAction(decision, ctx, {
655
+ tag,
656
+ onTelegram,
657
+ canEnableMerge,
658
+ });
652
659
 
653
660
  case "close_pr":
654
661
  return await executeCloseAction(decision, ctx, { tag, onTelegram });
@@ -988,7 +995,39 @@ function buildReAttemptPrompt(ctx, reason, promptTemplate = "") {
988
995
  * Enable auto-merge for the PR via `gh pr merge --auto --squash`.
989
996
  */
990
997
  async function executeMergeAction(decision, ctx, execOpts) {
991
- const { tag, onTelegram } = execOpts;
998
+ const { tag, onTelegram, canEnableMerge } = execOpts;
999
+
1000
+ if (typeof canEnableMerge === "function") {
1001
+ try {
1002
+ const gate = await canEnableMerge({ decision, ctx });
1003
+ if (gate && gate.allowed === false) {
1004
+ const reason = String(gate.reason || "merge_gate_blocked");
1005
+ console.warn(
1006
+ `[${tag}] merge_after_ci_pass blocked by merge gate: ${reason}`,
1007
+ );
1008
+ if (onTelegram) {
1009
+ onTelegram(
1010
+ `⛔ Merge blocked for ${ctx.taskTitle || `PR #${ctx.prNumber || "unknown"}`}: ${reason}`,
1011
+ );
1012
+ }
1013
+ return {
1014
+ executed: false,
1015
+ action: "merge_after_ci_pass",
1016
+ success: false,
1017
+ error: `FLOW_REVIEW_GATE:${reason}`,
1018
+ };
1019
+ }
1020
+ } catch (err) {
1021
+ const msg = err?.message || String(err || "merge gate error");
1022
+ console.warn(`[${tag}] merge gate threw: ${msg}`);
1023
+ return {
1024
+ executed: false,
1025
+ action: "merge_after_ci_pass",
1026
+ success: false,
1027
+ error: `FLOW_REVIEW_GATE_ERROR:${msg}`,
1028
+ };
1029
+ }
1030
+ }
992
1031
 
993
1032
  if (!ctx.prNumber) {
994
1033
  console.warn(`[${tag}] merge_after_ci_pass but no PR number`);
package/monitor.mjs CHANGED
@@ -730,6 +730,22 @@ function isReviewAgentEnabled() {
730
730
  return true;
731
731
  }
732
732
 
733
+ function isFlowPrimaryEnabled() {
734
+ const explicit = process.env.BOSUN_FLOW_PRIMARY;
735
+ if (explicit !== undefined && String(explicit).trim() !== "") {
736
+ return !isFalsyFlag(explicit);
737
+ }
738
+ return !isFalsyFlag(flowPrimaryDefault);
739
+ }
740
+
741
+ function isFlowReviewGateEnabled() {
742
+ const explicit = process.env.BOSUN_FLOW_REQUIRE_REVIEW;
743
+ if (explicit !== undefined && String(explicit).trim() !== "") {
744
+ return !isFalsyFlag(explicit);
745
+ }
746
+ return !isFalsyFlag(flowRequireReviewDefault);
747
+ }
748
+
733
749
  function isMonitorMonitorEnabled() {
734
750
  if (process.env.VITEST) return false;
735
751
  if (!isDevMode()) return false;
@@ -968,6 +984,8 @@ const codexAnalyzeMergeStrategy =
968
984
  const mergeStrategyMode = String(
969
985
  process.env.MERGE_STRATEGY_MODE || "smart",
970
986
  ).toLowerCase();
987
+ const flowPrimaryDefault = "true";
988
+ const flowRequireReviewDefault = "true";
971
989
  const codexResolveConflictsEnabled =
972
990
  agentPoolEnabled &&
973
991
  (process.env.CODEX_RESOLVE_CONFLICTS || "true").toLowerCase() !== "false";
@@ -4784,7 +4802,16 @@ async function checkMergedPRsAndUpdateTasks() {
4784
4802
  (c) => c.branch && mergedBranchCache.has(c.branch),
4785
4803
  );
4786
4804
  if (knownBranch) {
4805
+ const canFinalize = await shouldFinalizeMergedTask(task, {
4806
+ branch: knownBranch.branch,
4807
+ prNumber: knownBranch.prNumber,
4808
+ reason: "known_merged_branch",
4809
+ });
4810
+ if (!canFinalize) {
4811
+ continue;
4812
+ }
4787
4813
  mergedTaskCache.add(task.id);
4814
+ pendingMergeStrategyByTask.delete(String(task.id || "").trim());
4788
4815
  // Cache all branches for this task
4789
4816
  for (const c of candidates) {
4790
4817
  if (c.branch) mergedBranchCache.add(c.branch);
@@ -4831,9 +4858,19 @@ async function checkMergedPRsAndUpdateTasks() {
4831
4858
  console.log(
4832
4859
  `[monitor] Task "${task.title}" (${task.id.substring(0, 8)}...) has merged PR #${cand.prNumber}, updating to done [confidence=${confidence.confidence}, ${confidence.reason}]`,
4833
4860
  );
4861
+ const canFinalize = await shouldFinalizeMergedTask(task, {
4862
+ branch: cand.branch,
4863
+ prNumber: cand.prNumber,
4864
+ reason: "merged_pr_detected",
4865
+ });
4866
+ if (!canFinalize) {
4867
+ resolved = true;
4868
+ break;
4869
+ }
4834
4870
  const success = await updateTaskStatus(task.id, "done");
4835
4871
  movedCount++;
4836
4872
  mergedTaskCache.add(task.id);
4873
+ pendingMergeStrategyByTask.delete(String(task.id || "").trim());
4837
4874
  for (const c of candidates) {
4838
4875
  if (c.branch) mergedBranchCache.add(c.branch);
4839
4876
  }
@@ -4890,9 +4927,19 @@ async function checkMergedPRsAndUpdateTasks() {
4890
4927
  console.log(
4891
4928
  `[monitor] Task "${task.title}" (${task.id.substring(0, 8)}...) has merged branch ${cand.branch}, updating to done`,
4892
4929
  );
4930
+ const canFinalize = await shouldFinalizeMergedTask(task, {
4931
+ branch: cand.branch,
4932
+ prNumber: cand.prNumber,
4933
+ reason: "merged_branch_detected",
4934
+ });
4935
+ if (!canFinalize) {
4936
+ resolved = true;
4937
+ break;
4938
+ }
4893
4939
  const success = await updateTaskStatus(task.id, "done");
4894
4940
  movedCount++;
4895
4941
  mergedTaskCache.add(task.id);
4942
+ pendingMergeStrategyByTask.delete(String(task.id || "").trim());
4896
4943
  for (const c of candidates) {
4897
4944
  if (c.branch) mergedBranchCache.add(c.branch);
4898
4945
  }
@@ -6064,6 +6111,137 @@ async function checkAndMergeDependabotPRs() {
6064
6111
 
6065
6112
  // ── Merge Strategy Analysis ─────────────────────────────────────────────────
6066
6113
 
6114
+ function getReviewGateSnapshot(taskId) {
6115
+ const id = String(taskId || "").trim();
6116
+ if (!id) return null;
6117
+
6118
+ const runtime = reviewGateResults.get(id);
6119
+ if (runtime) {
6120
+ return {
6121
+ approved: runtime.approved === true,
6122
+ reviewedAt: runtime.reviewedAt || null,
6123
+ source: "runtime",
6124
+ };
6125
+ }
6126
+
6127
+ try {
6128
+ const task = getInternalTask(id);
6129
+ if (!task) return null;
6130
+ return {
6131
+ approved: task.reviewStatus === "approved",
6132
+ reviewedAt: task.reviewedAt || null,
6133
+ source: "task-store",
6134
+ };
6135
+ } catch {
6136
+ return null;
6137
+ }
6138
+ }
6139
+
6140
+ function isTaskReviewApprovedForFlow(taskId) {
6141
+ const snapshot = getReviewGateSnapshot(taskId);
6142
+ return snapshot?.approved === true;
6143
+ }
6144
+
6145
+ function rememberPendingMergeStrategy(ctx, reason = "review_pending") {
6146
+ const taskId = String(ctx?.taskId || "").trim();
6147
+ if (!taskId) return false;
6148
+ pendingMergeStrategyByTask.set(taskId, {
6149
+ ...ctx,
6150
+ _flowDeferredAt: new Date().toISOString(),
6151
+ _flowDeferredReason: reason,
6152
+ });
6153
+ return true;
6154
+ }
6155
+
6156
+ function dequeuePendingMergeStrategy(taskId) {
6157
+ const id = String(taskId || "").trim();
6158
+ if (!id) return null;
6159
+ const ctx = pendingMergeStrategyByTask.get(id) || null;
6160
+ if (ctx) pendingMergeStrategyByTask.delete(id);
6161
+ return ctx;
6162
+ }
6163
+
6164
+ async function queueFlowReview(taskId, ctx, reason = "") {
6165
+ if (!reviewAgent) return false;
6166
+ const id = String(taskId || "").trim();
6167
+ if (!id) return false;
6168
+ try {
6169
+ const prUrl =
6170
+ ctx?.prUrl ||
6171
+ (ctx?.prNumber ? `${repoUrlBase}/pull/${ctx.prNumber}` : "");
6172
+ await reviewAgent.queueReview({
6173
+ id,
6174
+ title: ctx?.taskTitle || id,
6175
+ branchName: ctx?.branch || "",
6176
+ prUrl,
6177
+ description: ctx?.taskDescription || "",
6178
+ taskContext: reason ? `Flow gate reason: ${reason}` : "",
6179
+ worktreePath: ctx?.worktreeDir || null,
6180
+ sessionMessages: "",
6181
+ diffStats: "",
6182
+ });
6183
+ return true;
6184
+ } catch (err) {
6185
+ console.warn(
6186
+ `[flow-gate] failed to queue review for ${id}: ${err.message || err}`,
6187
+ );
6188
+ return false;
6189
+ }
6190
+ }
6191
+
6192
+ async function canEnableMergeForFlow(ctx) {
6193
+ if (!isFlowPrimaryEnabled() || !isFlowReviewGateEnabled()) {
6194
+ return { allowed: true };
6195
+ }
6196
+
6197
+ const taskId = String(ctx?.taskId || "").trim();
6198
+ if (!taskId) {
6199
+ return { allowed: false, reason: "missing_task_id_for_review_gate" };
6200
+ }
6201
+
6202
+ if (!isTaskReviewApprovedForFlow(taskId)) {
6203
+ return { allowed: false, reason: "task_review_not_approved" };
6204
+ }
6205
+
6206
+ return { allowed: true };
6207
+ }
6208
+
6209
+ async function shouldFinalizeMergedTask(task, context = {}) {
6210
+ if (!isFlowPrimaryEnabled() || !isFlowReviewGateEnabled()) {
6211
+ return true;
6212
+ }
6213
+
6214
+ const taskId = String(task?.id || "").trim();
6215
+ if (!taskId) return false;
6216
+ if (isTaskReviewApprovedForFlow(taskId)) return true;
6217
+
6218
+ const branch = context.branch || task?.branch || task?.workspace_branch || "";
6219
+ const prNumber = context.prNumber || task?.pr_number || null;
6220
+ const reason = context.reason || "merged_before_review";
6221
+ console.log(
6222
+ `[flow-gate] Task "${task?.title || taskId}" merged but review is not approved yet — holding in inreview`,
6223
+ );
6224
+ const currentStatus = String(task?.status || "").toLowerCase();
6225
+ if (currentStatus && currentStatus !== "inreview") {
6226
+ try {
6227
+ await updateTaskStatus(taskId, "inreview");
6228
+ } catch {
6229
+ /* best effort */
6230
+ }
6231
+ }
6232
+ await queueFlowReview(taskId, {
6233
+ taskId,
6234
+ taskTitle: task?.title || taskId,
6235
+ taskDescription: task?.description || task?.body || "",
6236
+ branch,
6237
+ prNumber,
6238
+ prUrl:
6239
+ context.prUrl ||
6240
+ (prNumber ? `${repoUrlBase}/pull/${prNumber}` : task?.pr_url || ""),
6241
+ }, reason);
6242
+ return false;
6243
+ }
6244
+
6067
6245
  /**
6068
6246
  * Run the Codex-powered merge strategy analysis for a completed task.
6069
6247
  * This is fire-and-forget (void) — it runs async in the background and
@@ -6071,9 +6249,29 @@ async function checkAndMergeDependabotPRs() {
6071
6249
  *
6072
6250
  * @param {import("./merge-strategy.mjs").MergeContext} ctx
6073
6251
  */
6074
- async function runMergeStrategyAnalysis(ctx) {
6252
+ async function runMergeStrategyAnalysis(ctx, opts = {}) {
6075
6253
  const tag = `merge-strategy(${ctx.shortId})`;
6076
6254
  try {
6255
+ const skipFlowGate = opts?.skipFlowGate === true;
6256
+ const flowGateEnabled = isFlowPrimaryEnabled() && isFlowReviewGateEnabled();
6257
+ if (!skipFlowGate && flowGateEnabled) {
6258
+ const taskId = String(ctx?.taskId || "").trim();
6259
+ if (!taskId) {
6260
+ console.warn(
6261
+ `[${tag}] flow gate: missing taskId — deferring merge strategy until task mapping is available`,
6262
+ );
6263
+ return;
6264
+ }
6265
+ if (!isTaskReviewApprovedForFlow(taskId)) {
6266
+ rememberPendingMergeStrategy(ctx, "review_pending");
6267
+ await queueFlowReview(taskId, ctx, "merge_strategy_waiting_for_review");
6268
+ console.log(
6269
+ `[${tag}] flow gate: deferred until review is approved for task ${taskId}`,
6270
+ );
6271
+ return;
6272
+ }
6273
+ }
6274
+
6077
6275
  const telegramFn =
6078
6276
  telegramToken && telegramChatId
6079
6277
  ? (msg) => void sendTelegramMessage(msg)
@@ -6105,6 +6303,7 @@ async function runMergeStrategyAnalysis(ctx) {
6105
6303
  onTelegram: telegramFn,
6106
6304
  timeoutMs:
6107
6305
  parseInt(process.env.MERGE_STRATEGY_TIMEOUT_MS, 10) || 15 * 60 * 1000,
6306
+ canEnableMerge: ({ ctx: mergeCtx }) => canEnableMergeForFlow(mergeCtx),
6108
6307
  promptTemplates: {
6109
6308
  mergeStrategyFix: agentPrompts?.mergeStrategyFix,
6110
6309
  mergeStrategyReAttempt: agentPrompts?.mergeStrategyReAttempt,
@@ -6127,6 +6326,17 @@ async function runMergeStrategyAnalysis(ctx) {
6127
6326
 
6128
6327
  if (!execResult.success && execResult.error) {
6129
6328
  console.warn(`[${tag}] execution issue: ${execResult.error}`);
6329
+ if (
6330
+ String(execResult.error).startsWith("FLOW_REVIEW_GATE:") &&
6331
+ ctx?.taskId
6332
+ ) {
6333
+ rememberPendingMergeStrategy(ctx, "merge_blocked_by_review_gate");
6334
+ await queueFlowReview(
6335
+ ctx.taskId,
6336
+ ctx,
6337
+ "merge_action_blocked_by_review_gate",
6338
+ );
6339
+ }
6130
6340
  }
6131
6341
  } catch (err) {
6132
6342
  console.warn(
@@ -6753,12 +6963,32 @@ async function smartPRFlow(attemptId, shortId, status) {
6753
6963
  }
6754
6964
  const merged = await isBranchMerged(attemptInfo.branch);
6755
6965
  if (merged) {
6966
+ let canFinalize = true;
6967
+ if (attemptInfo.task_id) {
6968
+ canFinalize = await shouldFinalizeMergedTask(
6969
+ {
6970
+ id: attemptInfo.task_id,
6971
+ title: attemptInfo.task_title || attemptInfo.task_id,
6972
+ description: attemptInfo.task_description || "",
6973
+ pr_number: attemptInfo.pr_number || null,
6974
+ pr_url: attemptInfo.pr_url || "",
6975
+ },
6976
+ {
6977
+ branch: attemptInfo.branch,
6978
+ prNumber: attemptInfo.pr_number || null,
6979
+ reason: "smartpr_initial_merged_branch",
6980
+ },
6981
+ );
6982
+ }
6756
6983
  console.log(
6757
- `[monitor] ${tag}: branch ${attemptInfo.branch} confirmed merged — completing task`,
6984
+ `[monitor] ${tag}: branch ${attemptInfo.branch} confirmed merged — ${canFinalize ? "completing task" : "awaiting review gate"}`,
6758
6985
  );
6759
6986
  mergedBranchCache.add(attemptInfo.branch);
6760
- if (attemptInfo.task_id) {
6987
+ if (attemptInfo.task_id && canFinalize) {
6761
6988
  mergedTaskCache.add(attemptInfo.task_id);
6989
+ pendingMergeStrategyByTask.delete(
6990
+ String(attemptInfo.task_id || "").trim(),
6991
+ );
6762
6992
  void updateTaskStatus(attemptInfo.task_id, "done");
6763
6993
  }
6764
6994
  await archiveAttempt(attemptId);
@@ -7134,6 +7364,7 @@ Return a short summary of what you did and any files that needed manual resoluti
7134
7364
  // ── Step 5b: Merge strategy analysis (Codex-powered) ─────
7135
7365
  if (codexAnalyzeMergeStrategy) {
7136
7366
  void runMergeStrategyAnalysis({
7367
+ taskId: attempt?.task_id || attemptInfo?.task_id || null,
7137
7368
  attemptId,
7138
7369
  shortId,
7139
7370
  status,
@@ -7267,12 +7498,32 @@ async function resolveAndTriggerSmartPR(shortId, status) {
7267
7498
  // Check GitHub for a merged PR with this head branch
7268
7499
  const merged = await isBranchMerged(resolvedAttempt.branch);
7269
7500
  if (merged) {
7501
+ let canFinalize = true;
7502
+ if (resolvedAttempt.task_id) {
7503
+ canFinalize = await shouldFinalizeMergedTask(
7504
+ {
7505
+ id: resolvedAttempt.task_id,
7506
+ title: resolvedAttempt.task_title || resolvedAttempt.task_id,
7507
+ description: resolvedAttempt.task_description || "",
7508
+ pr_number: resolvedAttempt.pr_number || null,
7509
+ pr_url: resolvedAttempt.pr_url || "",
7510
+ },
7511
+ {
7512
+ branch: resolvedAttempt.branch,
7513
+ prNumber: resolvedAttempt.pr_number || null,
7514
+ reason: "resolve_smartpr_merged_branch",
7515
+ },
7516
+ );
7517
+ }
7270
7518
  console.log(
7271
- `[monitor] smartPR(${shortId}): branch ${resolvedAttempt.branch} confirmed merged — completing task and skipping PR flow`,
7519
+ `[monitor] smartPR(${shortId}): branch ${resolvedAttempt.branch} confirmed merged — ${canFinalize ? "completing task and skipping PR flow" : "awaiting review gate"}`,
7272
7520
  );
7273
7521
  mergedBranchCache.add(resolvedAttempt.branch);
7274
- if (resolvedAttempt.task_id) {
7522
+ if (resolvedAttempt.task_id && canFinalize) {
7275
7523
  mergedTaskCache.add(resolvedAttempt.task_id);
7524
+ pendingMergeStrategyByTask.delete(
7525
+ String(resolvedAttempt.task_id || "").trim(),
7526
+ );
7276
7527
  void updateTaskStatus(resolvedAttempt.task_id, "done");
7277
7528
  }
7278
7529
  await archiveAttempt(resolvedAttempt.id || shortId);
@@ -12356,6 +12607,10 @@ let agentEndpoint = null;
12356
12607
  let agentEventBus = null;
12357
12608
  /** @type {import("./review-agent.mjs").ReviewAgent|null} */
12358
12609
  let reviewAgent = null;
12610
+ /** @type {Map<string, import("./merge-strategy.mjs").MergeContext>} */
12611
+ const pendingMergeStrategyByTask = new Map();
12612
+ /** @type {Map<string, { approved: boolean, reviewedAt: string }>} */
12613
+ const reviewGateResults = new Map();
12359
12614
  /** @type {import("./sync-engine.mjs").SyncEngine|null} */
12360
12615
  let syncEngine = null;
12361
12616
  /** @type {import("./error-detector.mjs").ErrorDetector|null} */
@@ -12715,9 +12970,14 @@ if (isExecutorDisabled()) {
12715
12970
  : null,
12716
12971
  promptTemplate: agentPrompts?.reviewer,
12717
12972
  onReviewComplete: (taskId, result) => {
12973
+ const normalizedTaskId = String(taskId || "").trim();
12718
12974
  console.log(
12719
12975
  `[monitor] review complete for ${taskId}: ${result?.approved ? "approved" : "changes_requested"} — prMerged: ${result?.prMerged}`,
12720
12976
  );
12977
+ reviewGateResults.set(normalizedTaskId, {
12978
+ approved: result?.approved === true,
12979
+ reviewedAt: result?.reviewedAt || new Date().toISOString(),
12980
+ });
12721
12981
  try {
12722
12982
  setReviewResult(taskId, {
12723
12983
  approved: result?.approved ?? false,
@@ -12726,6 +12986,9 @@ if (isExecutorDisabled()) {
12726
12986
  } catch {
12727
12987
  /* best-effort */
12728
12988
  }
12989
+ const pendingCtx = result?.approved
12990
+ ? dequeuePendingMergeStrategy(normalizedTaskId)
12991
+ : null;
12729
12992
 
12730
12993
  if (result?.approved && result?.prMerged) {
12731
12994
  // PR merged and reviewer happy — fully done
@@ -12747,10 +13010,27 @@ if (isExecutorDisabled()) {
12747
13010
  console.log(
12748
13011
  `[monitor] review approved but PR not merged — ${taskId} stays inreview`,
12749
13012
  );
13013
+ if (pendingCtx) {
13014
+ console.log(
13015
+ `[monitor] flow gate released for ${taskId} — running deferred merge strategy`,
13016
+ );
13017
+ void runMergeStrategyAnalysis(
13018
+ {
13019
+ ...pendingCtx,
13020
+ taskId: pendingCtx.taskId || taskId,
13021
+ },
13022
+ { skipFlowGate: false },
13023
+ );
13024
+ }
12750
13025
  } else {
12751
13026
  console.log(
12752
13027
  `[monitor] review found ${result?.issues?.length || 0} issue(s) for ${taskId} — task stays inreview`,
12753
13028
  );
13029
+ if (pendingMergeStrategyByTask.has(normalizedTaskId)) {
13030
+ console.log(
13031
+ `[monitor] flow gate remains active for ${taskId} — merge strategy stays deferred`,
13032
+ );
13033
+ }
12754
13034
  }
12755
13035
  },
12756
13036
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.33.2",
3
+ "version": "0.33.3",
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",
package/review-agent.mjs CHANGED
@@ -185,9 +185,17 @@ function getPrDiff({ prUrl, branchName }) {
185
185
  function parseReviewResult(raw) {
186
186
  if (!raw || !raw.trim()) {
187
187
  return {
188
- approved: true,
189
- issues: [],
190
- summary: "Empty agent output — auto-approved",
188
+ approved: false,
189
+ issues: [
190
+ {
191
+ severity: "major",
192
+ category: "broken",
193
+ file: "(review)",
194
+ line: null,
195
+ description: "Reviewer returned empty output; review is not complete.",
196
+ },
197
+ ],
198
+ summary: "Empty reviewer output",
191
199
  };
192
200
  }
193
201
 
@@ -218,11 +226,19 @@ function parseReviewResult(raw) {
218
226
  }
219
227
  }
220
228
 
221
- // Couldn't parse — auto-approve with note
229
+ // Couldn't parse — treat as failed review to prevent unsafe auto-merge.
222
230
  return {
223
- approved: true,
224
- issues: [],
225
- summary: "Could not parse review output — auto-approved",
231
+ approved: false,
232
+ issues: [
233
+ {
234
+ severity: "major",
235
+ category: "broken",
236
+ file: "(review)",
237
+ line: null,
238
+ description: "Could not parse reviewer output as JSON.",
239
+ },
240
+ ],
241
+ summary: "Could not parse reviewer output",
226
242
  };
227
243
  }
228
244
 
@@ -232,8 +248,18 @@ function parseReviewResult(raw) {
232
248
  * @returns {{ approved: boolean, issues: Array, summary: string }}
233
249
  */
234
250
  function normalizeResult(obj) {
235
- const approved = obj.verdict !== "changes_requested";
251
+ const verdict = String(obj?.verdict || "").trim().toLowerCase();
252
+ const approved = verdict === "approved";
236
253
  const issues = Array.isArray(obj.issues) ? obj.issues : [];
254
+ if (!approved && verdict !== "changes_requested" && issues.length === 0) {
255
+ issues.push({
256
+ severity: "major",
257
+ category: "broken",
258
+ file: "(review)",
259
+ line: null,
260
+ description: `Invalid reviewer verdict "${verdict || "missing"}".`,
261
+ });
262
+ }
237
263
  const summary = typeof obj.summary === "string" ? obj.summary : "";
238
264
  return { approved, issues, summary };
239
265
  }
@@ -249,7 +275,7 @@ export class ReviewAgent {
249
275
  /** @type {Array<{ id: string, title: string, branchName: string, prUrl: string, description: string, taskContext?: string }>} */
250
276
  #queue = [];
251
277
 
252
- /** @type {Set<string>} - task IDs already reviewed or in-flight */
278
+ /** @type {Set<string>} - task IDs queued or currently in-flight */
253
279
  #seen = new Set();
254
280
 
255
281
  #completedCount = 0;
@@ -309,7 +335,7 @@ export class ReviewAgent {
309
335
 
310
336
  /**
311
337
  * Queue a task for review.
312
- * Deduplicates by task ID same task won't be reviewed twice.
338
+ * Deduplicates by task ID while queued/in-flight.
313
339
  * @param {{ id: string, title: string, branchName: string, prUrl: string, description: string, taskContext?: string }} task
314
340
  */
315
341
  async queueReview(task) {
@@ -319,7 +345,7 @@ export class ReviewAgent {
319
345
  }
320
346
 
321
347
  if (this.#seen.has(task.id)) {
322
- console.log(`${TAG} task ${task.id} already reviewed/queued — skipping`);
348
+ console.log(`${TAG} task ${task.id} already queued/in-flight — skipping`);
323
349
  return;
324
350
  }
325
351
 
@@ -412,6 +438,7 @@ export class ReviewAgent {
412
438
  })
413
439
  .finally(() => {
414
440
  this.#activeReviews.delete(task.id);
441
+ this.#seen.delete(task.id);
415
442
  this.#completedCount++;
416
443
  // Continue processing after slot freed
417
444
  if (this.#running && this.#queue.length > 0) {
@@ -441,12 +468,21 @@ export class ReviewAgent {
441
468
 
442
469
  if (!diff) {
443
470
  console.log(
444
- `${TAG} no diff available for task ${task.id} — auto-approving`,
471
+ `${TAG} no diff available for task ${task.id} — marking review as changes requested`,
445
472
  );
446
473
  const result = {
447
- approved: true,
448
- issues: [],
449
- summary: "No diff available",
474
+ approved: false,
475
+ issues: [
476
+ {
477
+ severity: "major",
478
+ category: "broken",
479
+ file: "(diff)",
480
+ line: null,
481
+ description:
482
+ "No diff could be loaded for this PR; cannot complete review safely.",
483
+ },
484
+ ],
485
+ summary: "No diff available for review",
450
486
  reviewedAt: new Date().toISOString(),
451
487
  agentOutput: "",
452
488
  };
@@ -474,7 +510,7 @@ export class ReviewAgent {
474
510
  const sdkResult = await execWithRetry(prompt, {
475
511
  taskKey: `review-${task.id}`,
476
512
  timeoutMs: this.#reviewTimeoutMs,
477
- maxRetries: 0, // Reviews don't retry — approve on failure
513
+ maxRetries: 0, // Reviews don't retry — reject on failure
478
514
  sdk: this.#sdk,
479
515
  });
480
516
 
@@ -482,8 +518,16 @@ export class ReviewAgent {
482
518
  } catch (err) {
483
519
  console.error(`${TAG} SDK call failed for task ${task.id}:`, err.message);
484
520
  const result = {
485
- approved: true,
486
- issues: [],
521
+ approved: false,
522
+ issues: [
523
+ {
524
+ severity: "major",
525
+ category: "broken",
526
+ file: "(review)",
527
+ line: null,
528
+ description: `Reviewer execution failed: ${err.message}`,
529
+ },
530
+ ],
487
531
  summary: `Review failed: ${err.message}`,
488
532
  reviewedAt: new Date().toISOString(),
489
533
  agentOutput: "",
@@ -30,7 +30,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  // 2. Node module resolution via createRequire — handles global install hoisting
31
31
  // 3. CDN redirect — last resort
32
32
  const _require = createRequire(import.meta.url);
33
- const BUNDLED_VENDOR_DIR = resolve(__dirname, "ui", "vendor");
33
+ const uiRootPreferred = resolve(__dirname, "site", "ui");
34
+ const uiRootFallback = resolve(__dirname, "ui");
35
+ const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
36
+ const BUNDLED_VENDOR_DIR = resolve(uiRoot, "vendor");
34
37
 
35
38
  const VENDOR_FILES = {
36
39
  "preact.js": { specifier: "preact/dist/preact.module.js", cdn: "https://esm.sh/preact@10.25.4" },
@@ -997,7 +1000,6 @@ async function handleRequest(req, res) {
997
1000
  }
998
1001
 
999
1002
  // Static file serving from ui/
1000
- const uiRoot = resolve(__dirname, "ui");
1001
1003
  let pathname = url.pathname === "/" || url.pathname === "/setup" ? "/setup.html" : url.pathname;
1002
1004
  const filePath = resolve(uiRoot, `.${pathname}`);
1003
1005
 
@@ -397,6 +397,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
397
397
  session?.status === "active" || session?.status === "running";
398
398
  const resumeLabel =
399
399
  session?.status === "archived" ? "Unarchive" : "Resume Session";
400
+ const safeSessionId = sessionId ? encodeURIComponent(sessionId) : "";
400
401
 
401
402
  /* Memoize the filter key list so filteredMessages memoization works properly.
402
403
  Previously a new array was created every render, breaking useMemo deps. */
@@ -630,7 +631,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
630
631
  for (const file of list) {
631
632
  form.append("file", file, file.name || "attachment");
632
633
  }
633
- const res = await apiFetch(`/api/sessions/${sessionId}/attachments`, {
634
+ const res = await apiFetch(`/api/sessions/${safeSessionId}/attachments`, {
634
635
  method: "POST",
635
636
  body: form,
636
637
  });
@@ -684,7 +685,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
684
685
  setSending(true);
685
686
 
686
687
  try {
687
- await apiFetch(`/api/sessions/${sessionId}/message`, {
688
+ await apiFetch(`/api/sessions/${safeSessionId}/message`, {
688
689
  method: "POST",
689
690
  body: JSON.stringify({
690
691
  content: text,
@@ -702,7 +703,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
702
703
 
703
704
  const handleResume = useCallback(async () => {
704
705
  try {
705
- await apiFetch(`/api/sessions/${sessionId}/resume`, { method: "POST" });
706
+ await apiFetch(`/api/sessions/${safeSessionId}/resume`, { method: "POST" });
706
707
  showToast(
707
708
  session?.status === "archived" ? "Session unarchived" : "Session resumed",
708
709
  "success",
@@ -717,7 +718,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
717
718
 
718
719
  const handleArchive = useCallback(async () => {
719
720
  try {
720
- await apiFetch(`/api/sessions/${sessionId}/archive`, { method: "POST" });
721
+ await apiFetch(`/api/sessions/${safeSessionId}/archive`, { method: "POST" });
721
722
  showToast("Session archived", "success");
722
723
  await loadSessions();
723
724
  const res = await loadSessionMessages(sessionId);
@@ -99,8 +99,9 @@ export function DiffViewer({ sessionId }) {
99
99
  let active = true;
100
100
  setLoading(true);
101
101
  setError(null);
102
+ const safeSessionId = encodeURIComponent(sessionId);
102
103
 
103
- apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
104
+ apiFetch(`/api/sessions/${safeSessionId}/diff`, { _silent: true })
104
105
  .then((res) => {
105
106
  if (!active) return;
106
107
  setDiffData(res?.diff || null);
@@ -118,7 +119,8 @@ export function DiffViewer({ sessionId }) {
118
119
  const handleRetry = useCallback(() => {
119
120
  setError(null);
120
121
  setLoading(true);
121
- apiFetch(`/api/sessions/${sessionId}/diff`, { _silent: true })
122
+ const safeSessionId = encodeURIComponent(sessionId);
123
+ apiFetch(`/api/sessions/${safeSessionId}/diff`, { _silent: true })
122
124
  .then((res) => setDiffData(res?.diff || null))
123
125
  .catch(() => setError("unavailable"))
124
126
  .finally(() => setLoading(false));
@@ -10,6 +10,7 @@ import { apiFetch } from "../modules/api.js";
10
10
  import { haptic } from "../modules/telegram.js";
11
11
  import { formatRelative, truncate, cloneValue } from "../modules/utils.js";
12
12
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
13
+ import { getAgentDisplay } from "../modules/agent-display.js";
13
14
 
14
15
  const html = htm.bind(h);
15
16
 
@@ -340,6 +341,16 @@ function KanbanCard({ task, onOpen }) {
340
341
  const baseBranch = getTaskBaseBranch(task);
341
342
  const repoName = task.repo || task.repository || "";
342
343
  const issueNum = task.issueNumber || task.issue_number || (typeof task.id === "string" && /^\d+$/.test(task.id) ? task.id : null);
344
+ const hasAgent = Boolean(
345
+ task?.assignee ||
346
+ task?.meta?.execution?.sdk ||
347
+ task?.meta?.execution?.executor ||
348
+ task?.sdk ||
349
+ task?.executor ||
350
+ task?.agent ||
351
+ task?.agentName,
352
+ );
353
+ const agentDisplay = hasAgent ? getAgentDisplay(task) : null;
343
354
 
344
355
  return html`
345
356
  <div
@@ -380,7 +391,12 @@ function KanbanCard({ task, onOpen }) {
380
391
  </div>
381
392
  `}
382
393
  <div class="kanban-card-meta">
383
- ${task.assignee && html`<span class="kanban-card-assignee" title=${task.assignee}>${task.assignee.split("-")[0]}</span>`}
394
+ ${agentDisplay && html`
395
+ <span
396
+ class="kanban-card-assignee"
397
+ title=${agentDisplay.label}
398
+ >${agentDisplay.icon}</span>
399
+ `}
384
400
  <span class="kanban-card-id">${typeof task.id === "string" ? truncate(task.id, 12) : task.id}</span>
385
401
  ${task.created_at && html`<span>${formatRelative(task.created_at)}</span>`}
386
402
  </div>
@@ -23,6 +23,12 @@ let _wsListenerReady = false;
23
23
  /** Track the last filter used so createSession can reload with the same filter */
24
24
  let _lastLoadFilter = {};
25
25
 
26
+ function sessionPath(id, action = "") {
27
+ const safeId = encodeURIComponent(String(id || "").trim());
28
+ if (!safeId) return "";
29
+ return action ? `/api/sessions/${safeId}/${action}` : `/api/sessions/${safeId}`;
30
+ }
31
+
26
32
  /* ─── Data loaders ─── */
27
33
  export async function loadSessions(filter = {}) {
28
34
  _lastLoadFilter = filter;
@@ -38,7 +44,9 @@ export async function loadSessions(filter = {}) {
38
44
 
39
45
  export async function loadSessionMessages(id) {
40
46
  try {
41
- const res = await apiFetch(`/api/sessions/${id}`, { _silent: true });
47
+ const url = sessionPath(id);
48
+ if (!url) return { ok: false, error: "invalid" };
49
+ const res = await apiFetch(url, { _silent: true });
42
50
  if (res?.session) {
43
51
  sessionMessages.value = res.session.messages || [];
44
52
  return { ok: true, messages: sessionMessages.value };
@@ -195,7 +203,9 @@ export async function createSession(options = {}) {
195
203
  /* ─── Session actions ─── */
196
204
  export async function archiveSession(id) {
197
205
  try {
198
- await apiFetch(`/api/sessions/${id}/archive`, { method: "POST" });
206
+ const url = sessionPath(id, "archive");
207
+ if (!url) return false;
208
+ await apiFetch(url, { method: "POST" });
199
209
  if (selectedSessionId.value === id) selectedSessionId.value = null;
200
210
  await loadSessions(_lastLoadFilter);
201
211
  return true;
@@ -206,7 +216,9 @@ export async function archiveSession(id) {
206
216
 
207
217
  export async function deleteSession(id) {
208
218
  try {
209
- await apiFetch(`/api/sessions/${id}/delete`, { method: "POST" });
219
+ const url = sessionPath(id, "delete");
220
+ if (!url) return false;
221
+ await apiFetch(url, { method: "POST" });
210
222
  if (selectedSessionId.value === id) selectedSessionId.value = null;
211
223
  await loadSessions(_lastLoadFilter);
212
224
  return true;
@@ -217,7 +229,9 @@ export async function deleteSession(id) {
217
229
 
218
230
  export async function resumeSession(id) {
219
231
  try {
220
- await apiFetch(`/api/sessions/${id}/resume`, { method: "POST" });
232
+ const url = sessionPath(id, "resume");
233
+ if (!url) return false;
234
+ await apiFetch(url, { method: "POST" });
221
235
  await loadSessions(_lastLoadFilter);
222
236
  return true;
223
237
  } catch {
@@ -0,0 +1,79 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Agent display helpers
3
+ * - Normalizes SDK/executor metadata to icons + labels
4
+ * ───────────────────────────────────────────────────────────── */
5
+ import { resolveIcon } from "./icon-utils.js";
6
+
7
+ const AGENT_SDKS = [
8
+ {
9
+ key: "codex",
10
+ label: "Codex",
11
+ icon: "⚡",
12
+ aliases: ["codex", "openai", "gpt", "o3", "o4"],
13
+ },
14
+ {
15
+ key: "copilot",
16
+ label: "Copilot",
17
+ icon: "🤖",
18
+ aliases: ["copilot", "github"],
19
+ },
20
+ {
21
+ key: "claude",
22
+ label: "Claude",
23
+ icon: "🧠",
24
+ aliases: ["claude", "anthropic"],
25
+ },
26
+ ];
27
+
28
+ function normalize(value) {
29
+ return String(value || "").trim().toLowerCase();
30
+ }
31
+
32
+ function resolveSdkKey(raw) {
33
+ const normalized = normalize(raw);
34
+ if (!normalized) return null;
35
+ for (const sdk of AGENT_SDKS) {
36
+ if (sdk.aliases.some((alias) => normalized.includes(alias))) {
37
+ return sdk.key;
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function findSdk(task = {}) {
44
+ const execution = task?.meta?.execution || task?.execution || {};
45
+ const candidates = [
46
+ execution.sdk,
47
+ execution.executor,
48
+ task.sdk,
49
+ task.executor,
50
+ task.meta?.sdk,
51
+ task.meta?.executor,
52
+ task.assignee,
53
+ task.agent,
54
+ task.agentId,
55
+ task.agentName,
56
+ ];
57
+ for (const candidate of candidates) {
58
+ const key = resolveSdkKey(candidate);
59
+ if (key) return key;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ export function getAgentDisplay(task = {}) {
65
+ const sdkKey = findSdk(task);
66
+ const sdk = sdkKey ? AGENT_SDKS.find((entry) => entry.key === sdkKey) : null;
67
+ if (sdk) {
68
+ return {
69
+ key: sdk.key,
70
+ label: sdk.label,
71
+ icon: resolveIcon(sdk.icon) || sdk.icon,
72
+ };
73
+ }
74
+ return {
75
+ key: "agent",
76
+ label: "Agent",
77
+ icon: resolveIcon("🤖") || "🤖",
78
+ };
79
+ }
@@ -867,6 +867,7 @@ select.input {
867
867
  justify-content: center;
868
868
  align-items: center;
869
869
  padding: 8px 0;
870
+ position: relative;
870
871
  }
871
872
 
872
873
  .donut-legend {
@@ -5350,3 +5351,17 @@ select.input {
5350
5351
  height: 16px;
5351
5352
  stroke-width: 1.7;
5352
5353
  }
5354
+
5355
+ .agent-inline-icon {
5356
+ display: inline-flex;
5357
+ align-items: center;
5358
+ justify-content: center;
5359
+ margin-right: 6px;
5360
+ line-height: 0;
5361
+ }
5362
+
5363
+ .agent-inline-icon svg {
5364
+ width: 14px;
5365
+ height: 14px;
5366
+ stroke-width: 1.7;
5367
+ }
@@ -391,6 +391,11 @@ body.kanban-dragging .kanban-card {
391
391
  flex-shrink: 0;
392
392
  }
393
393
 
394
+ .kanban-card-assignee svg {
395
+ width: 12px;
396
+ height: 12px;
397
+ }
398
+
394
399
  .kanban-card-title {
395
400
  font-size: 14px;
396
401
  font-weight: 500;
package/ui/tabs/chat.js CHANGED
@@ -545,12 +545,12 @@ export function ChatTab() {
545
545
  markUserMessageSent(activeAgent.value);
546
546
 
547
547
  // Use sendOrQueue for offline resilience
548
- const sendFn = async (sid, msg) => {
549
- await apiFetch(`/api/sessions/${sid}/message`, {
550
- method: "POST",
551
- body: JSON.stringify({ content: msg, mode: agentMode.value, yolo: yoloMode.peek() }),
552
- });
553
- };
548
+ const sendFn = async (sid, msg) => {
549
+ await apiFetch(`/api/sessions/${encodeURIComponent(sid)}/message`, {
550
+ method: "POST",
551
+ body: JSON.stringify({ content: msg, mode: agentMode.value, yolo: yoloMode.peek() }),
552
+ });
553
+ };
554
554
 
555
555
  try {
556
556
  await sendOrQueue(sessionId, content, sendFn);
@@ -576,7 +576,7 @@ export function ChatTab() {
576
576
  markUserMessageSent(activeAgent.value);
577
577
 
578
578
  try {
579
- await apiFetch(`/api/sessions/${newId}/message`, {
579
+ await apiFetch(`/api/sessions/${encodeURIComponent(newId)}/message`, {
580
580
  method: "POST",
581
581
  body: JSON.stringify({ content, mode: agentMode.value, yolo: yoloMode.peek() }),
582
582
  });
@@ -638,10 +638,10 @@ export function ChatTab() {
638
638
  /* ── Session rename ── */
639
639
  async function saveRename(sid, newTitle) {
640
640
  try {
641
- await apiFetch(`/api/sessions/${sid}/rename`, {
642
- method: "POST",
643
- body: JSON.stringify({ title: newTitle }),
644
- });
641
+ await apiFetch(`/api/sessions/${encodeURIComponent(sid)}/rename`, {
642
+ method: "POST",
643
+ body: JSON.stringify({ title: newTitle }),
644
+ });
645
645
  loadSessions();
646
646
  showToast("Session renamed", "success");
647
647
  } catch {
package/ui/tabs/tasks.js CHANGED
@@ -16,6 +16,7 @@ const html = htm.bind(h);
16
16
  import { haptic, showConfirm } from "../modules/telegram.js";
17
17
  import { apiFetch, sendCommandToChat } from "../modules/api.js";
18
18
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
19
+ import { getAgentDisplay } from "../modules/agent-display.js";
19
20
  import { signal } from "@preact/signals";
20
21
  import {
21
22
  tasksData,
@@ -786,7 +787,7 @@ export function TaskProgressModal({ task, onClose }) {
786
787
  : "var(--color-error)";
787
788
 
788
789
  const startedRelative = liveTask?.created ? formatRelative(liveTask.created) : "—";
789
- const agentLabel = liveTask?.assignee || task.assignee || "Agent";
790
+ const agentDisplay = getAgentDisplay(liveTask || task);
790
791
  const branchLabel = liveTask?.branch || task.branch || "—";
791
792
 
792
793
  const handleCancel = async () => {
@@ -842,10 +843,13 @@ export function TaskProgressModal({ task, onClose }) {
842
843
 
843
844
 
844
845
  <div class="tp-meta-strip">
845
- <div class="tp-meta-item">
846
- <span class="tp-meta-label">Agent</span>
847
- <span class="tp-meta-value">${agentLabel}</span>
848
- </div>
846
+ <div class="tp-meta-item">
847
+ <span class="tp-meta-label">Agent</span>
848
+ <span class="tp-meta-value">
849
+ <span class="agent-inline-icon">${agentDisplay.icon}</span>
850
+ ${agentDisplay.label}
851
+ </span>
852
+ </div>
849
853
  <div class="tp-meta-item">
850
854
  <span class="tp-meta-label">Branch</span>
851
855
  <span class="tp-meta-value mono">${branchLabel}</span>
@@ -964,7 +968,7 @@ export function TaskReviewModal({ task, onClose, onStart }) {
964
968
 
965
969
  const prNumber = liveTask?.pr || task.pr;
966
970
  const branchLabel = liveTask?.branch || task.branch || "—";
967
- const agentLabel = liveTask?.assignee || task.assignee || "Agent";
971
+ const agentDisplay = getAgentDisplay(liveTask || task);
968
972
  const updatedRelative = liveTask?.updated ? formatRelative(liveTask.updated) : "—";
969
973
  const reviewAttachments = normalizeTaskAttachments(liveTask || task);
970
974
 
@@ -1060,10 +1064,13 @@ export function TaskReviewModal({ task, onClose, onStart }) {
1060
1064
 
1061
1065
 
1062
1066
  <div class="tr-meta-grid">
1063
- <div class="tr-meta-item">
1064
- <span class="tr-meta-label">Agent</span>
1065
- <span class="tr-meta-value">${agentLabel}</span>
1066
- </div>
1067
+ <div class="tr-meta-item">
1068
+ <span class="tr-meta-label">Agent</span>
1069
+ <span class="tr-meta-value">
1070
+ <span class="agent-inline-icon">${agentDisplay.icon}</span>
1071
+ ${agentDisplay.label}
1072
+ </span>
1073
+ </div>
1067
1074
  <div class="tr-meta-item">
1068
1075
  <span class="tr-meta-label">Branch</span>
1069
1076
  <span class="tr-meta-value mono">${branchLabel}</span>
@@ -1288,6 +1288,15 @@ function NodeConfigEditor({ node, nodeTypes: types, onUpdate, onUpdateLabel, onC
1288
1288
  function WorkflowListView() {
1289
1289
  const wfs = workflows.value || [];
1290
1290
  const tmpls = templates.value || [];
1291
+ const installedTemplateIds = new Set();
1292
+ wfs.forEach((wf) => {
1293
+ if (wf.metadata?.installedFrom) installedTemplateIds.add(wf.metadata.installedFrom);
1294
+ installedTemplateIds.add(wf.name);
1295
+ });
1296
+ const availableTemplates = tmpls.filter((t) => {
1297
+ if (installedTemplateIds.has(t.id) || installedTemplateIds.has(t.name)) return false;
1298
+ return true;
1299
+ });
1291
1300
 
1292
1301
  return html`
1293
1302
  <div style="padding: 0 4px;">
@@ -1384,25 +1393,30 @@ function WorkflowListView() {
1384
1393
  const newWf = { name: "New Workflow", description: "", category: "custom", enabled: true, nodes: [], edges: [], variables: {} };
1385
1394
  saveWorkflow(newWf).then(wf => { if (wf) { activeWorkflow.value = wf; viewMode.value = "canvas"; } });
1386
1395
  }}>+ Create Blank</button>
1387
- ${tmpls.length > 0 && html`
1388
- <button class="wf-btn" style="border-color: #f59e0b60; color: #f59e0b;" onClick=${() => installTemplate(tmpls[0]?.id)}>
1396
+ ${availableTemplates.length > 0 && html`
1397
+ <button class="wf-btn" style="border-color: #f59e0b60; color: #f59e0b;" onClick=${() => installTemplate(availableTemplates[0]?.id)}>
1389
1398
  <span class="btn-icon">${resolveIcon("zap")}</span>
1390
- Quick Install: ${tmpls[0]?.name}
1399
+ Quick Install: ${availableTemplates[0]?.name}
1391
1400
  </button>
1392
1401
  `}
1393
1402
  </div>
1394
1403
  </div>
1395
1404
  `}
1396
1405
 
1397
- <!-- Templates (grouped by category) -->
1406
+ <!-- Templates (grouped by category, deduped against installed) -->
1398
1407
  <div>
1399
1408
  <h3 style="font-size: 14px; font-weight: 600; color: var(--color-text-secondary, #8b95a5); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px;">
1400
- Templates (${tmpls.length})
1409
+ Available Templates (${availableTemplates.length})${tmpls.length !== availableTemplates.length ? html` <span style="font-size: 11px; font-weight: 400; opacity: 0.6;">· ${tmpls.length - availableTemplates.length} installed</span>` : ""}
1401
1410
  </h3>
1411
+ ${availableTemplates.length === 0 && html`
1412
+ <div style="text-align: center; padding: 24px; opacity: 0.5; font-size: 13px;">
1413
+ All templates are installed! 🎉
1414
+ </div>
1415
+ `}
1402
1416
  ${(() => {
1403
1417
  // Group templates by category
1404
1418
  const groups = {};
1405
- tmpls.forEach(t => {
1419
+ availableTemplates.forEach(t => {
1406
1420
  const key = t.category || "custom";
1407
1421
  if (!groups[key]) groups[key] = { label: t.categoryLabel || key, icon: t.categoryIcon || "settings", order: t.categoryOrder || 99, items: [] };
1408
1422
  groups[key].items.push(t);
package/ui-server.mjs CHANGED
@@ -125,7 +125,9 @@ import {
125
125
 
126
126
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
127
127
  const repoRoot = resolveRepoRoot();
128
- const uiRoot = resolve(__dirname, "ui");
128
+ const uiRootPreferred = resolve(__dirname, "site", "ui");
129
+ const uiRootFallback = resolve(__dirname, "ui");
130
+ const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
129
131
  let libraryInitAttempted = false;
130
132
 
131
133
  function ensureLibraryInitialized() {
@@ -154,7 +156,7 @@ function ensureLibraryInitialized() {
154
156
  // 2. node_modules — createRequire resolution (handles npm hoisting)
155
157
  // 3. CDN redirect — last resort for airgap / first-run edge cases
156
158
  const _require = createRequire(import.meta.url);
157
- const BUNDLED_VENDOR_DIR = resolve(__dirname, "ui", "vendor");
159
+ const BUNDLED_VENDOR_DIR = resolve(uiRoot, "vendor");
158
160
 
159
161
  function resolveVendorPath(specifier) {
160
162
  // Direct resolution first (works when package exports allow the sub-path)
@@ -248,7 +250,10 @@ async function handleVendor(req, res, url) {
248
250
  }
249
251
  const statusPath = resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
250
252
  const logsDir = resolve(__dirname, "logs");
251
- const agentLogsDir = resolve(repoRoot, ".cache", "agent-logs");
253
+ const agentLogsDirCandidates = [
254
+ resolve(__dirname, "logs", "agents"),
255
+ resolve(repoRoot, ".cache", "agent-logs"),
256
+ ];
252
257
  const CONFIG_SCHEMA_PATH = resolve(__dirname, "bosun.schema.json");
253
258
  const PLANNER_STATE_PATH = resolve(
254
259
  repoRoot,
@@ -2235,6 +2240,21 @@ function broadcastSessionMessage(payload) {
2235
2240
 
2236
2241
  /* ─── Log Streaming Helpers ─── */
2237
2242
 
2243
+ async function resolveAgentLogsDir() {
2244
+ for (const dir of agentLogsDirCandidates) {
2245
+ const files = await readdir(dir).catch(() => null);
2246
+ if (files?.some((f) => f.endsWith(".log"))) return dir;
2247
+ }
2248
+ for (const dir of agentLogsDirCandidates) {
2249
+ if (existsSync(dir)) return dir;
2250
+ }
2251
+ return agentLogsDirCandidates[0];
2252
+ }
2253
+
2254
+ function normalizeAgentLogName(name) {
2255
+ return basename(String(name || "")).trim();
2256
+ }
2257
+
2238
2258
  /**
2239
2259
  * Resolve the log file path for a given logType and optional query.
2240
2260
  * Returns null if no matching file found.
@@ -2246,6 +2266,7 @@ async function resolveLogPath(logType, query) {
2246
2266
  return logFile ? resolve(logsDir, logFile) : null;
2247
2267
  }
2248
2268
  if (logType === "agent") {
2269
+ const agentLogsDir = await resolveAgentLogsDir();
2249
2270
  const files = await readdir(agentLogsDir).catch(() => []);
2250
2271
  let candidates = files.filter((f) => f.endsWith(".log")).sort().reverse();
2251
2272
  if (query) {
@@ -3325,6 +3346,7 @@ function summarizeTelemetry(metrics, days) {
3325
3346
 
3326
3347
  async function listAgentLogFiles(query = "", limit = 60) {
3327
3348
  const entries = [];
3349
+ const agentLogsDir = await resolveAgentLogsDir();
3328
3350
  const files = await readdir(agentLogsDir).catch(() => []);
3329
3351
  for (const name of files) {
3330
3352
  if (!name.endsWith(".log")) continue;
@@ -3333,6 +3355,7 @@ async function listAgentLogFiles(query = "", limit = 60) {
3333
3355
  const info = await stat(resolve(agentLogsDir, name));
3334
3356
  entries.push({
3335
3357
  name,
3358
+ source: agentLogsDir,
3336
3359
  size: info.size,
3337
3360
  mtime:
3338
3361
  info.mtime?.toISOString?.() || new Date(info.mtime).toISOString(),
@@ -4617,7 +4640,7 @@ async function handleApi(req, res, url) {
4617
4640
 
4618
4641
  if (path === "/api/agent-logs") {
4619
4642
  try {
4620
- const file = url.searchParams.get("file");
4643
+ const file = normalizeAgentLogName(url.searchParams.get("file"));
4621
4644
  const query = url.searchParams.get("query") || "";
4622
4645
  const lines = Math.min(
4623
4646
  1000,
@@ -4628,6 +4651,7 @@ async function handleApi(req, res, url) {
4628
4651
  jsonResponse(res, 200, { ok: true, data: files });
4629
4652
  return;
4630
4653
  }
4654
+ const agentLogsDir = await resolveAgentLogsDir();
4631
4655
  const filePath = resolve(agentLogsDir, file);
4632
4656
  if (!filePath.startsWith(agentLogsDir)) {
4633
4657
  jsonResponse(res, 403, { ok: false, error: "Forbidden" });
@@ -4851,7 +4875,8 @@ async function handleApi(req, res, url) {
4851
4875
  return;
4852
4876
  }
4853
4877
  const latest = files[0];
4854
- const filePath = resolve(agentLogsDir, latest.name || latest);
4878
+ const agentLogsDir = await resolveAgentLogsDir();
4879
+ const filePath = resolve(agentLogsDir, normalizeAgentLogName(latest.name || latest));
4855
4880
  if (!filePath.startsWith(agentLogsDir) || !existsSync(filePath)) {
4856
4881
  jsonResponse(res, 200, { ok: true, data: null });
4857
4882
  return;
@@ -5417,8 +5442,9 @@ async function handleApi(req, res, url) {
5417
5442
  const all = engine.list();
5418
5443
  jsonResponse(res, 200, { ok: true, workflows: all.map(w => ({
5419
5444
  id: w.id, name: w.name, description: w.description, category: w.category,
5420
- enabled: w.enabled !== false, nodeCount: (w.nodes || []).length,
5421
- trigger: (w.nodes || [])[0]?.type || "manual",
5445
+ enabled: w.enabled !== false,
5446
+ nodeCount: Number.isFinite(w.nodeCount) ? w.nodeCount : (w.nodes || []).length,
5447
+ trigger: w.trigger || (w.nodes || [])[0]?.type || "manual",
5422
5448
  })) });
5423
5449
  } catch (err) {
5424
5450
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -5530,8 +5556,7 @@ async function handleApi(req, res, url) {
5530
5556
  }
5531
5557
 
5532
5558
  // GET — return full workflow definition
5533
- const all = engine.list();
5534
- const wf = all.find(w => w.id === workflowId);
5559
+ const wf = engine.get(workflowId);
5535
5560
  if (!wf) { jsonResponse(res, 404, { ok: false, error: "Workflow not found" }); return; }
5536
5561
  jsonResponse(res, 200, { ok: true, workflow: wf });
5537
5562
  } catch (err) {
@@ -6876,8 +6901,7 @@ export async function startTelegramUiServer(options = {}) {
6876
6901
  }
6877
6902
  }
6878
6903
 
6879
- // Start cloudflared tunnel for trusted TLS (Telegram Mini App requires valid cert)
6880
- if (uiServerTls) {
6904
+ // Start cloudflared tunnel for trusted TLS (Telegram Mini App requires valid cert)
6881
6905
  const tUrl = await startTunnel(actualPort);
6882
6906
  if (tUrl) {
6883
6907
  console.log(`[telegram-ui] Telegram Mini App URL: ${tUrl}`);
@@ -6888,7 +6912,6 @@ export async function startTelegramUiServer(options = {}) {
6888
6912
  );
6889
6913
  }
6890
6914
  }
6891
- }
6892
6915
 
6893
6916
  return uiServer;
6894
6917
  }