bosun 0.41.7 → 0.41.9

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 (43) hide show
  1. package/README.md +23 -1
  2. package/agent/agent-event-bus.mjs +31 -2
  3. package/agent/agent-pool.mjs +251 -11
  4. package/agent/agent-prompts.mjs +5 -1
  5. package/agent/agent-supervisor.mjs +22 -0
  6. package/agent/primary-agent.mjs +115 -5
  7. package/cli.mjs +3 -2
  8. package/config/config.mjs +4 -1
  9. package/desktop/main.mjs +350 -25
  10. package/desktop/preload.cjs +8 -0
  11. package/desktop/preload.mjs +19 -0
  12. package/entrypoint.mjs +332 -0
  13. package/infra/health-status.mjs +72 -0
  14. package/infra/library-manager.mjs +58 -1
  15. package/infra/maintenance.mjs +1 -2
  16. package/infra/monitor.mjs +25 -7
  17. package/infra/session-tracker.mjs +30 -3
  18. package/package.json +10 -4
  19. package/server/bosun-mcp-server.mjs +1004 -0
  20. package/server/setup-web-server.mjs +287 -258
  21. package/server/ui-server.mjs +218 -23
  22. package/shell/claude-shell.mjs +14 -1
  23. package/shell/codex-model-profiles.mjs +166 -29
  24. package/shell/codex-shell.mjs +56 -18
  25. package/shell/opencode-providers.mjs +20 -8
  26. package/task/task-executor.mjs +28 -0
  27. package/task/task-store.mjs +13 -4
  28. package/tools/list-todos.mjs +7 -1
  29. package/ui/app.js +3 -2
  30. package/ui/components/agent-selector.js +127 -0
  31. package/ui/components/session-list.js +2 -0
  32. package/ui/demo-defaults.js +6 -6
  33. package/ui/modules/router.js +2 -0
  34. package/ui/modules/state.js +13 -5
  35. package/ui/tabs/chat.js +3 -0
  36. package/ui/tabs/library.js +284 -52
  37. package/ui/tabs/tasks.js +5 -13
  38. package/workflow/workflow-engine.mjs +16 -4
  39. package/workflow/workflow-nodes/definitions.mjs +37 -0
  40. package/workflow/workflow-nodes.mjs +489 -153
  41. package/workflow/workflow-templates.mjs +0 -5
  42. package/workflow-templates/github.mjs +106 -16
  43. package/workspace/worktree-manager.mjs +1 -1
