bosun 0.29.2 → 0.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -482,7 +482,7 @@ TELEGRAM_MINIAPP_ENABLED=false
482
482
  # CODEX_FEATURES_CHILD_AGENTS_MD=true # Sub-agent discovery via CODEX.md (KEY for sub-agents)
483
483
  # CODEX_FEATURES_MEMORY_TOOL=true # Persistent memory across sessions
484
484
  # CODEX_FEATURES_UNDO=true # Undo/rollback support
485
- # CODEX_FEATURES_COLLAB=true # Collaboration mode
485
+ # CODEX_FEATURES_MULTI_AGENT=true # Multi Agent mode
486
486
  # CODEX_FEATURES_COLLABORATION_MODES=true # Mode selection for collaboration
487
487
  # CODEX_FEATURES_STEER=true # Steering/guidance
488
488
  # CODEX_FEATURES_APPS=true # ChatGPT Apps integration
package/agent-pool.mjs CHANGED
@@ -570,7 +570,7 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
570
570
  ...(codexOpts.config || {}),
571
571
  features: {
572
572
  child_agents_md: true,
573
- collab: true,
573
+ multi_agent: true,
574
574
  memory_tool: true,
575
575
  undo: true,
576
576
  steer: true,
package/cli.mjs CHANGED
@@ -118,6 +118,7 @@ function showHelp() {
118
118
  --workspace-add <name> Create a new workspace
119
119
  --workspace-switch <id> Switch active workspace
120
120
  --workspace-add-repo Add repo to workspace (interactive)
121
+ --workspace-health Run workspace health diagnostics
121
122
 
122
123
  TASK MANAGEMENT
123
124
  task list [--status s] [--json] List tasks with optional filters
@@ -914,6 +915,17 @@ async function main() {
914
915
  process.exit(0);
915
916
  }
916
917
 
918
+ // Handle --workspace-health / --verify-workspace
919
+ if (args.includes("--workspace-health") || args.includes("--verify-workspace") || args.includes("workspace-health")) {
920
+ const { runWorkspaceHealthCheck, formatWorkspaceHealthReport } =
921
+ await import("./config-doctor.mjs");
922
+ const configDirArg = getArgValue("--config-dir");
923
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
924
+ const result = runWorkspaceHealthCheck({ configDir });
925
+ console.log(formatWorkspaceHealthReport(result));
926
+ process.exit(result.ok ? 0 : 1);
927
+ }
928
+
917
929
  // Handle --setup
918
930
  if (args.includes("--setup") || args.includes("setup")) {
919
931
  const configDirArg = getArgValue("--config-dir");
package/codex-config.mjs CHANGED
@@ -13,8 +13,8 @@
13
13
  * append or patch well-known sections rather than rewriting the whole file.
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
- import { resolve, dirname } from "node:path";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "node:fs";
17
+ import { resolve, dirname, parse } from "node:path";
18
18
  import { homedir } from "node:os";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
@@ -100,7 +100,7 @@ const buildAgentsBlock = (_maxThreads) =>
100
100
  const RECOMMENDED_FEATURES = {
101
101
  // Sub-agents & collaboration
102
102
  child_agents_md: { default: true, envVar: "CODEX_FEATURES_CHILD_AGENTS_MD", comment: "Enable sub-agent discovery via CODEX.md" },
103
- collab: { default: true, envVar: "CODEX_FEATURES_COLLAB", comment: "Enable collaboration mode" },
103
+ multi_agent: { default: true, envVar: "CODEX_FEATURES_MULTI_AGENT", comment: "Enable collaboration mode" },
104
104
  collaboration_modes: { default: true, envVar: "CODEX_FEATURES_COLLABORATION_MODES", comment: "Enable collaboration mode selection" },
105
105
 
106
106
  // Continuity & recovery
@@ -128,7 +128,7 @@ const RECOMMENDED_FEATURES = {
128
128
  const CRITICAL_ALWAYS_ON_FEATURES = new Set([
129
129
  "child_agents_md",
130
130
  "memory_tool",
131
- "collab",
131
+ "multi_agent",
132
132
  "collaboration_modes",
133
133
  "shell_tool",
134
134
  "unified_exec",
@@ -416,6 +416,10 @@ function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
416
416
  const addRoot = (value) => {
417
417
  const trimmed = String(value || "").trim();
418
418
  if (!trimmed) return;
419
+ // Reject bare relative paths like ".git" — they resolve relative to CWD
420
+ // at Codex launch time and cause "writable root does not exist" errors
421
+ // (e.g. /home/user/.codex/.git). Only accept absolute paths.
422
+ if (!trimmed.startsWith("/")) return;
419
423
  roots.add(trimmed);
420
424
  };
421
425
  if (Array.isArray(input)) {
@@ -428,14 +432,17 @@ function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
428
432
  .forEach(addRoot);
429
433
  }
430
434
 
431
- // Add paths for primary repo root
435
+ // Add paths for a repo root — only if it's a non-empty absolute path
432
436
  const addRepoRootPaths = (repo) => {
433
437
  if (!repo) return;
434
- const r = String(repo);
435
- if (!r) return;
438
+ const r = String(repo).trim();
439
+ if (!r || !r.startsWith("/")) return;
436
440
  addRoot(r);
437
- addRoot(resolve(r, ".git"));
438
- addRoot(resolve(r, ".cache", "worktrees"));
441
+ const gitDir = resolve(r, ".git");
442
+ // Only add .git if it actually exists (prevents phantom writable roots)
443
+ if (existsSync(gitDir)) addRoot(gitDir);
444
+ const cacheWorktrees = resolve(r, ".cache", "worktrees");
445
+ addRoot(cacheWorktrees);
439
446
  addRoot(resolve(r, ".cache"));
440
447
  const parent = dirname(r);
441
448
  if (parent && parent !== r) addRoot(parent);
@@ -451,11 +458,91 @@ function normalizeWritableRoots(input, { repoRoot, additionalRoots } = {}) {
451
458
  }
452
459
 
453
460
  // /tmp is needed for sandbox temp files, pip installs, etc.
454
- addRoot("/tmp");
461
+ roots.add("/tmp");
455
462
 
456
463
  return Array.from(roots);
457
464
  }
458
465
 
466
+ /**
467
+ * Validate that a directory has a valid .git ancestor (regular repo or worktree).
468
+ * In worktrees, .git is a file containing "gitdir: <path>" — follow the reference
469
+ * to find the actual git object store. Returns the resolved .git directory path
470
+ * or null if not found.
471
+ * @param {string} dir - Directory to check
472
+ * @returns {{ gitDir: string|null, mainWorktreeRoot: string|null, isWorktree: boolean }}
473
+ */
474
+ export function ensureGitAncestor(dir) {
475
+ if (!dir) return { gitDir: null, mainWorktreeRoot: null, isWorktree: false };
476
+ let current = resolve(dir);
477
+ const { root } = parse(current);
478
+ while (current !== root) {
479
+ const gitPath = resolve(current, ".git");
480
+ if (existsSync(gitPath)) {
481
+ try {
482
+ const stat = statSync(gitPath);
483
+ if (stat.isDirectory()) {
484
+ // Regular git repo
485
+ return { gitDir: gitPath, mainWorktreeRoot: current, isWorktree: false };
486
+ }
487
+ if (stat.isFile()) {
488
+ // Worktree: .git is a file with "gitdir: <relative-or-absolute-path>"
489
+ const content = readFileSync(gitPath, "utf8").trim();
490
+ const match = content.match(/^gitdir:\s*(.+)$/);
491
+ if (match) {
492
+ const gitdirRef = resolve(current, match[1].trim());
493
+ // Worktree gitdir points to <main-repo>/.git/worktrees/<name>
494
+ // Walk up 2 levels to get the main .git directory
495
+ const mainGitDir = resolve(gitdirRef, "..", "..");
496
+ const mainRepoRoot = dirname(mainGitDir);
497
+ return {
498
+ gitDir: mainGitDir,
499
+ mainWorktreeRoot: mainRepoRoot,
500
+ isWorktree: true,
501
+ };
502
+ }
503
+ }
504
+ } catch { /* permission error, race, etc. */ }
505
+ }
506
+ current = dirname(current);
507
+ }
508
+ return { gitDir: null, mainWorktreeRoot: null, isWorktree: false };
509
+ }
510
+
511
+ /**
512
+ * Build writable roots for a specific task's execution context.
513
+ * Combines the global config roots with task-specific paths (worktree, repo root).
514
+ * @param {{ worktreePath?: string, repoRoot?: string, existingRoots?: string[] }} opts
515
+ * @returns {string[]} Merged writable roots
516
+ */
517
+ export function buildTaskWritableRoots({ worktreePath, repoRoot, existingRoots = [] } = {}) {
518
+ const roots = new Set(existingRoots.filter(r => r && r.startsWith("/")));
519
+ const addIfExists = (p) => {
520
+ if (p && p.startsWith("/")) roots.add(p);
521
+ };
522
+
523
+ if (worktreePath) {
524
+ addIfExists(worktreePath);
525
+ // Worktrees have a .git file pointing to main repo — resolve the actual git dir
526
+ const ancestor = ensureGitAncestor(worktreePath);
527
+ if (ancestor.gitDir) addIfExists(ancestor.gitDir);
528
+ if (ancestor.mainWorktreeRoot) {
529
+ addIfExists(resolve(ancestor.mainWorktreeRoot, ".git"));
530
+ addIfExists(resolve(ancestor.mainWorktreeRoot, ".cache", "worktrees"));
531
+ }
532
+ }
533
+ if (repoRoot) {
534
+ addIfExists(repoRoot);
535
+ const gitDir = resolve(repoRoot, ".git");
536
+ if (existsSync(gitDir)) addIfExists(gitDir);
537
+ addIfExists(resolve(repoRoot, ".cache", "worktrees"));
538
+ addIfExists(resolve(repoRoot, ".cache"));
539
+ const parent = dirname(repoRoot);
540
+ if (parent && parent !== repoRoot) addIfExists(parent);
541
+ }
542
+ roots.add("/tmp");
543
+ return Array.from(roots);
544
+ }
545
+
459
546
  export function hasSandboxWorkspaceWrite(toml) {
460
547
  return /^\[sandbox_workspace_write\]/m.test(toml);
461
548
  }
@@ -1114,7 +1201,7 @@ export function ensureCodexConfig({
1114
1201
  // ── 1f. Ensure sandbox workspace-write defaults ───────────
1115
1202
 
1116
1203
  {
1117
- // Determine primary repo root and any workspace roots
1204
+ // Determine primary repo root prefer workspace agent root
1118
1205
  const primaryRepoRoot = env.BOSUN_AGENT_REPO_ROOT || env.REPO_ROOT || "";
1119
1206
  const additionalRoots = [];
1120
1207
  // If agent repo root differs from REPO_ROOT, include both
@@ -1122,6 +1209,27 @@ export function ensureCodexConfig({
1122
1209
  env.BOSUN_AGENT_REPO_ROOT !== env.REPO_ROOT) {
1123
1210
  additionalRoots.push(env.REPO_ROOT);
1124
1211
  }
1212
+ // Enumerate ALL workspace repo paths so every repo's .git/.cache is writable
1213
+ try {
1214
+ // Inline workspace config read (sync) — avoids async import in sync function
1215
+ const configDirGuess = env.BOSUN_DIR || resolve(homedir(), "bosun");
1216
+ const bosunConfigPath = resolve(configDirGuess, "bosun.config.json");
1217
+ if (existsSync(bosunConfigPath)) {
1218
+ const bosunCfg = JSON.parse(readFileSync(bosunConfigPath, "utf8"));
1219
+ const wsDir = resolve(configDirGuess, "workspaces");
1220
+ const allWs = Array.isArray(bosunCfg.workspaces) ? bosunCfg.workspaces : [];
1221
+ for (const ws of allWs) {
1222
+ const wsPath = resolve(wsDir, ws.id || ws.name || "");
1223
+ for (const repo of ws.repos || []) {
1224
+ const repoPath = resolve(wsPath, repo.name);
1225
+ if (repoPath && !additionalRoots.includes(repoPath) &&
1226
+ repoPath !== primaryRepoRoot) {
1227
+ additionalRoots.push(repoPath);
1228
+ }
1229
+ }
1230
+ }
1231
+ }
1232
+ } catch { /* workspace config read failed — skip */ }
1125
1233
  const ensured = ensureSandboxWorkspaceWrite(toml, {
1126
1234
  writableRoots: env.CODEX_SANDBOX_WRITABLE_ROOTS || "",
1127
1235
  repoRoot: primaryRepoRoot,
package/codex-shell.mjs CHANGED
@@ -222,7 +222,7 @@ const THREAD_OPTIONS = {
222
222
  skipGitRepoCheck: true,
223
223
  webSearchMode: "live",
224
224
  approvalPolicy: "never",
225
- // Note: sub-agent features (child_agents_md, collab, memory_tool, etc.)
225
+ // Note: sub-agent features (child_agents_md, multi_agent, memory_tool, etc.)
226
226
  // are configured via ~/.codex/config.toml [features] section, not SDK ThreadOptions.
227
227
  // codex-config.mjs ensureFeatureFlags() handles this during setup.
228
228
  };
@@ -247,7 +247,7 @@ async function getThread() {
247
247
  config: {
248
248
  features: {
249
249
  child_agents_md: true,
250
- collab: true,
250
+ multi_agent: true,
251
251
  memory_tool: true,
252
252
  undo: true,
253
253
  steer: true,
@@ -746,7 +746,7 @@ export async function initCodexShell() {
746
746
  config: {
747
747
  features: {
748
748
  child_agents_md: true,
749
- collab: true,
749
+ multi_agent: true,
750
750
  memory_tool: true,
751
751
  undo: true,
752
752
  steer: true,
package/config-doctor.mjs CHANGED
@@ -699,3 +699,247 @@ export function formatConfigDoctorReport(result) {
699
699
 
700
700
  return lines.join("\n");
701
701
  }
702
+
703
+ /**
704
+ * Run workspace-specific health checks.
705
+ * Validates workspace repos, git status, writable roots, worktree health.
706
+ * @param {{ configDir?: string, repoRoot?: string }} options
707
+ * @returns {{ ok: boolean, workspaces: Array, issues: { errors: Array, warnings: Array, infos: Array } }}
708
+ */
709
+ export function runWorkspaceHealthCheck(options = {}) {
710
+ const configDir = options.configDir || process.env.BOSUN_DIR || join(homedir(), "bosun");
711
+ const repoRoot = options.repoRoot || process.cwd();
712
+ const issues = { errors: [], warnings: [], infos: [] };
713
+ const workspaceResults = [];
714
+
715
+ // 1. Check if workspaces are configured
716
+ let workspaces = [];
717
+ try {
718
+ const configPath = join(configDir, "bosun.config.json");
719
+ if (existsSync(configPath)) {
720
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
721
+ workspaces = config.workspaces || [];
722
+ }
723
+ } catch (err) {
724
+ issues.errors.push({
725
+ code: "WS_CONFIG_READ_FAILED",
726
+ message: `Failed to read workspace config: ${err.message}`,
727
+ fix: "Check bosun.config.json is valid JSON",
728
+ });
729
+ }
730
+
731
+ if (workspaces.length === 0) {
732
+ issues.infos.push({
733
+ code: "WS_NONE_CONFIGURED",
734
+ message: "No workspaces configured — agents use developer repo directly.",
735
+ fix: "Run 'bosun --workspace-add <name>' to create a workspace for isolated agent execution.",
736
+ });
737
+ return { ok: true, workspaces: workspaceResults, issues };
738
+ }
739
+
740
+ // 2. Check each workspace
741
+ for (const ws of workspaces) {
742
+ const wsResult = {
743
+ id: ws.id || "unknown",
744
+ name: ws.name || ws.id || "unnamed",
745
+ path: ws.path || "",
746
+ repos: [],
747
+ ok: true,
748
+ };
749
+
750
+ // 2a. Workspace directory exists
751
+ const wsPath = ws.path || join(configDir, "workspaces", ws.id || ws.name);
752
+ if (!existsSync(wsPath)) {
753
+ issues.warnings.push({
754
+ code: "WS_DIR_MISSING",
755
+ message: `Workspace "${wsResult.name}" directory missing: ${wsPath}`,
756
+ fix: `Run 'bosun --setup' or mkdir -p "${wsPath}"`,
757
+ });
758
+ wsResult.ok = false;
759
+ }
760
+
761
+ // 2b. Check repos in workspace
762
+ for (const repo of ws.repos || []) {
763
+ const repoName = repo.name || repo.slug || "unknown";
764
+ const repoPath = join(wsPath, repoName);
765
+ const repoStatus = { name: repoName, path: repoPath, ok: true, issues: [] };
766
+
767
+ if (!existsSync(repoPath)) {
768
+ repoStatus.ok = false;
769
+ repoStatus.issues.push("directory missing");
770
+ issues.errors.push({
771
+ code: "WS_REPO_MISSING",
772
+ message: `Workspace repo "${repoName}" not found at ${repoPath}`,
773
+ fix: `Run 'bosun --workspace-add-repo <url>' or 'bosun --setup' to clone it`,
774
+ });
775
+ } else {
776
+ const gitPath = join(repoPath, ".git");
777
+ if (!existsSync(gitPath)) {
778
+ repoStatus.ok = false;
779
+ repoStatus.issues.push(".git missing");
780
+ issues.errors.push({
781
+ code: "WS_REPO_NO_GIT",
782
+ message: `Workspace repo "${repoName}" has no .git at ${repoPath}`,
783
+ fix: `Clone the repo: git clone <url> "${repoPath}"`,
784
+ });
785
+ } else {
786
+ // Check remote connectivity (quick)
787
+ try {
788
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
789
+ cwd: repoPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
790
+ }).trim();
791
+ repoStatus.branch = branch;
792
+ repoStatus.issues.push(`on branch: ${branch}`);
793
+ } catch {
794
+ repoStatus.issues.push("git status check failed");
795
+ }
796
+
797
+ // Check for uncommitted changes
798
+ try {
799
+ const status = execSync("git status --porcelain", {
800
+ cwd: repoPath, encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
801
+ }).trim();
802
+ if (status) {
803
+ const lines = status.split("\n").length;
804
+ repoStatus.issues.push(`${lines} uncommitted change(s)`);
805
+ issues.infos.push({
806
+ code: "WS_REPO_DIRTY",
807
+ message: `Workspace repo "${repoName}" has ${lines} uncommitted change(s)`,
808
+ fix: null,
809
+ });
810
+ }
811
+ } catch { /* ignore */ }
812
+ }
813
+ }
814
+
815
+ wsResult.repos.push(repoStatus);
816
+ if (!repoStatus.ok) wsResult.ok = false;
817
+ }
818
+
819
+ workspaceResults.push(wsResult);
820
+ }
821
+
822
+ // 3. Check Codex sandbox writable_roots coverage
823
+ const codexConfigPath = join(homedir(), ".codex", "config.toml");
824
+ if (existsSync(codexConfigPath)) {
825
+ try {
826
+ const toml = readFileSync(codexConfigPath, "utf8");
827
+ const rootsMatch = toml.match(/writable_roots\s*=\s*\[([^\]]*)\]/);
828
+ if (rootsMatch) {
829
+ const roots = rootsMatch[1].split(",").map(r => r.trim().replace(/^"|"$/g, "")).filter(Boolean);
830
+ for (const ws of workspaces) {
831
+ const wsPath = ws.path || join(configDir, "workspaces", ws.id || ws.name);
832
+ for (const repo of ws.repos || []) {
833
+ const repoPath = join(wsPath, repo.name || repo.slug || "");
834
+ const gitPath = join(repoPath, ".git");
835
+ if (existsSync(gitPath) && !roots.some(r => gitPath.startsWith(r) || r === gitPath)) {
836
+ issues.warnings.push({
837
+ code: "WS_SANDBOX_MISSING_ROOT",
838
+ message: `Workspace repo .git not in Codex writable_roots: ${gitPath}`,
839
+ fix: `Run 'bosun --setup' to update Codex sandbox config, or add "${gitPath}" to writable_roots in ~/.codex/config.toml`,
840
+ });
841
+ }
842
+ }
843
+ }
844
+
845
+ // Check for phantom/relative writable roots
846
+ for (const root of roots) {
847
+ if (!root.startsWith("/")) {
848
+ issues.warnings.push({
849
+ code: "WS_SANDBOX_RELATIVE_ROOT",
850
+ message: `Relative path in Codex writable_roots: "${root}" — may resolve incorrectly`,
851
+ fix: `Remove "${root}" from writable_roots in ~/.codex/config.toml and run 'bosun --setup'`,
852
+ });
853
+ } else if (!existsSync(root) && root !== "/tmp") {
854
+ issues.infos.push({
855
+ code: "WS_SANDBOX_PHANTOM_ROOT",
856
+ message: `Codex writable_root path does not exist: ${root}`,
857
+ fix: null,
858
+ });
859
+ }
860
+ }
861
+ }
862
+ } catch { /* ignore parse errors */ }
863
+ }
864
+
865
+ // 4. Check BOSUN_AGENT_REPO_ROOT
866
+ const agentRoot = process.env.BOSUN_AGENT_REPO_ROOT || "";
867
+ if (agentRoot) {
868
+ if (!existsSync(agentRoot)) {
869
+ issues.warnings.push({
870
+ code: "WS_AGENT_ROOT_MISSING",
871
+ message: `BOSUN_AGENT_REPO_ROOT points to non-existent path: ${agentRoot}`,
872
+ fix: "Run 'bosun --setup' to bootstrap workspace repos",
873
+ });
874
+ } else if (!existsSync(join(agentRoot, ".git"))) {
875
+ issues.warnings.push({
876
+ code: "WS_AGENT_ROOT_NO_GIT",
877
+ message: `BOSUN_AGENT_REPO_ROOT has no .git: ${agentRoot}`,
878
+ fix: "Clone the repo at the workspace path or update BOSUN_AGENT_REPO_ROOT",
879
+ });
880
+ } else {
881
+ issues.infos.push({
882
+ code: "WS_AGENT_ROOT_OK",
883
+ message: `Agent repo root: ${agentRoot}`,
884
+ fix: null,
885
+ });
886
+ }
887
+ }
888
+
889
+ const hasErrors = issues.errors.length > 0;
890
+ return { ok: !hasErrors, workspaces: workspaceResults, issues };
891
+ }
892
+
893
+ /**
894
+ * Format workspace health report for CLI output.
895
+ * @param {{ ok: boolean, workspaces: Array, issues: object }} result
896
+ * @returns {string}
897
+ */
898
+ export function formatWorkspaceHealthReport(result) {
899
+ const lines = [];
900
+ lines.push("=== bosun workspace health ===");
901
+ lines.push(`Status: ${result.ok ? "HEALTHY" : "ISSUES FOUND"}`);
902
+ lines.push("");
903
+
904
+ if (result.workspaces.length === 0) {
905
+ lines.push(" No workspaces configured.");
906
+ lines.push("");
907
+ }
908
+
909
+ for (const ws of result.workspaces) {
910
+ const icon = ws.ok ? "✓" : "✗";
911
+ lines.push(` ${icon} ${ws.name} (${ws.id})`);
912
+ for (const repo of ws.repos) {
913
+ const rIcon = repo.ok ? "✓" : "✗";
914
+ const details = repo.issues.length > 0 ? ` — ${repo.issues.join(", ")}` : "";
915
+ lines.push(` ${rIcon} ${repo.name}${details}`);
916
+ }
917
+ }
918
+ lines.push("");
919
+
920
+ if (result.issues.errors.length > 0) {
921
+ lines.push("Errors:");
922
+ for (const e of result.issues.errors) {
923
+ lines.push(` ✗ ${e.message}`);
924
+ if (e.fix) lines.push(` fix: ${e.fix}`);
925
+ }
926
+ lines.push("");
927
+ }
928
+ if (result.issues.warnings.length > 0) {
929
+ lines.push("Warnings:");
930
+ for (const w of result.issues.warnings) {
931
+ lines.push(` ⚠ ${w.message}`);
932
+ if (w.fix) lines.push(` fix: ${w.fix}`);
933
+ }
934
+ lines.push("");
935
+ }
936
+ if (result.issues.infos.length > 0) {
937
+ lines.push("Info:");
938
+ for (const i of result.issues.infos) {
939
+ lines.push(` ℹ ${i.message}`);
940
+ }
941
+ lines.push("");
942
+ }
943
+
944
+ return lines.join("\n");
945
+ }
package/monitor.mjs CHANGED
@@ -593,6 +593,37 @@ if (effectiveRepoRoot && process.cwd() !== effectiveRepoRoot) {
593
593
  }
594
594
  }
595
595
 
596
+ // ── Periodic Workspace Sync ─────────────────────────────────────────────────
597
+ // Every 30 minutes, fetch latest changes for all workspace repos so agents
598
+ // always work against recent upstream. Only runs if workspaces are configured.
599
+ const WORKSPACE_SYNC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
600
+ let workspaceSyncTimer = null;
601
+ {
602
+ const wsArray = config.repositories?.filter((r) => r.workspace) || [];
603
+ if (wsArray.length > 0) {
604
+ const workspaceIds = [...new Set(wsArray.map((r) => r.workspace).filter(Boolean))];
605
+ const doWorkspaceSync = () => {
606
+ for (const wsId of workspaceIds) {
607
+ try {
608
+ const results = pullWorkspaceRepos(config.configDir, wsId);
609
+ const failed = results.filter((r) => !r.success);
610
+ if (failed.length > 0) {
611
+ console.warn(`[monitor] workspace sync: ${failed.length} repo(s) failed in ${wsId}`);
612
+ } else {
613
+ console.log(`[monitor] workspace sync: ${wsId} up to date (${results.length} repos)`);
614
+ }
615
+ } catch (err) {
616
+ console.warn(`[monitor] workspace sync failed for ${wsId}: ${err.message}`);
617
+ }
618
+ }
619
+ };
620
+ workspaceSyncTimer = setInterval(doWorkspaceSync, WORKSPACE_SYNC_INTERVAL_MS);
621
+ // Unref so the timer doesn't keep the process alive during shutdown
622
+ if (workspaceSyncTimer?.unref) workspaceSyncTimer.unref();
623
+ console.log(`[monitor] workspace sync: scheduled every ${WORKSPACE_SYNC_INTERVAL_MS / 60000} min for ${workspaceIds.length} workspace(s)`);
624
+ }
625
+ }
626
+
596
627
  console.log(`[monitor] task planner mode: ${plannerMode}`);
597
628
  console.log(`[monitor] kanban backend: ${kanbanBackend}`);
598
629
  console.log(`[monitor] executor mode: ${executorMode}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.29.2",
3
+ "version": "0.29.3",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
package/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: renderMarkdown(part) }} />`;
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
- el.scrollTop = el.scrollHeight;
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, 100) + 'px';
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
- const isTool =
759
- msg.type === "tool_call" || msg.type === "tool_result";
760
- const isError = msg.type === "error" || msg.type === "stream_error";
761
- const role = msg.role ||
762
- (isTool || isError ? "system" : msg.type === "system" ? "system" : "assistant");
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.scrollTop = el.scrollHeight;
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
  `}
@@ -35,8 +35,9 @@
35
35
  background: rgba(5, 6, 8, 0.5);
36
36
  opacity: 0;
37
37
  pointer-events: none;
38
- transition: opacity 0.2s ease;
38
+ transition: opacity 0.28s cubic-bezier(0.32, 0.72, 0, 1);
39
39
  z-index: 140;
40
+ -webkit-tap-highlight-color: transparent;
40
41
  }
41
42
 
42
43
  .session-drawer-backdrop.open {
@@ -93,9 +94,11 @@
93
94
  bottom: calc(var(--nav-height) + var(--safe-bottom));
94
95
  width: min(88vw, 360px);
95
96
  transform: translateX(-100%);
96
- transition: transform 0.24s ease;
97
+ transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
98
+ will-change: transform;
97
99
  z-index: 160;
98
100
  box-shadow: 12px 0 40px rgba(0, 0, 0, 0.35);
101
+ background: var(--bg-secondary);
99
102
  }
100
103
  .session-split.drawer-open .session-pane {
101
104
  transform: translateX(0);
@@ -150,7 +153,7 @@
150
153
  display: flex;
151
154
  align-items: center;
152
155
  padding: 0;
153
- border-bottom: 1px solid var(--border, var(--border));
156
+ border-bottom: 1px solid var(--border);
154
157
  background: var(--bg-primary);
155
158
  flex-shrink: 0;
156
159
  }
@@ -177,6 +180,7 @@
177
180
  font-size: 12px;
178
181
  font-weight: 600;
179
182
  cursor: pointer;
183
+ transition: color var(--transition-fast), border-color var(--transition-fast);
180
184
  }
181
185
 
182
186
  .session-drawer-btn:hover {
@@ -184,6 +188,17 @@
184
188
  border-color: var(--border-strong);
185
189
  }
186
190
 
191
+ .session-drawer-btn:active {
192
+ transform: scale(0.96);
193
+ }
194
+
195
+ /* On tablet+, the session pane is inline — hide the hamburger drawer toggle */
196
+ @media (min-width: 769px) {
197
+ .session-drawer-btn {
198
+ display: none;
199
+ }
200
+ }
201
+
187
202
  .chat-shell-title {
188
203
  display: flex;
189
204
  flex-direction: column;
@@ -569,6 +584,12 @@
569
584
  .chat-stream-dot.executing,
570
585
  .chat-stream-dot.streaming {
571
586
  background: var(--accent);
587
+ animation: streamDotPulse 1.8s ease-in-out infinite;
588
+ }
589
+
590
+ @keyframes streamDotPulse {
591
+ 0%, 100% { box-shadow: 0 0 0 4px rgba(218, 119, 86, 0.1); }
592
+ 50% { box-shadow: 0 0 0 6px rgba(218, 119, 86, 0.25); }
572
593
  }
573
594
 
574
595
  .chat-stream-dot.paused {
@@ -749,7 +770,12 @@
749
770
  flex: 1;
750
771
  min-height: 0;
751
772
  overflow-y: auto;
773
+ overflow-x: hidden;
752
774
  -webkit-overflow-scrolling: touch;
775
+ overscroll-behavior-y: contain;
776
+ scroll-padding-bottom: 80px;
777
+ scrollbar-width: thin;
778
+ scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
753
779
  padding: 20px 16px 48px;
754
780
  display: flex;
755
781
  flex-direction: column;
@@ -757,6 +783,23 @@
757
783
  align-items: stretch;
758
784
  }
759
785
 
786
+ .chat-messages::-webkit-scrollbar {
787
+ width: 6px;
788
+ }
789
+
790
+ .chat-messages::-webkit-scrollbar-track {
791
+ background: transparent;
792
+ }
793
+
794
+ .chat-messages::-webkit-scrollbar-thumb {
795
+ background: rgba(255, 255, 255, 0.1);
796
+ border-radius: 3px;
797
+ }
798
+
799
+ .chat-messages::-webkit-scrollbar-thumb:hover {
800
+ background: rgba(255, 255, 255, 0.18);
801
+ }
802
+
760
803
  .chat-load-earlier {
761
804
  display: flex;
762
805
  align-items: center;
@@ -821,6 +864,11 @@
821
864
  background: var(--bg-card);
822
865
  align-self: flex-start;
823
866
  box-shadow: var(--shadow-sm);
867
+ /* CSS containment — tells the browser each bubble is layout-independent,
868
+ reducing style recalc and layout thrashing on long message lists */
869
+ content-visibility: auto;
870
+ contain-intrinsic-size: auto 80px;
871
+ contain: layout style;
824
872
  }
825
873
 
826
874
  .chat-bubble.user {
@@ -888,6 +936,19 @@
888
936
  margin-top: 4px;
889
937
  }
890
938
 
939
+ /* Subtle entrance for bubbles — only the last few get the animation
940
+ (content-visibility: auto on older bubbles skips them for free) */
941
+ .chat-bubble:last-child,
942
+ .chat-bubble:nth-last-child(2),
943
+ .chat-bubble:nth-last-child(3) {
944
+ animation: chatBubbleIn 0.18s ease-out;
945
+ }
946
+
947
+ @keyframes chatBubbleIn {
948
+ from { opacity: 0; transform: translateY(6px); }
949
+ to { opacity: 1; transform: translateY(0); }
950
+ }
951
+
891
952
  .chat-jump-latest {
892
953
  position: absolute;
893
954
  left: 0;
@@ -897,10 +958,27 @@
897
958
  justify-content: center;
898
959
  pointer-events: none;
899
960
  z-index: 6;
961
+ animation: fadeSlideUp 0.2s ease;
900
962
  }
901
963
 
902
964
  .chat-jump-latest .btn {
903
965
  pointer-events: auto;
966
+ border-radius: var(--radius-full);
967
+ padding: 6px 16px;
968
+ font-size: 12px;
969
+ font-weight: 600;
970
+ box-shadow: var(--shadow-md);
971
+ backdrop-filter: blur(8px);
972
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
973
+ }
974
+
975
+ .chat-jump-latest .btn:hover {
976
+ transform: translateY(-1px);
977
+ box-shadow: var(--shadow-lg);
978
+ }
979
+
980
+ .chat-jump-latest .btn:active {
981
+ transform: scale(0.96);
904
982
  }
905
983
 
906
984
  /* ─── Code blocks ─── */
@@ -1253,8 +1331,8 @@ ul.md-list li::before {
1253
1331
  right: 0;
1254
1332
  max-height: 240px;
1255
1333
  overflow-y: auto;
1256
- background: var(--bg-card, var(--bg-card));
1257
- border: 1px solid var(--border, var(--border));
1334
+ background: var(--bg-card);
1335
+ border: 1px solid var(--border);
1258
1336
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
1259
1337
  box-shadow: var(--shadow-lg, 0 4px 24px rgba(0,0,0,.35));
1260
1338
  z-index: 9999;
@@ -1345,7 +1423,7 @@ ul.md-list li::before {
1345
1423
  .chat-input-area {
1346
1424
  position: relative;
1347
1425
  padding: 16px 16px;
1348
- border-top: 1px solid var(--border, var(--border));
1426
+ border-top: 1px solid var(--border);
1349
1427
  background: var(--bg-surface, var(--bg-card));
1350
1428
  flex-shrink: 0;
1351
1429
  }
@@ -1391,20 +1469,12 @@ ul.md-list li::before {
1391
1469
  padding: 0 0 calc(12px + var(--safe-bottom, 0px));
1392
1470
  }
1393
1471
 
1394
- .app-shell[data-tab="chat"] .chat-view-embedded .chat-messages {
1395
- padding: 0 0 16px;
1396
- }
1397
-
1398
- .app-shell[data-tab="chat"] .chat-input-area {
1399
- padding: 0 0 calc(12px + var(--safe-bottom, 0px));
1400
- }
1401
-
1402
1472
  .chat-input-wrapper {
1403
1473
  display: flex;
1404
1474
  align-items: flex-end;
1405
1475
  gap: 8px;
1406
- background: var(--bg-card, var(--bg-card));
1407
- border: 1px solid var(--border, var(--border));
1476
+ background: var(--bg-card);
1477
+ border: 1px solid var(--border);
1408
1478
  border-radius: 24px;
1409
1479
  padding: 8px 8px 8px 16px;
1410
1480
  transition: border-color 0.15s, box-shadow 0.15s;
@@ -1413,8 +1483,8 @@ ul.md-list li::before {
1413
1483
  }
1414
1484
 
1415
1485
  .chat-input-wrapper:focus-within {
1416
- border-color: var(--accent, var(--color-primary, #3b82f6));
1417
- box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
1486
+ border-color: var(--accent);
1487
+ box-shadow: 0 0 0 2px var(--accent-soft, rgba(218, 119, 86, 0.12));
1418
1488
  }
1419
1489
 
1420
1490
  .chat-textarea {
@@ -1430,6 +1500,7 @@ ul.md-list li::before {
1430
1500
  min-height: 24px;
1431
1501
  max-height: 120px;
1432
1502
  padding: 4px 0;
1503
+ scrollbar-width: thin;
1433
1504
  }
1434
1505
 
1435
1506
  .chat-textarea::placeholder {
@@ -1752,3 +1823,76 @@ ul.md-list li::before {
1752
1823
  background: rgba(245, 158, 11, 0.1);
1753
1824
  border-radius: 4px;
1754
1825
  }
1826
+
1827
+ /* ═══ Mobile Chat Refinements ═══ */
1828
+ @media (max-width: 768px) {
1829
+ .chat-shell-inner {
1830
+ padding: 8px 10px;
1831
+ gap: 8px;
1832
+ }
1833
+
1834
+ .chat-shell-name {
1835
+ font-size: 13px;
1836
+ }
1837
+
1838
+ .chat-shell-meta {
1839
+ font-size: 10px;
1840
+ }
1841
+
1842
+ .chat-input-wrapper {
1843
+ padding: 6px 6px 6px 12px;
1844
+ border-radius: 20px;
1845
+ }
1846
+
1847
+ .chat-textarea {
1848
+ font-size: 16px; /* prevent iOS zoom on focus */
1849
+ max-height: 100px;
1850
+ }
1851
+
1852
+ .chat-input-area .chat-send-btn {
1853
+ width: 34px;
1854
+ height: 34px;
1855
+ font-size: 15px;
1856
+ }
1857
+
1858
+ .chat-input-hint {
1859
+ font-size: 10px;
1860
+ padding: 3px 2px 0;
1861
+ }
1862
+
1863
+ .chat-bubble {
1864
+ padding: 8px 12px;
1865
+ font-size: 14px;
1866
+ }
1867
+
1868
+ .chat-bubble.user {
1869
+ max-width: min(85%, 480px);
1870
+ }
1871
+
1872
+ .chat-messages {
1873
+ padding: 12px 10px 40px;
1874
+ gap: 8px;
1875
+ }
1876
+ }
1877
+
1878
+ /* ═══ Desktop Chat Refinements ═══ */
1879
+ @media (min-width: 1200px) {
1880
+ .chat-messages {
1881
+ padding: 24px 20px 48px;
1882
+ gap: 10px;
1883
+ }
1884
+
1885
+ .chat-shell-inner {
1886
+ max-width: 1080px;
1887
+ padding: 12px 16px;
1888
+ }
1889
+
1890
+ /* Wider input area on large screens */
1891
+ .chat-input-wrapper {
1892
+ max-width: min(840px, 100%);
1893
+ }
1894
+
1895
+ .chat-input-hint {
1896
+ max-width: min(840px, 100%);
1897
+ }
1898
+ }
package/ui/tabs/chat.js CHANGED
@@ -386,6 +386,16 @@ export function ChatTab() {
386
386
  } catch { /* signal read error - ignore */ }
387
387
  }, [isMobile]);
388
388
 
389
+ /* ── Auto-focus textarea when switching sessions (desktop only) ── */
390
+ useEffect(() => {
391
+ if (!sessionId || isMobile) return;
392
+ // Delay focus to let the ChatView mount first
393
+ const timer = setTimeout(() => {
394
+ if (textareaRef.current) textareaRef.current.focus();
395
+ }, 150);
396
+ return () => clearTimeout(timer);
397
+ }, [sessionId, isMobile]);
398
+
389
399
  /* ── Slash command filtering ──
390
400
  Use useMemo with no signal deps to avoid subscribing ChatTab to
391
401
  activeAgentInfo. The commands list only matters when the user is
package/ui-server.mjs CHANGED
@@ -3435,6 +3435,18 @@ async function handleApi(req, res, url) {
3435
3435
  return;
3436
3436
  }
3437
3437
 
3438
+ if (path === "/api/workspace-health") {
3439
+ try {
3440
+ const { runWorkspaceHealthCheck } = await import("./config-doctor.mjs");
3441
+ const configDir = resolveUiConfigDir();
3442
+ const result = runWorkspaceHealthCheck({ configDir });
3443
+ jsonResponse(res, 200, { ok: result.ok, data: result });
3444
+ } catch (err) {
3445
+ jsonResponse(res, 500, { ok: false, error: err.message });
3446
+ }
3447
+ return;
3448
+ }
3449
+
3438
3450
  if (path === "/api/worktrees") {
3439
3451
  try {
3440
3452
  const worktrees = listActiveWorktrees(repoRoot);