bosun 0.29.2 → 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/.env.example +1 -1
- package/agent-pool.mjs +21 -3
- package/agent-sdk.mjs +2 -2
- package/cli.mjs +12 -0
- package/codex-config.mjs +261 -39
- package/codex-shell.mjs +3 -3
- package/config-doctor.mjs +244 -0
- package/monitor.mjs +31 -0
- package/package.json +1 -1
- package/setup.mjs +227 -75
- package/task-executor.mjs +4 -0
- package/ui/components/chat-view.js +69 -53
- package/ui/components/session-list.js +4 -4
- package/ui/components/shared.js +4 -0
- package/ui/components/workspace-switcher.js +17 -6
- package/ui/demo.html +19 -1
- package/ui/styles/sessions.css +167 -21
- package/ui/styles/workspace-switcher.css +21 -0
- package/ui/tabs/chat.js +10 -0
- package/ui/tabs/dashboard.js +26 -32
- package/ui/tabs/tasks.js +43 -6
- package/ui-server.mjs +12 -0
- package/workspace-manager.mjs +37 -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
|
|
2653
|
+
// ── Step 5: AI Provider Keys ─────────────────────────────
|
|
2654
|
+
headingStep(5, "AI Provider Keys", markSetupProgress);
|
|
2634
2655
|
console.log(
|
|
2635
|
-
"
|
|
2656
|
+
" Configure API keys for the agent SDKs in your executor preset.\n",
|
|
2636
2657
|
);
|
|
2637
2658
|
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
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
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
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
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
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
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
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
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
env.
|
|
2696
|
-
"
|
|
2697
|
-
process.env.
|
|
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.
|
|
2702
|
-
"
|
|
2703
|
-
process.env.
|
|
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
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
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
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
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
|
-
|
|
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,
|
package/task-executor.mjs
CHANGED
|
@@ -3717,6 +3717,7 @@ class TaskExecutor {
|
|
|
3717
3717
|
"BOSUN_TASK_ID", "BOSUN_TASK_TITLE", "BOSUN_TASK_DESCRIPTION",
|
|
3718
3718
|
"BOSUN_BRANCH_NAME", "BOSUN_WORKTREE_PATH", "BOSUN_SDK", "BOSUN_MANAGED",
|
|
3719
3719
|
"BOSUN_REPO_ROOT", "BOSUN_REPO_SLUG", "BOSUN_WORKSPACE", "BOSUN_REPOSITORY",
|
|
3720
|
+
"BOSUN_AGENT_REPO_ROOT",
|
|
3720
3721
|
];
|
|
3721
3722
|
const _savedEnv = {};
|
|
3722
3723
|
for (const k of _savedEnvKeys) _savedEnv[k] = process.env[k];
|
|
@@ -3746,6 +3747,9 @@ class TaskExecutor {
|
|
|
3746
3747
|
process.env.BOSUN_REPO_SLUG = executionRepoSlug;
|
|
3747
3748
|
process.env.BOSUN_WORKSPACE = taskRepoContext.workspace || "";
|
|
3748
3749
|
process.env.BOSUN_REPOSITORY = taskRepoContext.repository || "";
|
|
3750
|
+
// Enforce workspace isolation: agents must resolve repo root from the
|
|
3751
|
+
// workspace-scoped executionRepoRoot, NOT the developer's personal repo.
|
|
3752
|
+
process.env.BOSUN_AGENT_REPO_ROOT = executionRepoRoot;
|
|
3749
3753
|
|
|
3750
3754
|
const attemptId = `${taskId}-${randomUUID()}`;
|
|
3751
3755
|
const taskMeta = {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* tracking, and pending/retry message support.
|
|
5
5
|
* ────────────────────────────────────────────────────────────── */
|
|
6
6
|
import { h } from "preact";
|
|
7
|
+
import { memo } from "preact/compat";
|
|
7
8
|
import { useState, useEffect, useRef, useCallback, useMemo } from "preact/hooks";
|
|
8
9
|
import htm from "htm";
|
|
9
10
|
import { apiFetch } from "../modules/api.js";
|
|
@@ -151,8 +152,23 @@ function renderMarkdown(text) {
|
|
|
151
152
|
return result;
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/* ─── Markdown render cache — avoids re-parsing identical content ─── */
|
|
156
|
+
const _mdCache = new Map();
|
|
157
|
+
const MD_CACHE_MAX = 500;
|
|
158
|
+
function cachedRenderMarkdown(text) {
|
|
159
|
+
if (_mdCache.has(text)) return _mdCache.get(text);
|
|
160
|
+
const html = renderMarkdown(text);
|
|
161
|
+
if (_mdCache.size >= MD_CACHE_MAX) {
|
|
162
|
+
// Evict oldest quarter when full
|
|
163
|
+
const keys = Array.from(_mdCache.keys()).slice(0, MD_CACHE_MAX >> 2);
|
|
164
|
+
for (const k of keys) _mdCache.delete(k);
|
|
165
|
+
}
|
|
166
|
+
_mdCache.set(text, html);
|
|
167
|
+
return html;
|
|
168
|
+
}
|
|
169
|
+
|
|
154
170
|
/* ─── Code block copy button ─── */
|
|
155
|
-
function CodeBlock({ code }) {
|
|
171
|
+
const CodeBlock = memo(function CodeBlock({ code }) {
|
|
156
172
|
const [copied, setCopied] = useState(false);
|
|
157
173
|
const handleCopy = useCallback(() => {
|
|
158
174
|
try {
|
|
@@ -170,10 +186,10 @@ function CodeBlock({ code }) {
|
|
|
170
186
|
<pre><code>${code}</code></pre>
|
|
171
187
|
</div>
|
|
172
188
|
`;
|
|
173
|
-
}
|
|
189
|
+
});
|
|
174
190
|
|
|
175
191
|
/* ─── Render message content with code block + markdown support ─── */
|
|
176
|
-
function MessageContent({ text }) {
|
|
192
|
+
const MessageContent = memo(function MessageContent({ text }) {
|
|
177
193
|
if (!text) return null;
|
|
178
194
|
const parts = text.split(/(```[\s\S]*?```)/g);
|
|
179
195
|
return html`${parts.map((part, i) => {
|
|
@@ -181,9 +197,9 @@ function MessageContent({ text }) {
|
|
|
181
197
|
const code = part.slice(3, -3).replace(/^\w+\n/, "");
|
|
182
198
|
return html`<${CodeBlock} key=${i} code=${code} />`;
|
|
183
199
|
}
|
|
184
|
-
return html`<div key=${i} class="md-rendered" dangerouslySetInnerHTML=${{ __html:
|
|
200
|
+
return html`<div key=${i} class="md-rendered" dangerouslySetInnerHTML=${{ __html: cachedRenderMarkdown(part) }} />`;
|
|
185
201
|
})}`;
|
|
186
|
-
}
|
|
202
|
+
});
|
|
187
203
|
|
|
188
204
|
/* ─── Stream helpers ─── */
|
|
189
205
|
function categorizeMessage(msg) {
|
|
@@ -206,6 +222,42 @@ function formatMessageLine(msg) {
|
|
|
206
222
|
return `[${timestamp}] ${String(kind).toUpperCase()}: ${content}`;
|
|
207
223
|
}
|
|
208
224
|
|
|
225
|
+
/* ─── Memoized ChatBubble — only re-renders if msg identity changes ─── */
|
|
226
|
+
const ChatBubble = memo(function ChatBubble({ msg }) {
|
|
227
|
+
const isTool = msg.type === "tool_call" || msg.type === "tool_result";
|
|
228
|
+
const isError = msg.type === "error" || msg.type === "stream_error";
|
|
229
|
+
const role = msg.role ||
|
|
230
|
+
(isTool || isError ? "system" : msg.type === "system" ? "system" : "assistant");
|
|
231
|
+
const bubbleClass = isError
|
|
232
|
+
? "error"
|
|
233
|
+
: isTool
|
|
234
|
+
? "tool"
|
|
235
|
+
: role === "user"
|
|
236
|
+
? "user"
|
|
237
|
+
: role === "system"
|
|
238
|
+
? "system"
|
|
239
|
+
: "assistant";
|
|
240
|
+
const label =
|
|
241
|
+
isTool
|
|
242
|
+
? msg.type === "tool_call" ? "TOOL CALL" : "TOOL RESULT"
|
|
243
|
+
: isError ? "ERROR" : null;
|
|
244
|
+
return html`
|
|
245
|
+
<div class="chat-bubble ${bubbleClass}">
|
|
246
|
+
${role === "system" && !isTool
|
|
247
|
+
? html`<div class="chat-system-text">${msg.content}</div>`
|
|
248
|
+
: html`
|
|
249
|
+
${label ? html`<div class="chat-bubble-label">${label}</div>` : null}
|
|
250
|
+
<div class="chat-bubble-content">
|
|
251
|
+
<${MessageContent} text=${msg.content} />
|
|
252
|
+
</div>
|
|
253
|
+
<div class="chat-bubble-time">
|
|
254
|
+
${msg.timestamp ? formatRelative(msg.timestamp) : ""}
|
|
255
|
+
</div>
|
|
256
|
+
`}
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
}, (prev, next) => prev.msg === next.msg);
|
|
260
|
+
|
|
209
261
|
/* ─── Chat View component ─── */
|
|
210
262
|
export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
211
263
|
const [input, setInput] = useState("");
|
|
@@ -406,7 +458,9 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
406
458
|
const newMessages = nextCount > prevCount;
|
|
407
459
|
lastMessageCount.current = nextCount;
|
|
408
460
|
if (!paused && autoScroll) {
|
|
409
|
-
|
|
461
|
+
// Use smooth scroll for small increments, instant for large jumps
|
|
462
|
+
const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
463
|
+
el.scrollTo({ top: el.scrollHeight, behavior: gap < 800 ? "smooth" : "instant" });
|
|
410
464
|
return;
|
|
411
465
|
}
|
|
412
466
|
if (newMessages && !autoScroll) {
|
|
@@ -484,7 +538,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
484
538
|
setInput(e.target.value);
|
|
485
539
|
const el = e.target;
|
|
486
540
|
el.style.height = 'auto';
|
|
487
|
-
el.style.height = Math.min(el.scrollHeight,
|
|
541
|
+
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
|
488
542
|
}, []);
|
|
489
543
|
|
|
490
544
|
const toggleFilter = useCallback((key) => {
|
|
@@ -754,50 +808,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
754
808
|
</button>
|
|
755
809
|
</div>
|
|
756
810
|
`}
|
|
757
|
-
${visibleMessages.map((msg) =>
|
|
758
|
-
|
|
759
|
-
msg.
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
const bubbleClass = isError
|
|
764
|
-
? "error"
|
|
765
|
-
: isTool
|
|
766
|
-
? "tool"
|
|
767
|
-
: role === "user"
|
|
768
|
-
? "user"
|
|
769
|
-
: role === "system"
|
|
770
|
-
? "system"
|
|
771
|
-
: "assistant";
|
|
772
|
-
const label =
|
|
773
|
-
isTool
|
|
774
|
-
? msg.type === "tool_call"
|
|
775
|
-
? "TOOL CALL"
|
|
776
|
-
: "TOOL RESULT"
|
|
777
|
-
: isError
|
|
778
|
-
? "ERROR"
|
|
779
|
-
: null;
|
|
780
|
-
return html`
|
|
781
|
-
<div
|
|
782
|
-
key=${msg.id || msg.timestamp}
|
|
783
|
-
class="chat-bubble ${bubbleClass}"
|
|
784
|
-
>
|
|
785
|
-
${role === "system" && !isTool
|
|
786
|
-
? html`<div class="chat-system-text">${msg.content}</div>`
|
|
787
|
-
: html`
|
|
788
|
-
${label
|
|
789
|
-
? html`<div class="chat-bubble-label">${label}</div>`
|
|
790
|
-
: null}
|
|
791
|
-
<div class="chat-bubble-content">
|
|
792
|
-
<${MessageContent} text=${msg.content} />
|
|
793
|
-
</div>
|
|
794
|
-
<div class="chat-bubble-time">
|
|
795
|
-
${msg.timestamp ? formatRelative(msg.timestamp) : ""}
|
|
796
|
-
</div>
|
|
797
|
-
`}
|
|
798
|
-
</div>
|
|
799
|
-
`;
|
|
800
|
-
})}
|
|
811
|
+
${visibleMessages.map((msg) => html`
|
|
812
|
+
<${ChatBubble}
|
|
813
|
+
key=${msg.id || msg.timestamp}
|
|
814
|
+
msg=${msg}
|
|
815
|
+
/>`
|
|
816
|
+
)}
|
|
801
817
|
|
|
802
818
|
${/* Pending messages (optimistic rendering) — use .peek() to avoid
|
|
803
819
|
subscribing ChatView to pendingMessages signal. Pending messages
|
|
@@ -844,12 +860,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
|
|
|
844
860
|
class="btn btn-primary btn-sm"
|
|
845
861
|
onClick=${() => {
|
|
846
862
|
const el = messagesRef.current;
|
|
847
|
-
if (el) el.
|
|
863
|
+
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
848
864
|
setAutoScroll(true);
|
|
849
865
|
setUnreadCount(0);
|
|
850
866
|
}}
|
|
851
867
|
>
|
|
852
|
-
Jump to latest${unreadCount ? ` (${unreadCount})` : ""}
|
|
868
|
+
↓ Jump to latest${unreadCount ? ` (${unreadCount})` : ""}
|
|
853
869
|
</button>
|
|
854
870
|
</div>
|
|
855
871
|
`}
|
|
@@ -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 -
|
|
281
|
+
// Only allow left swipe (negative), cap at -140
|
|
282
282
|
if (dx < 0) {
|
|
283
|
-
setOffset(Math.max(dx, -
|
|
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(-
|
|
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(-
|
|
443
|
+
setOffset(-140);
|
|
444
444
|
}
|
|
445
445
|
}}
|
|
446
446
|
>
|
package/ui/components/shared.js
CHANGED
|
@@ -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();
|