bosun 0.29.3 → 0.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agent-pool.mjs CHANGED
@@ -307,8 +307,26 @@ const SDK_ADAPTERS = {
307
307
  },
308
308
  };
309
309
 
310
- /** Ordered fallback chain for SDK resolution */
311
- const SDK_FALLBACK_ORDER = ["codex", "copilot", "claude"];
310
+ /**
311
+ * Ordered fallback chain for SDK resolution.
312
+ * Configurable via bosun.config.json → agentPool.fallbackOrder
313
+ */
314
+ let SDK_FALLBACK_ORDER = ["codex", "copilot", "claude"];
315
+
316
+ // Attempt to load custom fallback order from config
317
+ try {
318
+ const cfg = loadConfig();
319
+ const customOrder = cfg?.agentPool?.fallbackOrder;
320
+ if (Array.isArray(customOrder) && customOrder.length > 0) {
321
+ // Validate: only accept known SDK names
322
+ const valid = customOrder
323
+ .map((s) => String(s).trim().toLowerCase())
324
+ .filter((s) => SDK_ADAPTERS[s]);
325
+ if (valid.length > 0) SDK_FALLBACK_ORDER = valid;
326
+ }
327
+ } catch {
328
+ // loadConfig not available or no custom order — use default
329
+ }
312
330
 
313
331
  // ---------------------------------------------------------------------------
314
332
  // SDK Resolution & Cache
package/agent-sdk.mjs CHANGED
@@ -21,12 +21,12 @@ const DEFAULT_CAPABILITIES_BY_PRIMARY = {
21
21
  },
22
22
  copilot: {
23
23
  steering: false,
24
- subagents: false,
24
+ subagents: true,
25
25
  vscodeTools: true,
26
26
  },
27
27
  claude: {
28
28
  steering: false,
29
- subagents: false,
29
+ subagents: true,
30
30
  vscodeTools: false,
31
31
  },
32
32
  };
package/codex-config.mjs CHANGED
@@ -9,6 +9,16 @@
9
9
  * 5. Sandbox permissions and shell environment policy
10
10
  * 6. Common MCP servers (context7, microsoft-docs)
11
11
  *
12
+ * SCOPE: This manages the GLOBAL ~/.codex/config.toml which contains:
13
+ * - Model provider configs (API keys, base URLs) — MUST be global
14
+ * - Stream timeouts & retry settings — per-provider, global
15
+ * - Sandbox workspace-write (writable_roots) — spans multiple repos, global
16
+ * - Feature flags, MCP servers, agent_sdk — kept as FALLBACK defaults
17
+ * (prefer repo-level .codex/config.toml via repo-config.mjs)
18
+ *
19
+ * For project-scoped settings, see repo-config.mjs which generates
20
+ * .codex/config.toml at the repo level.
21
+ *
12
22
  * Uses string-based TOML manipulation (no parser dependency) — we only
13
23
  * append or patch well-known sections rather than rewriting the whole file.
14
24
  */
@@ -55,25 +65,38 @@ const AGENT_SDK_CAPS_HEADER = "[agent_sdk.capabilities]";
55
65
  const AGENTS_HEADER = "[agents]";
56
66
  const DEFAULT_AGENT_MAX_THREADS = 12;
57
67
 
58
- const DEFAULT_AGENT_SDK_BLOCK = [
59
- "",
60
- "# ── Agent SDK selection (added by bosun) ──",
61
- AGENT_SDK_HEADER,
62
- "# Primary agent SDK used for in-process automation.",
63
- '# Supported: "codex", "copilot", "claude"',
64
- 'primary = "codex"',
65
- "# Max concurrent agent threads per Codex session.",
66
- `max_threads = ${DEFAULT_AGENT_MAX_THREADS}`,
67
- "",
68
- AGENT_SDK_CAPS_HEADER,
69
- "# Live steering updates during an active run.",
70
- "steering = true",
71
- "# Ability to spawn subagents/child tasks.",
72
- "subagents = true",
73
- "# Access to VS Code tools (Copilot extension).",
74
- "vscode_tools = false",
75
- "",
76
- ].join("\n");
68
+ /**
69
+ * Build the default [agent_sdk] TOML block.
70
+ * @param {string} [primary="codex"] The primary SDK: "codex", "copilot", or "claude"
71
+ * @returns {string}
72
+ */
73
+ function buildDefaultAgentSdkBlock(primary = "codex") {
74
+ const caps = {
75
+ codex: { steering: true, subagents: true, vscodeTools: false },
76
+ copilot: { steering: false, subagents: true, vscodeTools: true },
77
+ claude: { steering: false, subagents: true, vscodeTools: false },
78
+ };
79
+ const c = caps[primary] || caps.codex;
80
+ return [
81
+ "",
82
+ "# ── Agent SDK selection (added by bosun) ──",
83
+ AGENT_SDK_HEADER,
84
+ "# Primary agent SDK used for in-process automation.",
85
+ '# Supported: "codex", "copilot", "claude"',
86
+ `primary = "${primary}"`,
87
+ "# Max concurrent agent threads per Codex session.",
88
+ `max_threads = ${DEFAULT_AGENT_MAX_THREADS}`,
89
+ "",
90
+ AGENT_SDK_CAPS_HEADER,
91
+ "# Live steering updates during an active run.",
92
+ `steering = ${c.steering}`,
93
+ "# Ability to spawn subagents/child tasks.",
94
+ `subagents = ${c.subagents}`,
95
+ "# Access to VS Code tools (Copilot extension).",
96
+ `vscode_tools = ${c.vscodeTools}`,
97
+ "",
98
+ ].join("\n");
99
+ }
77
100
 
78
101
  /**
79
102
  * @deprecated No longer used — max_threads is now managed under [agent_sdk].
@@ -411,7 +434,7 @@ function formatTomlArray(values) {
411
434
  return `[${values.map((value) => `"${String(value).replace(/"/g, '\\"')}"`).join(", ")}]`;
412
435
  }
413
436
 
414
- function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
437
+ function normalizeWritableRoots(input, { repoRoot, additionalRoots, validateExistence = false } = {}) {
415
438
  const roots = new Set();
416
439
  const addRoot = (value) => {
417
440
  const trimmed = String(value || "").trim();
@@ -420,6 +443,16 @@ function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
420
443
  // at Codex launch time and cause "writable root does not exist" errors
421
444
  // (e.g. /home/user/.codex/.git). Only accept absolute paths.
422
445
  if (!trimmed.startsWith("/")) return;
446
+ // When validateExistence is true, skip paths that don't exist on disk.
447
+ // This prevents the sandbox from failing to start with phantom roots.
448
+ if (validateExistence && !existsSync(trimmed)) return;
449
+ roots.add(trimmed);
450
+ };
451
+ // Always-add: these are primary roots (repo root, parent) that should be
452
+ // present even if validateExistence is true — they're the intended CWD.
453
+ const addPrimaryRoot = (value) => {
454
+ const trimmed = String(value || "").trim();
455
+ if (!trimmed || !trimmed.startsWith("/")) return;
423
456
  roots.add(trimmed);
424
457
  };
425
458
  if (Array.isArray(input)) {
@@ -437,15 +470,18 @@ function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
437
470
  if (!repo) return;
438
471
  const r = String(repo).trim();
439
472
  if (!r || !r.startsWith("/")) return;
440
- addRoot(r);
473
+ // Repo root and parent are always added (they're primary working dirs)
474
+ addPrimaryRoot(r);
441
475
  const gitDir = resolve(r, ".git");
442
476
  // Only add .git if it actually exists (prevents phantom writable roots)
443
477
  if (existsSync(gitDir)) addRoot(gitDir);
444
478
  const cacheWorktrees = resolve(r, ".cache", "worktrees");
445
- addRoot(cacheWorktrees);
446
- addRoot(resolve(r, ".cache"));
479
+ // Only add .cache subdirs if they exist — avoid phantom roots
480
+ if (existsSync(cacheWorktrees)) addRoot(cacheWorktrees);
481
+ const cacheDir = resolve(r, ".cache");
482
+ if (existsSync(cacheDir)) addRoot(cacheDir);
447
483
  const parent = dirname(r);
448
- if (parent && parent !== r) addRoot(parent);
484
+ if (parent && parent !== r) addPrimaryRoot(parent);
449
485
  };
450
486
 
451
487
  addRepoRootPaths(repoRoot);
@@ -515,13 +551,17 @@ export function ensureGitAncestor(dir) {
515
551
  * @returns {string[]} Merged writable roots
516
552
  */
