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 +1 -1
- package/agent-pool.mjs +1 -1
- package/cli.mjs +12 -0
- package/codex-config.mjs +119 -11
- package/codex-shell.mjs +3 -3
- package/config-doctor.mjs +244 -0
- package/monitor.mjs +31 -0
- package/package.json +1 -1
- package/task-executor.mjs +4 -0
- package/ui/components/chat-view.js +69 -53
- package/ui/styles/sessions.css +162 -18
- package/ui/tabs/chat.js +10 -0
- package/ui-server.mjs +12 -0
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
|
-
#
|
|
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
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
`}
|
package/ui/styles/sessions.css
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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
|
|
1257
|
-
border: 1px solid 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
|
|
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
|
|
1407
|
-
border: 1px solid 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
|
|
1417
|
-
box-shadow: 0 0 0 2px rgba(
|
|
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);
|