@@ -78,6 +78,56 @@ function normalizeTimeoutMs(value, { fallback = DEFAULT_TIMEOUT_MS, label = "tim
78
78
  return normalized;
79
79
  }
80
80
 
81
+ function isAzureOpenAIBaseUrl(value) {
82
+ try {
83
+ const parsed = new URL(String(value || ""));
84
+ const host = String(parsed.hostname || "").toLowerCase();
85
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function buildCodexSdkRuntime(streamProviderOverrides, envInput = process.env) {
92
+ const { env: resolvedEnv } = resolveCodexProfileRuntime(envInput);
93
+ const baseUrl = resolvedEnv.OPENAI_BASE_URL || "";
94
+ const isAzure = isAzureOpenAIBaseUrl(baseUrl);
95
+ const env = { ...resolvedEnv };
96
+
97
+ delete env.OPENAI_BASE_URL;
98
+
99
+ if (isAzure && env.OPENAI_API_KEY && !env.AZURE_OPENAI_API_KEY) {
100
+ env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
101
+ }
102
+
103
+ const providerName = isAzure ? "azure" : "openai";
104
+ const config = {
105
+ model_providers: {
106
+ [providerName]: isAzure
107
+ ? {
108
+ name: "Azure OpenAI",
109
+ base_url: baseUrl,
110
+ env_key: "AZURE_OPENAI_API_KEY",
111
+ wire_api: "responses",
112
+ ...streamProviderOverrides,
113
+ }
114
+ : streamProviderOverrides,
115
+ },
116
+ };
117
+
118
+ if (isAzure && env.CODEX_MODEL) {
119
+ config.model_provider = "azure";
120
+ config.model = env.CODEX_MODEL;
121
+ }
122
+
123
+ return {
124
+ env,
125
+ config,
126
+ providerName,
127
+ streamIdleTimeoutMs: streamProviderOverrides.stream_idle_timeout_ms,
128
+ };
129
+ }
130
+
81
131
  function getInternalExecutorStreamConfig() {
82
132
  try {
83
133
  const cfg = loadConfig();
@@ -443,9 +493,6 @@ async function getThread() {
443
493
  if (activeThread) return activeThread;
444
494
  const threadOptions = buildThreadOptions();
445
495
 
446
- const { env: resolvedEnv } = resolveCodexProfileRuntime(process.env);
447
- Object.assign(process.env, resolvedEnv);
448
-
449
496
  if (!codexInstance) {
450
497
  const Cls = await loadCodexSdk();
451
498
  if (!Cls) throw new Error("Codex SDK not available");
@@ -454,23 +501,16 @@ async function getThread() {
454
501
  // even if config.toml hasn't been patched by codex-config.mjs yet.
455
502
  // This is the most reliable path for Azure/Foundry deployments where
456
503
  // dropped SSE streams ("response.failed") are the dominant failure mode.
457
- const providerName = (() => {
458
- try {
459
- const parsed = new URL(String(resolvedEnv.OPENAI_BASE_URL || ""));
460
- const host = String(parsed.hostname || "").toLowerCase();
461
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com")
462
- ? "azure"
463
- : "openai";
464
- } catch {
465
- return "openai";
466
- }
467
- })();
468
504
  const STREAM_IDLE_TIMEOUT_MS = 3_600_000; // 60 min — matches Azure max stream lifetime
469
505
  const streamProviderOverrides = {
470
506
  stream_idle_timeout_ms: STREAM_IDLE_TIMEOUT_MS,
471
507
  stream_max_retries: 15,
472
508
  request_max_retries: 6,
473
509
  };
510
+ const runtime = buildCodexSdkRuntime(streamProviderOverrides, process.env);
511
+
512
+ Object.assign(process.env, runtime.env);
513
+ delete process.env.OPENAI_BASE_URL;
474
514
 
475
515
  codexInstance = new Cls({
476
516
  config: {
@@ -481,13 +521,11 @@ async function getThread() {
481
521
  undo: true,
482
522
  steer: true,
483
523
  },
484
- model_providers: {
485
- [providerName]: streamProviderOverrides,
486
- },
524
+ ...runtime.config,
487
525
  },
488
526
  });
489
527
 
490
- console.log(`[codex-shell] created Codex instance (provider=${providerName}, stream_idle_timeout=${STREAM_IDLE_TIMEOUT_MS}ms, stream_max_retries=${streamProviderOverrides.stream_max_retries})`);
528
+ console.log(`[codex-shell] created Codex instance (provider=${runtime.providerName}, stream_idle_timeout=${runtime.streamIdleTimeoutMs}ms, stream_max_retries=${streamProviderOverrides.stream_max_retries})`);
491
529
  }
492
530
 
493
531
  const transport = resolveCodexTransport();
@@ -91,22 +91,33 @@ async function discoverViaSDK(existingClient = null) {
91
91
  // Try to connect to an already-running server
92
92
  try {
93
93
  const port = Number(process.env.OPENCODE_PORT || "4096");
94
- const result = sdk.createOpencodeClient({
95
- hostname: "127.0.0.1",
96
- port,
97
- timeout: 5_000,
98
- });
99
- client = result;
94
+ try {
95
+ client = sdk.createOpencodeClient({
96
+ baseUrl: `http://127.0.0.1:${port}`,
97
+ timeout: 5_000,
98
+ });
99
+ } catch {
100
+ client = sdk.createOpencodeClient({
101
+ hostname: "127.0.0.1",
102
+ port,
103
+ timeout: 5_000,
104
+ });
105
+ }
100
106
  } catch {
101
107
  return null;
102
108
  }
103
109
  }
104
110
 
105
111
  try {
112
+ const directory = process.cwd();
113
+ const requestOptions = directory
114
+ ? { query: { directory } }
115
+ : undefined;
116
+
106
117
  // Fetch provider list + auth methods in parallel
107
118
  const [providerRes, authRes] = await Promise.all([
108
- client.provider.list().catch(() => null),
109
- client.provider.auth().catch(() => null),
119
+ client.provider.list(requestOptions).catch(() => null),
120
+ client.provider.auth(requestOptions).catch(() => null),
110
121
  ]);
111
122
 
112
123
  if (!providerRes?.data) return null;
@@ -481,3 +492,4 @@ export function buildExecutorEntry(providerID, modelFullId, overrides = {}) {
481
492
  export function invalidateCache() {
482
493
  _providerCache = { data: null, ts: 0 };
483
494
  }
495
+
@@ -4278,6 +4278,34 @@ class TaskExecutor {
4278
4278
  };
4279
4279
  }
4280
4280
 
4281
+ resetTaskThrottleState(taskId, options = {}) {
4282
+ const key = normalizeTaskIdKey(taskId);
4283
+ if (!key) return false;
4284
+
4285
+ let changed = false;
4286
+ if (options.clearNoCommit !== false && this._noCommitCounts.delete(key)) {
4287
+ changed = true;
4288
+ }
4289
+ if (options.clearSkipUntil !== false && this._skipUntil.delete(key)) {
4290
+ changed = true;
4291
+ }
4292
+ if (options.clearCooldowns !== false && this._taskCooldowns.delete(key)) {
4293
+ changed = true;
4294
+ }
4295
+ if (this._idleContinueCounts.delete(key)) {
4296
+ changed = true;
4297
+ }
4298
+ if (this._repoAreaBlockedTasks.has(key)) {
4299
+ this._repoAreaBlockedTasks.delete(key);
4300
+ changed = true;
4301
+ }
4302
+
4303
+ if (changed) {
4304
+ this._saveNoCommitState();
4305
+ }
4306
+ return changed;
4307
+ }
4308
+
4281
4309
  setBacklogReplenishmentConfig(patch = {}) {
4282
4310
  if (!patch || typeof patch !== "object") {
4283
4311
  return this.getBacklogReplenishmentConfig();
@@ -101,15 +101,25 @@ function resolveBosunHomeDir() {
101
101
  return resolve(base, "bosun");
102
102
  }
103
103
 
104
+ function resolveExplicitRepoRoot() {
105
+ const explicit = String(process.env.REPO_ROOT || "").trim();
106
+ if (!explicit) return null;
107
+ return resolve(explicit);
108
+ }
109
+
104
110
  function resolvePersistentStorePath() {
105
- const repoRoot = inferRepoRoot(process.cwd());
106
- if (repoRoot) {
107
- return resolve(repoRoot, ".bosun", ".cache", "kanban-state.json");
111
+ const explicitRepoRoot = resolveExplicitRepoRoot();
112
+ if (explicitRepoRoot) {
113
+ return resolve(explicitRepoRoot, ".bosun", ".cache", "kanban-state.json");
108
114
  }
109
115
  const bosunHome = resolveBosunHomeDir();
110
116
  if (bosunHome) {
111
117
  return resolve(bosunHome, ".cache", "kanban-state.json");
112
118
  }
119
+ const repoRoot = inferRepoRoot(process.cwd());
120
+ if (repoRoot) {
121
+ return resolve(repoRoot, ".bosun", ".cache", "kanban-state.json");
122
+ }
113
123
  return resolve(__dirname, "..", ".cache", "kanban-state.json");
114
124
  }
115
125
 
@@ -2831,4 +2841,3 @@ export function getStaleInReviewTasks(maxAgeMs) {
2831
2841
  (t) => t.status === "inreview" && t.lastActivityAt < cutoff,
2832
2842
  );
2833
2843
  }
2834
-
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * list-todos — Scan codebase for TODO/FIXME/HACK/XXX/BUG comment markers
4
4
  *
5
- * Usage: node list-todos.mjs [rootDir] [--json]
5
+ * Usage: node list-todos.mjs [rootDir] [--json] [--help]
6
6
  * rootDir defaults to cwd
7
7
  * --json emit JSON array instead of human-readable output
8
+ * --help show usage and exit without scanning
8
9
  *
9
10
  * Exit 0 always (informational tool).
10
11
  */
@@ -12,6 +13,11 @@ import { readdirSync, readFileSync } from "node:fs";
12
13
  import { resolve, relative, extname } from "node:path";
13
14
 
14
15
  const args = process.argv.slice(2);
16
+ if (args.includes("--help") || args.includes("-h") || args.includes("help")) {
17
+ console.log("Usage: node list-todos.mjs [rootDir] [--json] [--help]");
18
+ console.log("Scans for TODO/FIXME/HACK/XXX/TEMP/BUG comment markers.");
19
+ process.exit(0);
20
+ }
15
21
  const jsonMode = args.includes("--json");
16
22
  const ROOT = args.find((a) => !a.startsWith("-")) || process.cwd();
17
23
 
package/ui/app.js CHANGED
@@ -314,7 +314,7 @@ import { LogsTab } from "./tabs/logs.js";
314
314
  import { TelemetryTab } from "./tabs/telemetry.js";
315
315
  import { SettingsTab } from "./tabs/settings.js";
316
316
  import { WorkflowsTab } from "./tabs/workflows.js";
317
- import { LibraryTab } from "./tabs/library.js";
317
+ import { LibraryTab, LibraryMarketplaceTab } from "./tabs/library.js";
318
318
  import { ManualFlowsTab } from "./tabs/manual-flows.js";
319
319
 
320
320
  /* ── Shared components ── */
@@ -603,6 +603,7 @@ const TAB_COMPONENTS = {
603
603
  workflows: WorkflowsTab,
604
604
  "manual-flows": ManualFlowsTab,
605
605
  library: LibraryTab,
606
+ marketplace: LibraryMarketplaceTab,
606
607
  settings: SettingsTab,
607
608
  };
608
609
 
@@ -1228,7 +1229,7 @@ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
1228
1229
  * Bottom Navigation
1229
1230
  * ═══════════════════════════════════════════════ */
1230
1231
  const PRIMARY_NAV_TABS = ["dashboard", "chat", "tasks", "agents"];
1231
- const MORE_NAV_TABS = ["control", "infra", "logs", "telemetry", "library", "workflows", "manual-flows", "settings"];
1232
+ const MORE_NAV_TABS = ["control", "infra", "logs", "telemetry", "library", "marketplace", "workflows", "manual-flows", "settings"];
1232
1233
 
1233
1234
  function getTabsById(ids) {
1234
1235
  return ids
@@ -53,9 +53,11 @@ export const agentMode = signal("ask"); // "ask" | "agent" | "plan" | "web" | "i
53
53
 
54
54
  /** Available agents loaded from API */
55
55
  export const availableAgents = signal([]); // Array<{ id, name, provider, available, busy, capabilities }>
56
+ export const manualAgents = signal([]);
56
57
 
57
58
  /** Currently active agent adapter id */
58
59
  export const activeAgent = signal("codex-sdk");
60
+ export const activeManualAgentId = signal("");
59
61
 
60
62
  /** Whether agent data is currently loading */
61
63
  export const agentSelectorLoading = signal(false);
@@ -71,6 +73,7 @@ try { if (typeof localStorage !== "undefined") yoloMode.value = localStorage.get
71
73
  /** Selected model override — empty string means "default" */
72
74
  export const selectedModel = signal("");
73
75
  try { if (typeof localStorage !== "undefined") selectedModel.value = localStorage.getItem("ve-selected-model") || ""; } catch {}
76
+ try { if (typeof localStorage !== "undefined") activeManualAgentId.value = localStorage.getItem("ve-active-manual-agent") || ""; } catch {}
74
77
 
75
78
  /** Computed: resolved active agent object */
76
79
  export const activeAgentInfo = computed(() => {
@@ -91,6 +94,8 @@ const MODES = [
91
94
  { id: "instant", label: "Instant", icon: "zap", description: "Fast back-and-forth" },
92
95
  ];
93
96
 
97
+ const VALID_MANUAL_MODES = new Set(MODES.map((mode) => mode.id));
98
+
94
99
  const AGENT_ICONS = {
95
100
  "codex-sdk": "zap",
96
101
  "copilot-sdk": "bot",
@@ -394,8 +399,10 @@ export async function loadAvailableAgents() {
394
399
  try {
395
400
  const res = await apiFetch("/api/agents/available", { _silent: true });
396
401
  const agents = Array.isArray(res) ? res : (res?.agents || res?.data || []);
402
+ const nextManualAgents = Array.isArray(res?.manualAgents) ? res.manualAgents : [];
397
403
  const reportedActive = String(res?.active || "").trim();
398
404
  availableAgents.value = agents;
405
+ manualAgents.value = nextManualAgents;
399
406
  // Prefer backend-reported active selection when present.
400
407
  if (reportedActive && agents.some((a) => a.id === reportedActive)) {
401
408
  activeAgent.value = reportedActive;
@@ -404,6 +411,10 @@ export async function loadAvailableAgents() {
404
411
  const firstEnabled = agents.find((a) => a.available);
405
412
  activeAgent.value = (firstEnabled || agents[0]).id;
406
413
  }
414
+ if (activeManualAgentId.value && !nextManualAgents.some((agent) => agent.id === activeManualAgentId.value)) {
415
+ activeManualAgentId.value = "";
416
+ try { localStorage.setItem("ve-active-manual-agent", ""); } catch {}
417
+ }
407
418
  } catch (err) {
408
419
  console.warn("[agent-selector] Failed to load agents:", err);
409
420
  // Provide sensible fallback agents for offline/dev mode
@@ -419,6 +430,12 @@ export async function loadAvailableAgents() {
419
430
  }
420
431
  }
421
432
 
433
+ function setActiveManualAgent(agentId) {
434
+ const nextId = String(agentId || "").trim();
435
+ activeManualAgentId.value = nextId;
436
+ try { localStorage.setItem("ve-active-manual-agent", nextId); } catch {}
437
+ }
438
+
422
439
  /**
423
440
  * Switch the active agent via API.
424
441
  * @param {string} agentId
@@ -861,6 +878,115 @@ export function AgentPicker() {
861
878
  `;
862
879
  }
863
880
 
881
+ export function ManualAgentPicker() {
882
+ const profiles = manualAgents.value;
883
+ const current = activeManualAgentId.value;
884
+ const [anchorEl, setAnchorEl] = useState(null);
885
+ const open = Boolean(anchorEl);
886
+
887
+ const grouped = profiles.reduce((acc, profile) => {
888
+ const key = String(profile?.sectionLabel || "Manual").trim() || "Manual";
889
+ if (!acc[key]) acc[key] = [];
890
+ acc[key].push(profile);
891
+ return acc;
892
+ }, {});
893
+ const currentProfile = profiles.find((profile) => profile.id === current) || null;
894
+ const currentLabel = currentProfile?.name || "Manual Agent";
895
+
896
+ const handleSelect = useCallback((profileId) => {
897
+ const nextId = String(profileId || "").trim();
898
+ const selected = profiles.find((profile) => profile.id === nextId) || null;
899
+ setActiveManualAgent(nextId);
900
+ if (selected?.interactiveMode && VALID_MANUAL_MODES.has(selected.interactiveMode)) {
901
+ setAgentMode(selected.interactiveMode);
902
+ }
903
+ if (selected?.model) {
904
+ selectedModel.value = selected.model;
905
+ try { localStorage.setItem("ve-selected-model", selected.model); } catch {}
906
+ }
907
+ haptic(selected ? "light" : "medium");
908
+ setAnchorEl(null);
909
+ }, [profiles]);
910
+
911
+ return html`
912
+ <${Tooltip} title="Select a library-backed manual chat agent" arrow>
913
+ <${Chip}
914
+ label=${currentLabel}
915
+ size="small"
916
+ variant=${currentProfile ? "filled" : "outlined"}
917
+ onClick=${(e) => setAnchorEl(e.currentTarget)}
918
+ icon=${html`<span style="font-size:13px;line-height:1">${resolveIcon("bot")}</span>`}
919
+ sx=${{
920
+ flexShrink: 0,
921
+ cursor: "pointer",
922
+ fontSize: 12,
923
+ fontWeight: 500,
924
+ color: "var(--tg-theme-text-color, #fff)",
925
+ borderColor: open ? "var(--tg-theme-button-color, #3b82f6)" : "rgba(255,255,255,0.08)",
926
+ bgcolor: currentProfile ? "rgba(59,130,246,0.12)" : "transparent",
927
+ "&:hover": { bgcolor: "rgba(255,255,255,0.06)" },
928
+ }}
929
+ />
930
+ </${Tooltip}>
931
+ <${Menu}
932
+ anchorEl=${anchorEl}
933
+ open=${open}
934
+ onClose=${() => setAnchorEl(null)}
935
+ anchorOrigin=${{ vertical: "top", horizontal: "left" }}
936
+ transformOrigin=${{ vertical: "bottom", horizontal: "left" }}
937
+ slotProps=${{ paper: { sx: { ...muiDarkPaper, minWidth: 260 } } }}
938
+ >
939
+ <${MenuItem} selected=${!current} onClick=${() => handleSelect("")}>
940
+ <${ListItemIcon} sx=${{ minWidth: "28px !important" }}>
941
+ ${!current ? html`<${Typography} sx=${{ color: "var(--tg-theme-button-color, #3b82f6)", fontWeight: 700, fontSize: 14 }}>✓</${Typography}>` : null}
942
+ </${ListItemIcon}>
943
+ <${ListItemText}
944
+ primary="No manual profile"
945
+ secondary="Use only the executor + mode selection"
946
+ primaryTypographyProps=${{ fontSize: 13, fontWeight: 500 }}
947
+ secondaryTypographyProps=${{ fontSize: 11 }}
948
+ />
949
+ </${MenuItem}>
950
+ ${profiles.length > 0 ? html`<${Divider} />` : null}
951
+ ${Object.entries(grouped).map(([sectionLabel, items], sectionIndex) => html`
952
+ <div key=${sectionLabel}>
953
+ ${sectionIndex > 0 ? html`<${Divider} />` : null}
954
+ <${MenuItem} disabled sx=${{ opacity: 0.7, fontSize: 11, textTransform: "uppercase", letterSpacing: 0.5 }}>
955
+ ${sectionLabel}
956
+ </${MenuItem}>
957
+ ${items.map((profile) => html`
958
+ <${MenuItem}
959
+ key=${profile.id}
960
+ selected=${profile.id === current}
961
+ onClick=${() => handleSelect(profile.id)}
962
+ >
963
+ <${ListItemIcon} sx=${{ minWidth: "28px !important" }}>
964
+ ${profile.id === current ? html`<${Typography} sx=${{ color: "var(--tg-theme-button-color, #3b82f6)", fontWeight: 700, fontSize: 14 }}>✓</${Typography}>` : null}
965
+ </${ListItemIcon}>
966
+ <${ListItemText}
967
+ primary=${profile.name}
968
+ secondary=${profile.description || profile.interactiveMode || profile.agentCategory || "Manual agent"}
969
+ primaryTypographyProps=${{ fontSize: 13, fontWeight: 500 }}
970
+ secondaryTypographyProps=${{ fontSize: 11 }}
971
+ />
972
+ </${MenuItem}>
973
+ `)}
974
+ </div>
975
+ `)}
976
+ ${profiles.length === 0 ? html`
977
+ <${MenuItem} disabled>
978
+ <${ListItemText}
979
+ primary="No manual agents"
980
+ secondary="Mark an interactive agent as visible in chat from the Library tab."
981
+ primaryTypographyProps=${{ fontSize: 13, fontWeight: 500 }}
982
+ secondaryTypographyProps=${{ fontSize: 11 }}
983
+ />
984
+ </${MenuItem}>
985
+ ` : null}
986
+ </${Menu}>
987
+ `;
988
+ }
989
+
864
990
  /* ═══════════════════════════════════════════════
865
991
  * AgentStatusBadge
866
992
  * MUI Chip showing agent runtime state
@@ -1016,6 +1142,7 @@ export function ChatInputToolbar() {
1016
1142
  <div class="chat-input-toolbar">
1017
1143
  <${AgentPicker} />
1018
1144
  <${AgentModeSelector} />
1145
+ <${ManualAgentPicker} />
1019
1146
  <${ModelPicker} />
1020
1147
  <${YoloToggle} />
1021
1148
  <${Box} sx=${{ flex: 1, minWidth: 8 }} />
@@ -568,6 +568,8 @@ export async function resumeSession(id) {
568
568
  const STATUS_COLOR_MAP = {
569
569
  running: "var(--accent)",
570
570
  active: "var(--accent)",
571
+ idle: "var(--color-warning)",
572
+ stalled: "var(--color-error)",
571
573
  paused: "var(--text-hint)",
572
574
  completed: "var(--color-done)",
573
575
  done: "var(--color-done)",
@@ -970,7 +970,7 @@
970
970
  "type": "action.run_command",
971
971
  "label": "Fetch Bosun PR State",
972
972
  "config": {
973
- "command": "node -e \" const {execFileSync}=require('child_process'); const hours=Number('{{lookbackHours}}')||24; const repoScope=String('{{repoScope}}'||'auto').trim(); const since=new Date(Date.now()-hours*3600000).toISOString(); function ghJson(args){ try{const o=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return o?JSON.parse(o):[];} catch{return [];} } const merged=ghJson(['pr','list','--state','merged','--label','bosun-attached','--json','number,title,body,headRefName,mergedAt','--limit','50']); const open=ghJson(['pr','list','--state','open','--label','bosun-attached','--json','number,title,body,headRefName,isDraft','--limit','50']); function extractTaskId(pr){ const src=String((pr.body||'')+'\\n'+(pr.title||'')); const m=src.match(/(?:Bosun-Task|VE-Task|Task-ID|task[_-]?id)[:\\s]+([a-zA-Z0-9_-]{4,64})/i); return m?m[1].trim():null; } const recentMerged=merged.filter(p=>!p.mergedAt||new Date(p.mergedAt)>=new Date(since)); console.log(JSON.stringify({ repoScope, merged:recentMerged.map(p=>({n:p.number,title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), open:open.filter(p=>!p.isDraft).map(p=>({n:p.number,title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), })); \"",
973
+ "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const hours=Number('{{lookbackHours}}')||24; const repoScope=String('{{repoScope}}'||'auto').trim(); const since=new Date(Date.now()-hours*3600000).toISOString(); function ghJson(args){ try{const o=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return o?JSON.parse(o):[];} catch{return [];} } function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(repoScope&&repoScope!=='auto'&&repoScope!=='all'&&repoScope!=='current'){ return [...new Set(repoScope.split(',').map(v=>v.trim()).filter(Boolean))]; } if(repoScope==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } function extractTaskId(pr){ const src=String((pr.body||'')+'\\n'+(pr.title||'')); const m=src.match(/(?:Bosun-Task|VE-Task|Task-ID|task[_-]?id)[:\\s]+([a-zA-Z0-9_-]{4,64})/i); return m?m[1].trim():null; } const repoTargets=resolveRepoTargets(); const merged=[]; const open=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const mergedArgs=['pr','list','--state','merged','--label','bosun-attached','--json','number,title,body,headRefName,mergedAt,url','--limit','50']; const openArgs=['pr','list','--state','open','--label','bosun-attached','--json','number,title,body,headRefName,isDraft,url','--limit','50']; if(repo){ mergedArgs.push('--repo',repo); openArgs.push('--repo',repo); } for(const pr of ghJson(mergedArgs)){ merged.push({...pr,__repo:repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim()}); } for(const pr of ghJson(openArgs)){ open.push({...pr,__repo:repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim()}); } } const recentMerged=merged.filter(p=>!p.mergedAt||new Date(p.mergedAt)>=new Date(since)); console.log(JSON.stringify({ repoScope, reposScanned: repoTargets.length, merged:recentMerged.map(p=>({n:p.number,repo:p.__repo||'',title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), open:open.filter(p=>!p.isDraft).map(p=>({n:p.number,repo:p.__repo||'',title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), })); \"",
974
974
  "continueOnError": true
975
975
  },
976
976
  "position": {
@@ -1001,7 +1001,7 @@
1001
1001
  "type": "action.run_command",
1002
1002
  "label": "Sync PR State → Kanban (Programmatic)",
1003
1003
  "config": {
1004
- "command": "node -e \" const {execFileSync}=require('child_process'); const fs=require('fs'); const raw=String(process.env.BOSUN_FETCH_PR_STATE||''); const data=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})(); const merged=Array.isArray(data.merged)?data.merged:[]; const open=Array.isArray(data.open)?data.open:[]; const updates=[]; const unresolved=[]; const taskCli=['task-cli.mjs','task/task-cli.mjs'].find(p=>fs.existsSync(p))||''; if(!taskCli){ console.log(JSON.stringify({updated:0,unresolved:[{reason:'task_cli_missing'}],needsAgent:true})); process.exit(0); } function runTask(args){return execFileSync('node',[taskCli,...args],{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();} function parseJsonObject(raw){const txt=String(raw||'').trim();if(!txt)return null;try{return JSON.parse(txt);}catch{}const lines=txt.split(/\\r?\\n/).map(s=>s.trim()).filter(Boolean);for(let i=lines.length-1;i>=0;i--){const line=lines[i];if(!(line.startsWith('{')||line.startsWith('[')))continue;try{return JSON.parse(line);}catch{}}const start=txt.indexOf('{');const end=txt.lastIndexOf('}');if(start>=0&&end>start){try{return JSON.parse(txt.slice(start,end+1));}catch{}}return null;} function getTaskSnapshot(id){ try{const raw=runTask(['get',id,'--json']);const task=parseJsonObject(raw);return {status:task?.status||null,reviewStatus:task?.reviewStatus||null};}catch{return {status:null,reviewStatus:null};} } for(const item of merged){ const id=String(item?.taskId||'').trim(); if(!id) continue; try{runTask(['update',id,'--status','done']);updates.push({taskId:id,status:'done'});}catch(e){unresolved.push({taskId:id,status:'done',error:String(e?.message||e)});} } for(const item of open){ const id=String(item?.taskId||'').trim(); if(!id) continue; try{const snap=getTaskSnapshot(id);const current=snap?.status;const review=String(snap?.reviewStatus||'').toLowerCase();if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}if(current==='todo'||current==='inprogress'){const reason=(review==='changes_requested'||review==='change_requested'||review==='requested_changes')?'changes_requested_pending_fix':'local_progress_state';updates.push({taskId:id,status:current,skipped:true,reason});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview'});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});} } console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:unresolved.length>0})); \"",
1004
+ "command": "node -e \" const {execFileSync}=require('child_process'); const fs=require('fs'); const raw=String(process.env.BOSUN_FETCH_PR_STATE||''); const data=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})(); const merged=Array.isArray(data.merged)?data.merged:[]; const open=Array.isArray(data.open)?data.open:[]; const updates=[]; const unresolved=[]; const maxBuffer=25*1024*1024; const cliPath=fs.existsSync('cli.mjs')?'cli.mjs':''; const taskCli=['task-cli.mjs','task/task-cli.mjs'].find(p=>fs.existsSync(p))||''; const taskRunner=cliPath?'cli':(taskCli?'task-cli':''); if(!taskRunner){ console.log(JSON.stringify({updated:0,unresolved:[{reason:'task_command_missing'}],needsAgent:true})); process.exit(0); } function runTask(args){const cmdArgs=taskRunner==='cli'?['cli.mjs','task',...args,'--config-dir','.bosun','--repo-root','.']:[taskCli,...args];return execFileSync('node',cmdArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe'],maxBuffer}).trim();} function parseJsonObject(raw){const txt=String(raw||'').trim();if(!txt)return null;try{return JSON.parse(txt);}catch{}const lines=txt.split(/\\r?\\n/);for(let start=0;start<lines.length;start++){const token=lines[start].trim();if(!(token==='['||token==='{'||token.startsWith('[{')||token.startsWith('{\"')||token.startsWith('[\"')))continue;const candidate=lines.slice(start).join('\\n').trim();try{return JSON.parse(candidate);}catch{}}const compact=lines.map(s=>s.trim()).filter(Boolean);for(let i=compact.length-1;i>=0;i--){const line=compact[i];if(!(line.startsWith('{')||line.startsWith('[')))continue;try{return JSON.parse(line);}catch{}}const start=txt.indexOf('{');const end=txt.lastIndexOf('}');if(start>=0&&end>start){try{return JSON.parse(txt.slice(start,end+1));}catch{}}return null;} let taskListCache=null; function normalizeRepo(value){return String(value||'').trim().toLowerCase();} function listTasks(){ if(Array.isArray(taskListCache)) return taskListCache; try{const raw=runTask(['list','--json']);const tasks=parseJsonObject(raw);taskListCache=Array.isArray(tasks)?tasks:[];return taskListCache;}catch{taskListCache=[];return taskListCache;} } function resolveTaskId(item){ const explicit=String(item?.taskId||'').trim(); if(explicit) return explicit; const branch=String(item?.branch||'').trim(); if(!branch) return ''; const repo=normalizeRepo(item?.repo); const matches=listTasks().filter((task)=>{ const taskBranch=String(task?.branchName||'').trim(); if(taskBranch!==branch) return false; const taskRepo=normalizeRepo(task?.repository||''); if(!repo || !taskRepo) return true; return taskRepo===repo; }); if(matches.length===1) return String(matches[0]?.id||'').trim(); const exactRepo=matches.find((task)=>normalizeRepo(task?.repository||'')===repo); return exactRepo?String(exactRepo?.id||'').trim():''; } function getTaskSnapshot(id){ try{const raw=runTask(['get',id,'--json']);const task=parseJsonObject(raw);return {status:task?.status||null,reviewStatus:task?.reviewStatus||null};}catch{return {status:null,reviewStatus:null};} } for(const item of merged){ const id=resolveTaskId(item); if(!id){unresolved.push({taskId:null,repo:String(item?.repo||''),branch:String(item?.branch||''),status:'done',reason:'task_lookup_failed'});continue;} try{runTask(['update',id,'--status','done']);updates.push({taskId:id,status:'done'});}catch(e){unresolved.push({taskId:id,status:'done',error:String(e?.message||e)});} } for(const item of open){ const id=resolveTaskId(item); if(!id){unresolved.push({taskId:null,repo:String(item?.repo||''),branch:String(item?.branch||''),status:'inreview',reason:'task_lookup_failed'});continue;} try{const snap=getTaskSnapshot(id);const current=String(snap?.status||'').trim().toLowerCase();const review=String(snap?.reviewStatus||'').toLowerCase();if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview',fromStatus:current||null,reviewStatus:review||null});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});} } const actionableUnresolved=unresolved.filter((item)=>String(item?.taskId||'').trim()); console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:actionableUnresolved.length>0})); \"",
1005
1005
  "continueOnError": true,
1006
1006
  "failOnError": false,
1007
1007
  "env": {
@@ -1021,7 +1021,7 @@
1021
1021
  "type": "condition.expression",
1022
1022
  "label": "Needs Agent Sync?",
1023
1023
  "config": {
1024
- "expression": "(()=>{try{const raw=$ctx.getNodeOutput('sync-programmatic')?.output||'{}';const d=JSON.parse(raw);return d?.needsAgent===true || (Array.isArray(d?.unresolved)&&d.unresolved.length>0);}catch{return true;}})()"
1024
+ "expression": "(()=>{try{const raw=$ctx.getNodeOutput('sync-programmatic')?.output||'{}';const d=JSON.parse(raw);const actionable=Array.isArray(d?.unresolved)?d.unresolved.some((item)=>String(item?.taskId||'').trim()):false;return d?.needsAgent===true || actionable;}catch{return true;}})()"
1025
1025
  },
1026
1026
  "position": {
1027
1027
  "x": 400,
@@ -21732,7 +21732,7 @@
21732
21732
  "type": "action.run_command",
21733
21733
  "label": "Fetch Bosun PR State",
21734
21734
  "config": {
21735
- "command": "node -e \" const {execFileSync}=require('child_process'); const hours=Number('{{lookbackHours}}')||24; const repoScope=String('{{repoScope}}'||'auto').trim(); const since=new Date(Date.now()-hours*3600000).toISOString(); function ghJson(args){ try{const o=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return o?JSON.parse(o):[];} catch{return [];} } const merged=ghJson(['pr','list','--state','merged','--label','bosun-attached','--json','number,title,body,headRefName,mergedAt','--limit','50']); const open=ghJson(['pr','list','--state','open','--label','bosun-attached','--json','number,title,body,headRefName,isDraft','--limit','50']); function extractTaskId(pr){ const src=String((pr.body||'')+'\\n'+(pr.title||'')); const m=src.match(/(?:Bosun-Task|VE-Task|Task-ID|task[_-]?id)[:\\s]+([a-zA-Z0-9_-]{4,64})/i); return m?m[1].trim():null; } const recentMerged=merged.filter(p=>!p.mergedAt||new Date(p.mergedAt)>=new Date(since)); console.log(JSON.stringify({ repoScope, merged:recentMerged.map(p=>({n:p.number,title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), open:open.filter(p=>!p.isDraft).map(p=>({n:p.number,title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), })); \"",
21735
+ "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const hours=Number('{{lookbackHours}}')||24; const repoScope=String('{{repoScope}}'||'auto').trim(); const since=new Date(Date.now()-hours*3600000).toISOString(); function ghJson(args){ try{const o=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return o?JSON.parse(o):[];} catch{return [];} } function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(repoScope&&repoScope!=='auto'&&repoScope!=='all'&&repoScope!=='current'){ return [...new Set(repoScope.split(',').map(v=>v.trim()).filter(Boolean))]; } if(repoScope==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } function extractTaskId(pr){ const src=String((pr.body||'')+'\\n'+(pr.title||'')); const m=src.match(/(?:Bosun-Task|VE-Task|Task-ID|task[_-]?id)[:\\s]+([a-zA-Z0-9_-]{4,64})/i); return m?m[1].trim():null; } const repoTargets=resolveRepoTargets(); const merged=[]; const open=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const mergedArgs=['pr','list','--state','merged','--label','bosun-attached','--json','number,title,body,headRefName,mergedAt,url','--limit','50']; const openArgs=['pr','list','--state','open','--label','bosun-attached','--json','number,title,body,headRefName,isDraft,url','--limit','50']; if(repo){ mergedArgs.push('--repo',repo); openArgs.push('--repo',repo); } for(const pr of ghJson(mergedArgs)){ merged.push({...pr,__repo:repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim()}); } for(const pr of ghJson(openArgs)){ open.push({...pr,__repo:repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim()}); } } const recentMerged=merged.filter(p=>!p.mergedAt||new Date(p.mergedAt)>=new Date(since)); console.log(JSON.stringify({ repoScope, reposScanned: repoTargets.length, merged:recentMerged.map(p=>({n:p.number,repo:p.__repo||'',title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), open:open.filter(p=>!p.isDraft).map(p=>({n:p.number,repo:p.__repo||'',title:p.title,branch:p.headRefName,taskId:extractTaskId(p)})), })); \"",
21736
21736
  "continueOnError": true
21737
21737
  },
21738
21738
  "position": {
@@ -21763,7 +21763,7 @@
21763
21763
  "type": "action.run_command",
21764
21764
  "label": "Sync PR State → Kanban (Programmatic)",
21765
21765
  "config": {
21766
- "command": "node -e \" const {execFileSync}=require('child_process'); const fs=require('fs'); const raw=String(process.env.BOSUN_FETCH_PR_STATE||''); const data=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})(); const merged=Array.isArray(data.merged)?data.merged:[]; const open=Array.isArray(data.open)?data.open:[]; const updates=[]; const unresolved=[]; const taskCli=['task-cli.mjs','task/task-cli.mjs'].find(p=>fs.existsSync(p))||''; if(!taskCli){ console.log(JSON.stringify({updated:0,unresolved:[{reason:'task_cli_missing'}],needsAgent:true})); process.exit(0); } function runTask(args){return execFileSync('node',[taskCli,...args],{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();} function parseJsonObject(raw){const txt=String(raw||'').trim();if(!txt)return null;try{return JSON.parse(txt);}catch{}const lines=txt.split(/\\r?\\n/).map(s=>s.trim()).filter(Boolean);for(let i=lines.length-1;i>=0;i--){const line=lines[i];if(!(line.startsWith('{')||line.startsWith('[')))continue;try{return JSON.parse(line);}catch{}}const start=txt.indexOf('{');const end=txt.lastIndexOf('}');if(start>=0&&end>start){try{return JSON.parse(txt.slice(start,end+1));}catch{}}return null;} function getTaskSnapshot(id){ try{const raw=runTask(['get',id,'--json']);const task=parseJsonObject(raw);return {status:task?.status||null,reviewStatus:task?.reviewStatus||null};}catch{return {status:null,reviewStatus:null};} } for(const item of merged){ const id=String(item?.taskId||'').trim(); if(!id) continue; try{runTask(['update',id,'--status','done']);updates.push({taskId:id,status:'done'});}catch(e){unresolved.push({taskId:id,status:'done',error:String(e?.message||e)});} } for(const item of open){ const id=String(item?.taskId||'').trim(); if(!id) continue; try{const snap=getTaskSnapshot(id);const current=snap?.status;const review=String(snap?.reviewStatus||'').toLowerCase();if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}if(current==='todo'||current==='inprogress'){const reason=(review==='changes_requested'||review==='change_requested'||review==='requested_changes')?'changes_requested_pending_fix':'local_progress_state';updates.push({taskId:id,status:current,skipped:true,reason});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview'});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});} } console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:unresolved.length>0})); \"",
21766
+ "command": "node -e \" const {execFileSync}=require('child_process'); const fs=require('fs'); const raw=String(process.env.BOSUN_FETCH_PR_STATE||''); const data=(()=>{try{return JSON.parse(raw||'{}')}catch{return {}}})(); const merged=Array.isArray(data.merged)?data.merged:[]; const open=Array.isArray(data.open)?data.open:[]; const updates=[]; const unresolved=[]; const maxBuffer=25*1024*1024; const cliPath=fs.existsSync('cli.mjs')?'cli.mjs':''; const taskCli=['task-cli.mjs','task/task-cli.mjs'].find(p=>fs.existsSync(p))||''; const taskRunner=cliPath?'cli':(taskCli?'task-cli':''); if(!taskRunner){ console.log(JSON.stringify({updated:0,unresolved:[{reason:'task_command_missing'}],needsAgent:true})); process.exit(0); } function runTask(args){const cmdArgs=taskRunner==='cli'?['cli.mjs','task',...args,'--config-dir','.bosun','--repo-root','.']:[taskCli,...args];return execFileSync('node',cmdArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe'],maxBuffer}).trim();} function parseJsonObject(raw){const txt=String(raw||'').trim();if(!txt)return null;try{return JSON.parse(txt);}catch{}const lines=txt.split(/\\r?\\n/);for(let start=0;start<lines.length;start++){const token=lines[start].trim();if(!(token==='['||token==='{'||token.startsWith('[{')||token.startsWith('{\"')||token.startsWith('[\"')))continue;const candidate=lines.slice(start).join('\\n').trim();try{return JSON.parse(candidate);}catch{}}const compact=lines.map(s=>s.trim()).filter(Boolean);for(let i=compact.length-1;i>=0;i--){const line=compact[i];if(!(line.startsWith('{')||line.startsWith('[')))continue;try{return JSON.parse(line);}catch{}}const start=txt.indexOf('{');const end=txt.lastIndexOf('}');if(start>=0&&end>start){try{return JSON.parse(txt.slice(start,end+1));}catch{}}return null;} let taskListCache=null; function normalizeRepo(value){return String(value||'').trim().toLowerCase();} function listTasks(){ if(Array.isArray(taskListCache)) return taskListCache; try{const raw=runTask(['list','--json']);const tasks=parseJsonObject(raw);taskListCache=Array.isArray(tasks)?tasks:[];return taskListCache;}catch{taskListCache=[];return taskListCache;} } function resolveTaskId(item){ const explicit=String(item?.taskId||'').trim(); if(explicit) return explicit; const branch=String(item?.branch||'').trim(); if(!branch) return ''; const repo=normalizeRepo(item?.repo); const matches=listTasks().filter((task)=>{ const taskBranch=String(task?.branchName||'').trim(); if(taskBranch!==branch) return false; const taskRepo=normalizeRepo(task?.repository||''); if(!repo || !taskRepo) return true; return taskRepo===repo; }); if(matches.length===1) return String(matches[0]?.id||'').trim(); const exactRepo=matches.find((task)=>normalizeRepo(task?.repository||'')===repo); return exactRepo?String(exactRepo?.id||'').trim():''; } function getTaskSnapshot(id){ try{const raw=runTask(['get',id,'--json']);const task=parseJsonObject(raw);return {status:task?.status||null,reviewStatus:task?.reviewStatus||null};}catch{return {status:null,reviewStatus:null};} } for(const item of merged){ const id=resolveTaskId(item); if(!id){unresolved.push({taskId:null,repo:String(item?.repo||''),branch:String(item?.branch||''),status:'done',reason:'task_lookup_failed'});continue;} try{runTask(['update',id,'--status','done']);updates.push({taskId:id,status:'done'});}catch(e){unresolved.push({taskId:id,status:'done',error:String(e?.message||e)});} } for(const item of open){ const id=resolveTaskId(item); if(!id){unresolved.push({taskId:null,repo:String(item?.repo||''),branch:String(item?.branch||''),status:'inreview',reason:'task_lookup_failed'});continue;} try{const snap=getTaskSnapshot(id);const current=String(snap?.status||'').trim().toLowerCase();const review=String(snap?.reviewStatus||'').toLowerCase();if(current==='inreview'||current==='done'){updates.push({taskId:id,status:current,skipped:true});continue;}runTask(['update',id,'--status','inreview']);updates.push({taskId:id,status:'inreview',fromStatus:current||null,reviewStatus:review||null});}catch(e){unresolved.push({taskId:id,status:'inreview',error:String(e?.message||e)});} } const actionableUnresolved=unresolved.filter((item)=>String(item?.taskId||'').trim()); console.log(JSON.stringify({updated:updates.length,updates,unresolved,needsAgent:actionableUnresolved.length>0})); \"",
21767
21767
  "continueOnError": true,
21768
21768
  "failOnError": false,
21769
21769
  "env": {
@@ -21783,7 +21783,7 @@
21783
21783
  "type": "condition.expression",
21784
21784
  "label": "Needs Agent Sync?",
21785
21785
  "config": {
21786
- "expression": "(()=>{try{const raw=$ctx.getNodeOutput('sync-programmatic')?.output||'{}';const d=JSON.parse(raw);return d?.needsAgent===true || (Array.isArray(d?.unresolved)&&d.unresolved.length>0);}catch{return true;}})()"
21786
+ "expression": "(()=>{try{const raw=$ctx.getNodeOutput('sync-programmatic')?.output||'{}';const d=JSON.parse(raw);const actionable=Array.isArray(d?.unresolved)?d.unresolved.some((item)=>String(item?.taskId||'').trim()):false;return d?.needsAgent===true || actionable;}catch{return true;}})()"
21787
21787
  },
21788
21788
  "position": {
21789
21789
  "x": 400,
@@ -27,6 +27,7 @@ const ROUTE_TABS = new Set([
27
27
  "infra",
28
28
  "logs",
29
29
  "library",
30
+ "marketplace",
30
31
  "telemetry",
31
32
  "settings",
32
33
  ]);
@@ -258,6 +259,7 @@ export const TAB_CONFIG = [
258
259
  { id: "infra", label: "Infra", icon: "server" },
259
260
  { id: "logs", label: "Logs", icon: "terminal" },
260
261
  { id: "library", label: "Library", icon: "book" },
262
+ { id: "marketplace", label: "Market", icon: "box" },
261
263
  { id: "manual-flows", label: "Run Flows", icon: "zap" },
262
264
  { id: "telemetry", label: "Telemetry", icon: "chart" },
263
265
  { id: "benchmarks", label: "Bench", icon: "chart" },
@@ -88,6 +88,7 @@ export function isPlaceholderTaskDescription(value) {
88
88
  if (!text) return false;
89
89
  const normalized = text.toLowerCase();
90
90
  return (
91
+ TASK_TEMPLATE_PLACEHOLDER_RE.test(text) ||
91
92
  normalized === "internal server error" ||
92
93
  normalized === "{\"ok\":false,\"error\":\"internal server error\"}" ||
93
94
  normalized === "{\"error\":\"internal server error\"}"
@@ -227,6 +228,7 @@ export async function loadRetryQueue() {
227
228
  }
228
229
 
229
230
  const TASK_IGNORE_LABEL = "codex:ignore";
231
+ const TASK_TEMPLATE_PLACEHOLDER_RE = /^\{\{\s*[\w.-]+\s*\}\}$/;
230
232
  const TASK_TEXT_REPLACEMENTS = [
231
233
  [/\u00D4\u00C7\u00F6/g, "-"],
232
234
  [/\u00D4\u00C7\u00A3/g, "\""],
@@ -576,11 +578,16 @@ export async function loadTasks(options = {}) {
576
578
  if (tasksSort.value) params.set("sort", tasksSort.value);
577
579
 
578
580
  const res = await apiFetch(`/api/tasks?${params}`, { _silent: true }).catch(
579
- () => ({
580
- data: [],
581
- total: 0,
582
- totalPages: 1,
583
- }),
581
+ (err) => {
582
+ console.warn("[state] loadTasks fetch failed, keeping previous data:", err?.message || err);
583
+ return {
584
+ data: tasksData.value || [],
585
+ total: tasksTotal.value || 0,
586
+ totalPages: tasksTotalPages.value || 1,
587
+ statusCounts: tasksStatusCounts.value || {},
588
+ _fetchFailed: true,
589
+ };
590
+ },
584
591
  );
585
592
  const nextTasks = Array.isArray(res.data)
586
593
  ? res.data.map(normalizeTaskForUi)
@@ -1027,6 +1034,7 @@ const WS_CHANNEL_MAP = {
1027
1034
  infra: ["worktrees", "workspaces", "presence"],
1028
1035
  control: ["executor", "overview"],
1029
1036
  logs: ["*"],
1037
+ marketplace: ["library"],
1030
1038
  telemetry: ["*", "retry-queue"],
1031
1039
  settings: ["overview"],
1032
1040
  };