517
553
  export function buildTaskWritableRoots({ worktreePath, repoRoot, existingRoots = [] } = {}) {
518
- const roots = new Set(existingRoots.filter(r => r && r.startsWith("/")));
554
+ const roots = new Set(existingRoots.filter(r => r && r.startsWith("/") && existsSync(r)));
519
555
  const addIfExists = (p) => {
556
+ if (p && p.startsWith("/") && existsSync(p)) roots.add(p);
557
+ };
558
+ // Add path even if it doesn't exist yet (will be created by the task)
559
+ const addRoot = (p) => {
520
560
  if (p && p.startsWith("/")) roots.add(p);
521
561
  };
522
562
 
523
563
  if (worktreePath) {
524
- addIfExists(worktreePath);
564
+ addRoot(worktreePath); // Worktree dir itself may be about to be created
525
565
  // Worktrees have a .git file pointing to main repo — resolve the actual git dir
526
566
  const ancestor = ensureGitAncestor(worktreePath);
527
567
  if (ancestor.gitDir) addIfExists(ancestor.gitDir);
@@ -531,7 +571,7 @@ export function buildTaskWritableRoots({ worktreePath, repoRoot, existingRoots =
531
571
  }
532
572
  }
533
573
  if (repoRoot) {
534
- addIfExists(repoRoot);
574
+ addRoot(repoRoot);
535
575
  const gitDir = resolve(repoRoot, ".git");
536
576
  if (existsSync(gitDir)) addIfExists(gitDir);
537
577
  addIfExists(resolve(repoRoot, ".cache", "worktrees"));
@@ -557,7 +597,7 @@ export function ensureSandboxWorkspaceWrite(toml, options = {}) {
557
597
  excludeSlashTmp = false,
558
598
  } = options;
559
599
 
560
- const desiredRoots = normalizeWritableRoots(writableRoots, { repoRoot, additionalRoots });
600
+ const desiredRoots = normalizeWritableRoots(writableRoots, { repoRoot, additionalRoots, validateExistence: true });
561
601
  if (!hasSandboxWorkspaceWrite(toml)) {
562
602
  if (desiredRoots.length === 0) {
563
603
  return { toml, changed: false, added: false, rootsAdded: [] };
@@ -609,13 +649,18 @@ export function ensureSandboxWorkspaceWrite(toml, options = {}) {
609
649
  const match = section.match(rootsRegex);
610
650
  if (match) {
611
651
  const existingRoots = parseTomlArrayLiteral(match[1]);
612
- const merged = normalizeWritableRoots(existingRoots, { repoRoot });
652
+ // Filter out stale roots that no longer exist on disk
653
+ const validExisting = existingRoots.filter((r) => r === "/tmp" || existsSync(r));
654
+ const merged = normalizeWritableRoots(validExisting, { repoRoot, validateExistence: true });
613
655
  for (const root of desiredRoots) {
614
656
  if (!merged.includes(root)) {
615
657
  merged.push(root);
616
658
  rootsAdded.push(root);
617
659
  }
618
660
  }
661
+ // Track any roots that were removed due to non-existence
662
+ const staleRemoved = existingRoots.filter((r) => r !== "/tmp" && !existsSync(r));
663
+ if (staleRemoved.length > 0) changed = true;
619
664
  const formatted = formatTomlArray(merged);
620
665
  if (formatted !== match[1]) {
621
666
  section = section.replace(rootsRegex, `writable_roots = ${formatted}`);
@@ -642,6 +687,39 @@ export function ensureSandboxWorkspaceWrite(toml, options = {}) {
642
687
  };
643
688
  }
644
689
 
690
+ /**
691
+ * Prune writable_roots in [sandbox_workspace_write] that no longer exist on disk.
692
+ * Returns the updated TOML and a list of removed paths.
693
+ * @param {string} toml
694
+ * @returns {{ toml: string, changed: boolean, removed: string[] }}
695
+ */
696
+ export function pruneStaleSandboxRoots(toml) {
697
+ if (!hasSandboxWorkspaceWrite(toml)) {
698
+ return { toml, changed: false, removed: [] };
699
+ }
700
+ const header = "[sandbox_workspace_write]";
701
+ const headerIdx = toml.indexOf(header);
702
+ if (headerIdx === -1) return { toml, changed: false, removed: [] };
703
+ const afterHeader = headerIdx + header.length;
704
+ const nextSection = toml.indexOf("\n[", afterHeader);
705
+ const sectionEnd = nextSection === -1 ? toml.length : nextSection;
706
+ let section = toml.substring(afterHeader, sectionEnd);
707
+
708
+ const rootsRegex = /^writable_roots\s*=\s*(\[[^\]]*\])\s*$/m;
709
+ const match = section.match(rootsRegex);
710
+ if (!match) return { toml, changed: false, removed: [] };
711
+
712
+ const existing = parseTomlArrayLiteral(match[1]);
713
+ const valid = existing.filter((r) => r === "/tmp" || existsSync(r));
714
+ const removed = existing.filter((r) => r !== "/tmp" && !existsSync(r));
715
+ if (removed.length === 0) return { toml, changed: false, removed: [] };
716
+
717
+ section = section.replace(rootsRegex, `writable_roots = ${formatTomlArray(valid)}`);
718
+ const updatedToml =
719
+ toml.substring(0, afterHeader) + section + toml.substring(sectionEnd);
720
+ return { toml: updatedToml, changed: true, removed };
721
+ }
722
+
645
723
  /**
646
724
  * Build the [shell_environment_policy] section.
647
725
  * Default: inherit = "all" so .NET, Go, Node etc. env vars are visible.
@@ -810,9 +888,12 @@ export function hasAgentSdkConfig(toml) {
810
888
 
811
889
  /**
812
890
  * Build the default agent SDK block.
891
+ * @param {object} [opts]
892
+ * @param {string} [opts.primary="codex"] Primary SDK: "codex", "copilot", or "claude"
893
+ * @returns {string}
813
894
  */
814
- export function buildAgentSdkBlock() {
815
- return DEFAULT_AGENT_SDK_BLOCK;
895
+ export function buildAgentSdkBlock({ primary = "codex" } = {}) {
896
+ return buildDefaultAgentSdkBlock(primary);
816
897
  }
817
898
 
818
899
  /**
@@ -1054,12 +1135,14 @@ export function ensureRetrySettings(toml, providerName) {
1054
1135
  * @param {boolean} [opts.skipVk]
1055
1136
  * @param {boolean} [opts.dryRun] If true, returns result without writing
1056
1137
  * @param {object} [opts.env] Environment overrides (defaults to process.env)
1138
+ * @param {string} [opts.primarySdk] Primary agent SDK: "codex", "copilot", or "claude"
1057
1139
  */
1058
1140
  export function ensureCodexConfig({
1059
1141
  vkBaseUrl = "http://127.0.0.1:54089",
1060
1142
  skipVk = false,
1061
1143
  dryRun = false,
1062
1144
  env = process.env,
1145
+ primarySdk,
1063
1146
  } = {}) {
1064
1147
  const result = {
1065
1148
  path: CONFIG_PATH,
@@ -1075,6 +1158,7 @@ export function ensureCodexConfig({
1075
1158
  sandboxWorkspaceAdded: false,
1076
1159
  sandboxWorkspaceUpdated: false,
1077
1160
  sandboxWorkspaceRootsAdded: [],
1161
+ sandboxStaleRootsRemoved: [],
1078
1162
  shellEnvAdded: false,
1079
1163
  commonMcpAdded: false,
1080
1164
  profileProvidersAdded: [],
@@ -1155,8 +1239,21 @@ export function ensureCodexConfig({
1155
1239
 
1156
1240
  // ── 1b. Ensure agent SDK selection block ──────────────────
1157
1241
 
1242
+ // Resolve which SDK should be primary:
1243
+ // 1. Explicit primarySdk parameter
1244
+ // 2. PRIMARY_AGENT env var (e.g. "copilot-sdk" → "copilot")
1245
+ // 3. Default: "codex"
1246
+ const resolvedPrimary = (() => {
1247
+ if (primarySdk && ["codex", "copilot", "claude"].includes(primarySdk)) {
1248
+ return primarySdk;
1249
+ }
1250
+ const envPrimary = (env.PRIMARY_AGENT || "").trim().toLowerCase().replace(/-sdk$/, "");
1251
+ if (["codex", "copilot", "claude"].includes(envPrimary)) return envPrimary;
1252
+ return "codex";
1253
+ })();
1254
+
1158
1255
  if (!hasAgentSdkConfig(toml)) {
1159
- toml += buildAgentSdkBlock();
1256
+ toml += buildAgentSdkBlock({ primary: resolvedPrimary });
1160
1257
  result.agentSdkAdded = true;
1161
1258
  }
1162
1259
 
@@ -1241,6 +1338,14 @@ export function ensureCodexConfig({
1241
1338
  result.sandboxWorkspaceUpdated = !ensured.added;
1242
1339
  result.sandboxWorkspaceRootsAdded = ensured.rootsAdded || [];
1243
1340
  }
1341
+
1342
+ // Prune any writable_roots that no longer exist on disk
1343
+ const pruned = pruneStaleSandboxRoots(toml);
1344
+ if (pruned.changed) {
1345
+ toml = pruned.toml;
1346
+ result.sandboxWorkspaceUpdated = true;
1347
+ result.sandboxStaleRootsRemoved = pruned.removed;
1348
+ }
1244
1349
  }
1245
1350
 
1246
1351
  // ── 1g. Ensure shell environment policy ───────────────────
@@ -1408,6 +1513,15 @@ export function printConfigSummary(result, log = console.log) {
1408
1513
  );
1409
1514
  }
1410
1515
 
1516
+ if (result.sandboxStaleRootsRemoved && result.sandboxStaleRootsRemoved.length > 0) {
1517
+ log(
1518
+ ` 🗑️ Pruned ${result.sandboxStaleRootsRemoved.length} stale writable root(s) that no longer exist`,
1519
+ );
1520
+ for (const r of result.sandboxStaleRootsRemoved) {
1521
+ log(` - ${r}`);
1522
+ }
1523
+ }
1524
+
1411
1525
  if (result.shellEnvAdded) {
1412
1526
  log(" ✅ Added shell environment policy (inherit=all)");
1413
1527
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.29.3",
3
+ "version": "0.29.4",
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/setup.mjs CHANGED
@@ -1500,6 +1500,15 @@ const EXECUTOR_PRESETS = {
1500
1500
  role: "primary",
1501
1501
  },
1502
1502
  ],
1503
+ "claude-only": [
1504
+ {
1505
+ name: "claude-default",
1506
+ executor: "CLAUDE",
1507
+ variant: "CLAUDE_OPUS_4",
1508
+ weight: 100,
1509
+ role: "primary",
1510
+ },
1511
+ ],
1503
1512
  triple: [
1504
1513
  {
1505
1514
  name: "copilot-claude",
@@ -1817,6 +1826,16 @@ function normalizeSetupConfiguration({
1817
1826
  enabled: executor.enabled !== false,
1818
1827
  }));
1819
1828
 
1829
+ // Derive PRIMARY_AGENT from executor config for SDK resolution
1830
+ {
1831
+ const primaryExec = configJson.executors.find((e) => e.role === "primary");
1832
+ if (primaryExec) {
1833
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
1834
+ env.PRIMARY_AGENT = env.PRIMARY_AGENT ||
1835
+ sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
1836
+ }
1837
+ }
1838
+
1820
1839
  configJson.failover = {
1821
1840
  strategy: normalizeEnum(
1822
1841
  configJson.failover?.strategy || env.FAILOVER_STRATEGY || "next-in-line",
@@ -2460,6 +2479,7 @@ async function main() {
2460
2479
  "Codex only",
2461
2480
  "Copilot + Codex (50/50 split)",
2462
2481
  "Copilot only (Claude Opus 4.6)",
2482
+ "Claude only (direct API)",
2463
2483
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2464
2484
  "Custom — I'll define my own executors",
2465
2485
  ]
@@ -2467,6 +2487,7 @@ async function main() {
2467
2487
  "Codex only",
2468
2488
  "Copilot + Codex (50/50 split)",
2469
2489
  "Copilot only (Claude Opus 4.6)",
2490
+ "Claude only (direct API)",
2470
2491
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2471
2492
  ];
2472
2493
 
@@ -2477,8 +2498,8 @@ async function main() {
2477
2498
  );
2478
2499
 
2479
2500
  const presetNames = isAdvancedSetup
2480
- ? ["codex-only", "copilot-codex", "copilot-only", "triple", "custom"]
2481
- : ["codex-only", "copilot-codex", "copilot-only", "triple"];
2501
+ ? ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple", "custom"]
2502
+ : ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple"];
2482
2503
  const presetKey = presetNames[presetIdx] || "codex-only";
2483
2504
 
2484
2505
  if (presetKey === "custom") {
@@ -2629,92 +2650,140 @@ async function main() {
2629
2650
  if (!wantCodexFallback) env.CODEX_SDK_DISABLED = "true";
2630
2651
  }
2631
2652
 
2632
- // ── Step 5: AI Provider ────────────────────────────────
2633
- headingStep(5, "AI / Codex Provider", markSetupProgress);
2653
+ // ── Step 5: AI Provider Keys ─────────────────────────────
2654
+ headingStep(5, "AI Provider Keys", markSetupProgress);
2634
2655
  console.log(
2635
- " Codex Monitor uses the Codex SDK for crash analysis & autofix.\n",
2656
+ " Configure API keys for the agent SDKs in your executor preset.\n",
2636
2657
  );
2637
2658
 
2638
- const providerIdx = await prompt.choose(
2639
- "Select AI provider:",
2640
- [
2641
- "OpenAI (default)",
2642
- "Azure OpenAI",
2643
- "Local model (Ollama, vLLM, etc.)",
2644
- "Other OpenAI-compatible endpoint",
2645
- "None disable AI features",
2646
- ],
2647
- 0,
2648
- );
2649
-
2650
- if (providerIdx < 4) {
2651
- env.OPENAI_API_KEY = await prompt.ask(
2652
- "API Key",
2653
- process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_API_KEY || "",
2654
- );
2659
+ // Determine which SDK families are needed
2660
+ const needsCodexSdk = usedSdks.has("CODEX") || env.CODEX_SDK_DISABLED !== "true";
2661
+ const needsCopilotSdk = usedSdks.has("COPILOT") || env.COPILOT_SDK_DISABLED !== "true";
2662
+ const needsClaudeSdk = usedSdks.has("CLAUDE") || env.CLAUDE_SDK_DISABLED !== "true";
2663
+
2664
+ // ── 5a. Copilot / GitHub Token ──────────────────────
2665
+ if (needsCopilotSdk) {
2666
+ console.log(chalk.bold(" Copilot SDK") + chalk.dim(" (uses GitHub token)\n"));
2667
+ const existingGhToken = process.env.COPILOT_CLI_TOKEN || process.env.GITHUB_TOKEN || "";
2668
+ if (existingGhToken) {
2669
+ info(`GitHub token detected (${existingGhToken.slice(0, 8)}…). Copilot SDK will use it.`);
2670
+ } else {
2671
+ const ghToken = await prompt.ask(
2672
+ "GitHub Token (GITHUB_TOKEN or COPILOT_CLI_TOKEN, blank to skip)",
2673
+ "",
2674
+ );
2675
+ if (ghToken) env.GITHUB_TOKEN = ghToken;
2676
+ }
2677
+ // Copilot permission defaults
2678
+ env.COPILOT_NO_ALLOW_ALL = env.COPILOT_NO_ALLOW_ALL || "false";
2679
+ env.COPILOT_ENABLE_ASK_USER = env.COPILOT_ENABLE_ASK_USER || "false";
2680
+ env.COPILOT_AGENT_MAX_REQUESTS = env.COPILOT_AGENT_MAX_REQUESTS || "500";
2655
2681
  }
2656
- if (providerIdx === 1) {
2657
- // Azure OpenAI also needs AZURE_OPENAI_API_KEY for adapters that check it directly
2658
- env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
2659
- env.OPENAI_BASE_URL = await prompt.ask(
2660
- "Azure endpoint URL",
2661
- process.env.OPENAI_BASE_URL || "",
2662
- );
2663
- env.CODEX_MODEL = await prompt.ask(
2664
- "Deployment/model name",
2665
- process.env.CODEX_MODEL || "",
2666
- );
2667
- } else if (providerIdx === 2) {
2668
- env.OPENAI_API_KEY = env.OPENAI_API_KEY || "ollama";
2669
- env.OPENAI_BASE_URL = await prompt.ask(
2670
- "Local API URL",
2671
- "http://localhost:11434/v1",
2672
- );
2673
- env.CODEX_MODEL = await prompt.ask("Model name", "codex");
2674
- } else if (providerIdx === 3) {
2675
- env.OPENAI_BASE_URL = await prompt.ask("API Base URL", "");
2676
- env.CODEX_MODEL = await prompt.ask("Model name", "");
2677
- } else if (providerIdx === 4) {
2678
- env.CODEX_SDK_DISABLED = "true";
2682
+
2683
+ // ── 5b. Claude / Anthropic Key ──────────────────────
2684
+ if (needsClaudeSdk) {
2685
+ console.log(chalk.bold("\n Claude SDK") + chalk.dim(" (uses Anthropic API key)\n"));
2686
+ const existingAnthropicKey = process.env.ANTHROPIC_API_KEY || "";
2687
+ if (existingAnthropicKey) {
2688
+ info(`Anthropic API key detected (${existingAnthropicKey.slice(0, 8)}…). Claude SDK will use it.`);
2689
+ } else {
2690
+ const anthropicKey = await prompt.ask(
2691
+ "Anthropic API Key (ANTHROPIC_API_KEY, blank to skip)",
2692
+ "",
2693
+ );
2694
+ if (anthropicKey) env.ANTHROPIC_API_KEY = anthropicKey;
2695
+ }
2696
+ // Claude always runs in bypass mode under Bosun
2697
+ env.CLAUDE_PERMISSION_MODE = env.CLAUDE_PERMISSION_MODE || "bypassPermissions";
2679
2698
  }
2680
2699
 
2681
- if (providerIdx < 4) {
2682
- const configureProfiles = await prompt.confirm(
2683
- "Configure model profiles (xl/m) for one-click switching?",
2684
- true,
2700
+ // ── 5c. Codex / OpenAI Key ──────────────────────────
2701
+ if (needsCodexSdk) {
2702
+ console.log(chalk.bold("\n Codex SDK") + chalk.dim(" (uses OpenAI API key)\n"));
2703
+
2704
+ const providerIdx = await prompt.choose(
2705
+ "Select AI provider for Codex:",
2706
+ [
2707
+ "OpenAI (default)",
2708
+ "Azure OpenAI",
2709
+ "Local model (Ollama, vLLM, etc.)",
2710
+ "Other OpenAI-compatible endpoint",
2711
+ "None — disable Codex SDK",
2712
+ ],
2713
+ 0,
2685
2714
  );
2686
- if (configureProfiles) {
2687
- const activeProfileIdx = await prompt.choose(
2688
- "Default active profile:",
2689
- ["xl (high quality)", "m (faster/cheaper)"],
2690
- 0,
2715
+
2716
+ if (providerIdx < 4) {
2717
+ env.OPENAI_API_KEY = await prompt.ask(
2718
+ "API Key",
2719
+ process.env.OPENAI_API_KEY || process.env.AZURE_OPENAI_API_KEY || "",
2691
2720
  );
2692
- env.CODEX_MODEL_PROFILE = activeProfileIdx === 0 ? "xl" : "m";
2693
- env.CODEX_MODEL_PROFILE_SUBAGENT = activeProfileIdx === 0 ? "m" : "xl";
2694
-
2695
- env.CODEX_MODEL_PROFILE_XL_MODEL = await prompt.ask(
2696
- "XL profile model",
2697
- process.env.CODEX_MODEL_PROFILE_XL_MODEL ||
2698
- process.env.CODEX_MODEL ||
2699
- "gpt-5.3-codex",
2721
+ }
2722
+ if (providerIdx === 1) {
2723
+ env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
2724
+ env.OPENAI_BASE_URL = await prompt.ask(
2725
+ "Azure endpoint URL",
2726
+ process.env.OPENAI_BASE_URL || "",
2700
2727
  );
2701
- env.CODEX_MODEL_PROFILE_M_MODEL = await prompt.ask(
2702
- "M profile model",
2703
- process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini",
2728
+ env.CODEX_MODEL = await prompt.ask(
2729
+ "Deployment/model name",
2730
+ process.env.CODEX_MODEL || "",
2704
2731
  );
2732
+ } else if (providerIdx === 2) {
2733
+ env.OPENAI_API_KEY = env.OPENAI_API_KEY || "ollama";
2734
+ env.OPENAI_BASE_URL = await prompt.ask(
2735
+ "Local API URL",
2736
+ "http://localhost:11434/v1",
2737
+ );
2738
+ env.CODEX_MODEL = await prompt.ask("Model name", "codex");
2739
+ } else if (providerIdx === 3) {
2740
+ env.OPENAI_BASE_URL = await prompt.ask("API Base URL", "");
2741
+ env.CODEX_MODEL = await prompt.ask("Model name", "");
2742
+ } else if (providerIdx === 4) {
2743
+ env.CODEX_SDK_DISABLED = "true";
2744
+ }
2745
+
2746
+ if (providerIdx < 4) {
2747
+ const configureProfiles = await prompt.confirm(
2748
+ "Configure model profiles (xl/m) for one-click switching?",
2749
+ true,
2750
+ );
2751
+ if (configureProfiles) {
2752
+ const activeProfileIdx = await prompt.choose(
2753
+ "Default active profile:",
2754
+ ["xl (high quality)", "m (faster/cheaper)"],
2755
+ 0,
2756
+ );
2757
+ env.CODEX_MODEL_PROFILE = activeProfileIdx === 0 ? "xl" : "m";
2758
+ env.CODEX_MODEL_PROFILE_SUBAGENT = activeProfileIdx === 0 ? "m" : "xl";
2759
+
2760
+ env.CODEX_MODEL_PROFILE_XL_MODEL = await prompt.ask(
2761
+ "XL profile model",
2762
+ process.env.CODEX_MODEL_PROFILE_XL_MODEL ||
2763
+ process.env.CODEX_MODEL ||
2764
+ "gpt-5.3-codex",
2765
+ );
2766
+ env.CODEX_MODEL_PROFILE_M_MODEL = await prompt.ask(
2767
+ "M profile model",
2768
+ process.env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini",
2769
+ );
2705
2770
 
2706
- const providerName =
2707
- providerIdx === 1 ? "azure" : providerIdx === 3 ? "compatible" : "openai";
2708
- env.CODEX_MODEL_PROFILE_XL_PROVIDER =
2709
- process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || providerName;
2710
- env.CODEX_MODEL_PROFILE_M_PROVIDER =
2711
- process.env.CODEX_MODEL_PROFILE_M_PROVIDER || providerName;
2771
+ const providerName =
2772
+ providerIdx === 1 ? "azure" : providerIdx === 3 ? "compatible" : "openai";
2773
+ env.CODEX_MODEL_PROFILE_XL_PROVIDER =
2774
+ process.env.CODEX_MODEL_PROFILE_XL_PROVIDER || providerName;
2775
+ env.CODEX_MODEL_PROFILE_M_PROVIDER =
2776
+ process.env.CODEX_MODEL_PROFILE_M_PROVIDER || providerName;
2712
2777
 
2713
- if (!env.CODEX_SUBAGENT_MODEL) {
2714
- env.CODEX_SUBAGENT_MODEL =
2715
- env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
2778
+ if (!env.CODEX_SUBAGENT_MODEL) {
2779
+ env.CODEX_SUBAGENT_MODEL =
2780
+ env.CODEX_MODEL_PROFILE_M_MODEL || "gpt-5.1-codex-mini";
2781
+ }
2716
2782
  }
2717
2783
  }
2784
+ } else {
2785
+ // Codex not needed — skip OpenAI key prompts entirely
2786
+ info("Codex SDK not in executor preset — skipping OpenAI configuration.");
2718
2787
  }
2719
2788
 
2720
2789
  // ── Step 6: Telegram ──────────────────────────────────
@@ -4568,6 +4637,13 @@ async function runNonInteractive({
4568
4637
  env.COPILOT_AGENT_MAX_REQUESTS =
4569
4638
  process.env.COPILOT_AGENT_MAX_REQUESTS || "500";
4570
4639
 
4640
+ // Claude SDK: permission mode and API key passthrough
4641
+ env.CLAUDE_PERMISSION_MODE =
4642
+ process.env.CLAUDE_PERMISSION_MODE || "bypassPermissions";
4643
+ if (process.env.ANTHROPIC_API_KEY) {
4644
+ env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
4645
+ }
4646
+
4571
4647
  // Parse EXECUTORS env if set, else use default preset
4572
4648
  if (process.env.EXECUTORS) {
4573
4649
  const entries = process.env.EXECUTORS.split(",").map((e) => e.trim());
@@ -4589,7 +4665,33 @@ async function runNonInteractive({
4589
4665
  }
4590
4666
  }
4591
4667
  if (!configJson.executors.length) {
4592
- configJson.executors = EXECUTOR_PRESETS["codex-only"];
4668
+ // Smart default: pick preset based on available API keys
4669
+ const hasOpenAI = !!process.env.OPENAI_API_KEY;
4670
+ const hasAnthropic = !!process.env.ANTHROPIC_API_KEY;
4671
+ const hasGitHub = !!process.env.GITHUB_TOKEN || !!process.env.COPILOT_CLI_TOKEN;
4672
+ const presetEnv = (process.env.EXECUTOR_PRESET || "").toLowerCase();
4673
+ if (presetEnv && EXECUTOR_PRESETS[presetEnv]) {
4674
+ configJson.executors = EXECUTOR_PRESETS[presetEnv];
4675
+ } else if (hasGitHub) {
4676
+ configJson.executors = hasOpenAI
4677
+ ? EXECUTOR_PRESETS["copilot-codex"]
4678
+ : EXECUTOR_PRESETS["copilot-only"];
4679
+ } else if (hasAnthropic) {
4680
+ configJson.executors = EXECUTOR_PRESETS["claude-only"];
4681
+ } else {
4682
+ configJson.executors = EXECUTOR_PRESETS["codex-only"];
4683
+ }
4684
+ }
4685
+
4686
+ // Derive PRIMARY_AGENT from executor preset's primary role
4687
+ {
4688
+ const primaryExec = (configJson.executors || []).find(
4689
+ (e) => e.role === "primary",
4690
+ );
4691
+ if (primaryExec) {
4692
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
4693
+ env.PRIMARY_AGENT = sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
4694
+ }
4593
4695
  }
4594
4696
 
4595
4697
  configJson.projectName = env.PROJECT_NAME;
@@ -4803,6 +4905,42 @@ async function writeConfigFiles({ env, configJson, repoRoot, configDir }) {
4803
4905
  warn(`Could not update Copilot MCP config: ${copilotMcpResult.error}`);
4804
4906
  }
4805
4907
 
4908
+ // ── Repo-level AI configs for all workspace repos ──────
4909
+ heading("Repo-Level AI Configs");
4910
+ try {
4911
+ const { ensureRepoConfigs, printRepoConfigSummary } = await import("./repo-config.mjs");
4912
+ // Apply to the primary repo
4913
+ const repoResult = ensureRepoConfigs(repoRoot);
4914
+ printRepoConfigSummary(repoResult, (msg) => console.log(msg));
4915
+
4916
+ // Also apply to all workspace repos under $BOSUN_DIR/workspaces/
4917
+ const bosunDir = env.BOSUN_DIR || configDir || resolve(homedir(), "bosun");
4918
+ const bosunConfigPath = resolve(bosunDir, "bosun.config.json");
4919
+ if (existsSync(bosunConfigPath)) {
4920
+ try {
4921
+ const bosunCfg = JSON.parse(readFileSync(bosunConfigPath, "utf8"));
4922
+ const wsDir = resolve(bosunDir, "workspaces");
4923
+ for (const ws of bosunCfg.workspaces || []) {
4924
+ for (const repo of ws.repos || []) {
4925
+ const wsRepoPath = resolve(wsDir, ws.id || ws.name || "", repo.name);
4926
+ if (wsRepoPath !== repoRoot && existsSync(wsRepoPath)) {
4927
+ const wsResult = ensureRepoConfigs(wsRepoPath);
4928
+ const anyChange = Object.values(wsResult).some((r) => r.created || r.updated);
4929
+ if (anyChange) {
4930
+ info(`Workspace repo: ${repo.name}`);
4931
+ printRepoConfigSummary(wsResult, (msg) => console.log(msg));
4932
+ }
4933
+ }
4934
+ }
4935
+ }
4936
+ } catch (wsErr) {
4937
+ warn(`Could not update workspace repo configs: ${wsErr.message}`);
4938
+ }
4939
+ }
4940
+ } catch (rcErr) {
4941
+ warn(`Could not apply repo-level configs: ${rcErr.message}`);
4942
+ }
4943
+
4806
4944
  // ── Codex CLI config.toml ─────────────────────────────
4807
4945
  heading("Codex CLI Config");
4808
4946
 
@@ -4814,10 +4952,24 @@ async function writeConfigFiles({ env, configJson, repoRoot, configDir }) {
4814
4952
  const kanbanIsVk =
4815
4953
  (env.KANBAN_BACKEND || "internal").toLowerCase() === "vk" ||
4816
4954
  ["vk", "hybrid"].includes((env.EXECUTOR_MODE || "internal").toLowerCase());
4955
+ // Derive primary SDK from executor configuration
4956
+ const primaryExecutor = (configJson.executors || []).find(
4957
+ (e) => e.role === "primary",
4958
+ );
4959
+ const executorToPrimarySdk = {
4960
+ CODEX: "codex",
4961
+ COPILOT: "copilot",
4962
+ CLAUDE: "claude",
4963
+ };
4964
+ const primarySdk = primaryExecutor
4965
+ ? executorToPrimarySdk[String(primaryExecutor.executor).toUpperCase()] || "codex"
4966
+ : "codex";
4967
+
4817
4968
  const tomlResult = ensureCodexConfig({
4818
4969
  vkBaseUrl,
4819
4970
  skipVk: !kanbanIsVk,
4820
4971
  dryRun: false,
4972
+ primarySdk,
4821
4973
  env: {
4822
4974
  ...process.env,
4823
4975
  ...env,
@@ -278,9 +278,9 @@ function SwipeableSessionItem({
278
278
  const x = e.clientX || e.touches?.[0]?.clientX || 0;
279
279
  currentX.current = x;
280
280
  const dx = x - startX.current;
281
- // Only allow left swipe (negative), cap at -120
281
+ // Only allow left swipe (negative), cap at -140
282
282
  if (dx < 0) {
283
- setOffset(Math.max(dx, -120));
283
+ setOffset(Math.max(dx, -140));
284
284
  } else {
285
285
  setOffset(0);
286
286
  }
@@ -290,7 +290,7 @@ function SwipeableSessionItem({
290
290
  swiping.current = false;
291
291
  if (offset < -50) {
292
292
  // Snap open to reveal actions
293
- setOffset(-120);
293
+ setOffset(-140);
294
294
  if (onToggleActions) onToggleActions(s.id);
295
295
  } else {
296
296
  setOffset(0);
@@ -440,7 +440,7 @@ function SwipeableSessionItem({
440
440
  e.stopPropagation();
441
441
  if (onToggleActions) {
442
442
  onToggleActions(s.id);
443
- setOffset(-120);
443
+ setOffset(-140);
444
444
  }
445
445
  }}
446
446
  >
@@ -206,6 +206,8 @@ export function Modal({ title, open = true, onClose, children, contentClassName
206
206
  }, []);
207
207
 
208
208
  const handleTouchStart = useCallback((e) => {
209
+ // Don't intercept touches on the close button
210
+ if (e.target.closest?.(".modal-close-btn")) return;
209
211
  const el = contentRef.current;
210
212
  if (!el) return;
211
213
  const rect = el.getBoundingClientRect();
@@ -244,6 +246,8 @@ export function Modal({ title, open = true, onClose, children, contentClassName
244
246
 
245
247
  const handlePointerDown = useCallback((e) => {
246
248
  if (e.pointerType === "touch") return;
249
+ // Don't intercept clicks on the close button
250
+ if (e.target.closest?.(".modal-close-btn")) return;
247
251
  const el = contentRef.current;
248
252
  if (!el) return;
249
253
  const rect = el.getBoundingClientRect();
@@ -469,19 +469,30 @@ export function WorkspaceSwitcher() {
469
469
 
470
470
  const activeWs = workspaces.value.find((ws) => ws.id === activeWorkspaceId.value);
471
471
  const wsList = workspaces.value;
472
+ const isLoading = workspacesLoading.value;
472
473
 
473
- if (!wsList.length && !managerOpen) return null;
474
+ // Always render even with no workspaces, show a manage button so users can add one
474
475
 
475
476
  return html`
476
477
  <div class="ws-switcher">
477
478
  <button
478
- class="ws-switcher-btn"
479
- onClick=${(e) => { e.stopPropagation(); haptic("light"); setOpen(!open); }}
480
- title="Switch workspace"
479
+ class="ws-switcher-btn ${!wsList.length ? 'ws-switcher-btn-empty' : ''}"
480
+ onClick=${(e) => {
481
+ e.stopPropagation();
482
+ haptic("light");
483
+ if (!wsList.length) {
484
+ setManagerOpen(true);
485
+ } else {
486
+ setOpen(!open);
487
+ }
488
+ }}
489
+ title=${wsList.length ? "Switch workspace" : "Set up a workspace"}
481
490
  >
482
491
  <span class="ws-switcher-icon">⬡</span>
483
- <span class="ws-switcher-name">${activeWs?.name || "Select Workspace"}</span>
484
- <span class="ws-switcher-chevron ${open ? "open" : ""}">${open ? "" : ""}</span>
492
+ <span class="ws-switcher-name">
493
+ ${isLoading ? "Loading…" : (activeWs?.name || (wsList.length ? "Select Workspace" : "Set up workspace"))}
494
+ </span>
495
+ ${wsList.length ? html`<span class="ws-switcher-chevron ${open ? "open" : ""}">${open ? "▴" : "▾"}</span>` : null}
485
496
  </button>
486
497
 
487
498
  ${open && html`
package/ui/demo.html CHANGED
@@ -237,6 +237,24 @@
237
237
  version: '0.26.2',
238
238
  tasks: { completed: 13, failed: 1, retried: 2, active: 2, queued: 3 },
239
239
  prs: { created: 15, merged: 11, pending: 2, ciFailures: 1 },
240
+ // counts consumed by dashboard metrics
241
+ counts: {
242
+ running: 2,
243
+ inprogress: 2,
244
+ inreview: 1,
245
+ review: 1,
246
+ done: 2,
247
+ todo: 2,
248
+ draft: 1,
249
+ error: 0,
250
+ },
251
+ backlog_remaining: 2,
252
+ // consumed by dashboard quality section
253
+ success_metrics: {
254
+ first_shot_rate: 72,
255
+ needed_fix: 3,
256
+ failed: 1,
257
+ },
240
258
  };
241
259
 
242
260
  const SEED_EXECUTORS = [
@@ -668,7 +686,7 @@
668
686
  if (route === '/api/status')
669
687
  return { data: STATE.status };
670
688
  if (route === '/api/executor')
671
- return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors } };
689
+ return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors, activeSlots: STATE.executors.filter(e => e.status === 'active').length, sdk: 'auto' } };
672
690
  if (route === '/api/telemetry/summary')
673
691
  return { data: { status: 'ok', updatedAt: Date.now(), totals: { tasks: STATE.tasks.length, executors: STATE.executors.length } } };
674
692
  if (route === '/api/telemetry/errors')
@@ -953,7 +953,8 @@
953
953
  position: absolute;
954
954
  left: 0;
955
955
  right: 0;
956
- bottom: 16px;
956
+ /* position above the input bar (~60px) + gap */
957
+ bottom: 72px;
957
958
  display: flex;
958
959
  justify-content: center;
959
960
  pointer-events: none;
@@ -1585,8 +1586,8 @@ ul.md-list li::before {
1585
1586
  justify-content: center;
1586
1587
  border: none;
1587
1588
  cursor: pointer;
1588
- padding: 0 16px;
1589
- min-width: 60px;
1589
+ padding: 0 10px;
1590
+ min-width: 68px;
1590
1591
  font-size: 11px;
1591
1592
  font-family: inherit;
1592
1593
  gap: 2px;
@@ -1627,6 +1628,7 @@ ul.md-list li::before {
1627
1628
  font-weight: 600;
1628
1629
  text-transform: uppercase;
1629
1630
  letter-spacing: 0.3px;
1631
+ white-space: nowrap;
1630
1632
  }
1631
1633
 
1632
1634
  @keyframes session-pulse {
@@ -27,6 +27,17 @@
27
27
  border-color: var(--color-primary, #10b981);
28
28
  }
29
29
 
30
+ .ws-switcher-btn.ws-switcher-btn-empty {
31
+ border-style: dashed;
32
+ opacity: 0.8;
33
+ font-style: italic;
34
+ }
35
+
36
+ .ws-switcher-btn.ws-switcher-btn-empty:hover {
37
+ opacity: 1;
38
+ font-style: normal;
39
+ }
40
+
30
41
  .ws-switcher-icon {
31
42
  font-size: 14px;
32
43
  opacity: 0.7;
@@ -660,6 +671,16 @@
660
671
  to { transform: rotate(360deg); }
661
672
  }
662
673
 
674
+ /* ── Modal toolbar (inside shared Modal body) ──────────────── */
675
+ .ws-manager-modal-toolbar {
676
+ display: flex;
677
+ align-items: center;
678
+ gap: 8px;
679
+ margin-bottom: 12px;
680
+ padding-bottom: 12px;
681
+ border-bottom: 1px solid var(--border);
682
+ }
683
+
663
684
  /* ── Responsive ──────────────────────────────────────────── */
664
685
  @media (max-width: 540px) {
665
686
  .ws-manager-panel {
@@ -521,39 +521,33 @@ export function DashboardTab() {
521
521
  `,
522
522
  )}
523
523
  </div>
524
- <//>
525
-
526
- <${Card}
527
- title=${html`<span class="dashboard-card-title"
528
- ><span class="dashboard-title-icon">${ICONS.check}</span>Active Work</span
529
- >`}
530
- className="dashboard-card dashboard-active"
531
- >
532
- <div class="dashboard-work-layout">
533
- <div class="dashboard-work-list">
534
- ${workItems.map(
535
- (item) => html`
536
- <div class="dashboard-work-item">
537
- <div class="dashboard-work-left">
538
- <span
539
- class="dashboard-work-dot"
540
- style="background: ${item.color}"
541
- ></span>
542
- <span class="dashboard-work-label">${item.label}</span>
524
+ ${segments.length > 0 && html`
525
+ <div class="dashboard-work-layout" style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border)">
526
+ <div class="dashboard-work-list">
527
+ ${workItems.map(
528
+ (item) => html`
529
+ <div class="dashboard-work-item">
530
+ <div class="dashboard-work-left">
531
+ <span
532
+ class="dashboard-work-dot"
533
+ style="background: ${item.color}"
534
+ ></span>
535
+ <span class="dashboard-work-label">${item.label}</span>
536
+ </div>
537
+ <span class="dashboard-work-value">${item.value}</span>
543
538
  </div>
544
- <span class="dashboard-work-value">${item.value}</span>
545
- </div>
546
- `,
547
- )}
548
- </div>
549
- <div class="dashboard-work-chart">
550
- <${DonutChart} segments=${segments} size=${110} strokeWidth=${10} />
551
- <div class="dashboard-work-meta">
552
- Active progress · ${progressPct}% engaged
539
+ `,
540
+ )}
541
+ </div>
542
+ <div class="dashboard-work-chart">
543
+ <${DonutChart} segments=${segments} size=${90} strokeWidth=${9} />
544
+ <div class="dashboard-work-meta">
545
+ ${progressPct}% engaged
546
+ </div>
547
+ <${ProgressBar} percent=${progressPct} />
553
548
  </div>
554
- <${ProgressBar} percent=${progressPct} />
555
549
  </div>
556
- </div>
550
+ `}
557
551
  <//>
558
552
 
559
553
  <${Card}
@@ -683,8 +677,8 @@ export function DashboardTab() {
683
677
  ${truncate(task.title || "(untitled)", 50)}
684
678
  </div>
685
679
  <div class="meta-text">
686
- ${task.id}${task.updated_at
687
- ? ` · ${formatRelative(task.updated_at)}`
680
+ ${task.id}${(task.updated_at || task.updated)
681
+ ? ` · ${formatRelative(task.updated_at || task.updated)}`
688
682
  : ""}
689
683
  </div>
690
684
  </div>
package/ui/tabs/tasks.js CHANGED
@@ -89,6 +89,15 @@ const SORT_OPTIONS = [
89
89
  { value: "title", label: "Title" },
90
90
  ];
91
91
 
92
+ /* Maps snapshot-bar labels → tasksFilter values */
93
+ const SNAPSHOT_STATUS_MAP = {
94
+ Backlog: "todo",
95
+ Active: "inprogress",
96
+ Review: "inreview",
97
+ Done: "done",
98
+ Errors: "error",
99
+ };
100
+
92
101
  const PRIORITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3, "": 4 };
93
102
 
94
103
  const SYSTEM_TAGS = new Set([
@@ -994,8 +1003,10 @@ export function TasksTab() {
994
1003
  return dir * (av - bv);
995
1004
  }
996
1005
  if (listSortCol === "updated") {
997
- av = a.updated_at ? new Date(a.updated_at).getTime() : 0;
998
- bv = b.updated_at ? new Date(b.updated_at).getTime() : 0;
1006
+ const tsA = a.updated_at || a.updated;
1007
+ const tsB = b.updated_at || b.updated;
1008
+ av = tsA ? (typeof tsA === 'number' ? tsA : new Date(tsA).getTime()) : 0;
1009
+ bv = tsB ? (typeof tsB === 'number' ? tsB : new Date(tsB).getTime()) : 0;
999
1010
  return dir * (av - bv);
1000
1011
  }
1001
1012
  if (listSortCol === "status") { av = a.status || ""; bv = b.status || ""; }
@@ -1609,11 +1620,20 @@ export function TasksTab() {
1609
1620
 
1610
1621
  <div class="snapshot-bar">
1611
1622
  ${summaryMetrics.map((m) => html`
1612
- <span key=${m.label} class="snapshot-pill">
1623
+ <button
1624
+ key=${m.label}
1625
+ class="snapshot-pill snapshot-pill-btn ${!isKanban && filterVal === SNAPSHOT_STATUS_MAP[m.label] ? 'snapshot-pill-active' : ''}"
1626
+ onClick=${() => {
1627
+ if (isKanban) return;
1628
+ const statusVal = SNAPSHOT_STATUS_MAP[m.label];
1629
+ if (statusVal !== undefined) handleFilter(filterVal === statusVal ? 'all' : statusVal);
1630
+ }}
1631
+ title=${isKanban ? m.label : `Filter by ${m.label}`}
1632
+ >
1613
1633
  <span class="snapshot-dot" style="background:${m.color};" />
1614
1634
  <strong class="snapshot-val">${m.value}</strong>
1615
1635
  <span class="snapshot-lbl">${m.label}</span>
1616
- </span>
1636
+ </button>
1617
1637
  `)}
1618
1638
  <span class="snapshot-view-tag">${isKanban ? "⬛ Board" : "☰ List"}</span>
1619
1639
  </div>
@@ -1632,6 +1652,23 @@ export function TasksTab() {
1632
1652
  cursor:pointer;
1633
1653
  }
1634
1654
  .actions-dropdown-item:hover { background:var(--hover-bg, rgba(255,255,255,.08)); }
1655
+ .snapshot-pill-btn {
1656
+ background: none;
1657
+ border: 1px solid transparent;
1658
+ border-radius: 999px;
1659
+ cursor: pointer;
1660
+ padding: 2px 8px;
1661
+ transition: border-color 0.15s, background 0.15s;
1662
+ font: inherit;
1663
+ }
1664
+ .snapshot-pill-btn:hover {
1665
+ border-color: var(--border);
1666
+ background: var(--bg-card-hover, rgba(255,255,255,0.05));
1667
+ }
1668
+ .snapshot-pill-active {
1669
+ border-color: var(--accent) !important;
1670
+ background: rgba(59,130,246,0.12) !important;
1671
+ }
1635
1672
  @media (max-width: 640px) {
1636
1673
  .actions-label { display:none; }
1637
1674
  }
@@ -1706,8 +1743,8 @@ export function TasksTab() {
1706
1743
  : html`<span class="task-td-empty">—</span>`}
1707
1744
  </td>
1708
1745
  <td class="task-td task-td-updated">
1709
- ${task.updated_at
1710
- ? html`<span class="task-td-date">${formatRelative(task.updated_at)}</span>`
1746
+ ${(task.updated_at || task.updated)
1747
+ ? html`<span class="task-td-date">${formatRelative(task.updated_at || task.updated)}</span>`
1711
1748
  : html`<span class="task-td-empty">—</span>`}
1712
1749
  </td>
1713
1750
  </tr>
@@ -26,6 +26,39 @@ import { execSync, spawnSync } from "node:child_process";
26
26
 
27
27
  const TAG = "[workspace-manager]";
28
28
 
29
+ // Lazy-loaded reference to repo-config.mjs (resolved on first use)
30
+ let _repoConfigModule = null;
31
+
32
+ /**
33
+ * Ensure repo-level AI executor configs exist after clone/pull.
34
+ * Uses synchronous import cache — repo-config.mjs is pure sync.
35
+ * @param {string} repoPath Absolute path to the repo directory
36
+ */
37
+ function ensureRepoAIConfigs(repoPath) {
38
+ try {
39
+ if (!_repoConfigModule) {
40
+ // repo-config.mjs is ESM but fully synchronous internally.
41
+ // We pre-populate the cache via dynamic import at module init.
42
+ return; // Will be populated after first async import
43
+ }
44
+ const { ensureRepoConfigs, printRepoConfigSummary } = _repoConfigModule;
45
+ const result = ensureRepoConfigs(repoPath);
46
+ // Only log if something was created/updated
47
+ const anyChange = Object.values(result).some((r) => r.created || r.updated);
48
+ if (anyChange) {
49
+ console.log(TAG, `Repo-level AI configs for ${basename(repoPath)}:`);
50
+ printRepoConfigSummary(result, (msg) => console.log(TAG, msg));
51
+ }
52
+ } catch (err) {
53
+ console.warn(TAG, `Could not ensure repo AI configs: ${err.message}`);
54
+ }
55
+ }
56
+
57
+ // Pre-load repo-config.mjs asynchronously at module init time
58
+ import("./repo-config.mjs")
59
+ .then((mod) => { _repoConfigModule = mod; })
60
+ .catch(() => { /* repo-config not available — skip */ });
61
+
29
62
  // ── Path Helpers ─────────────────────────────────────────────────────────────
30
63
 
31
64
  /**
@@ -313,8 +346,12 @@ export function addRepoToWorkspace(configDir, workspaceId, { url, name, branch,
313
346
  }
314
347
  cloned = true;
315
348
  console.log(TAG, `Cloned ${repoName} successfully`);
349
+ // Ensure repo-level AI executor configs exist after fresh clone
350
+ ensureRepoAIConfigs(repoPath);
316
351
  } else {
317
352
  console.log(TAG, `Repository ${repoName} already exists at ${repoPath}`);
353
+ // Ensure repo-level configs are up to date even for existing repos
354
+ ensureRepoAIConfigs(repoPath);
318
355
  }
319
356
 
320
357
  const slug = extractSlug(url);