diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fc5db1cf..4bc61514f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,6 @@ jobs: run: cargo check --workspace --exclude bitfun-cli - name: Run core Rust tests - if: runner.os == 'Linux' run: cargo test --locked -p bitfun-core # ── Frontend: build ──────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 70f32ae8e..483ecddc0 100644 --- a/.gitignore +++ b/.gitignore @@ -64,7 +64,8 @@ tests/e2e/reports/ .cursor .cursor/rules/no-cargo.mdc .sisyphus/ +.worktrees/ ASSETS_LICENSES.md -external/ \ No newline at end of file +external/ diff --git a/AGENTS-CN.md b/AGENTS-CN.md index ed0c2454f..347d1c375 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -2,77 +2,97 @@ # AGENTS-CN.md -## 概览 - BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 仓库核心原则:**先保持产品逻辑平台无关,再通过平台适配层对外暴露能力**。 -- `src/crates/core`:共享产品逻辑中心 -- `src/crates/transport`:Tauri / WebSocket / CLI 适配层 -- `src/crates/api-layer`:共享处理器与 DTO -- `src/apps/desktop`:Tauri 桌面宿主应用 -- `src/apps/server`:web 后端运行时 -- `src/apps/cli`:CLI 运行时 -- `src/web-ui`:桌面端与 server/web 共享前端 -- `BitFun-Installer`:独立安装器应用 -- `tests/e2e`:桌面端 E2E 测试 +## 快速开始 + +1. 在修改架构敏感代码前,先阅读 `README.md` 和 `CONTRIBUTING.md`。 +2. 桌面端开发优先使用 `pnpm run desktop:dev` — 提供完整热更新(Vite HMR + Rust 自动重编译并重启)。仅在需要更快冷启动且只迭代前端时使用 `pnpm run desktop:preview:debug`(Rust 改动不会自动重编译)。 +3. 改完后按下方表格执行与改动范围匹配的最小验证。 -## 3 步快速上手 +## 模块索引 -1. 在修改架构敏感代码前,先阅读 `README.md`、`CONTRIBUTING.md` 和本文件。 -2. 共享前端改动或 Rust / Tauri 改动后的本地桌面快速人工验证,都优先使用 `pnpm run desktop:preview:debug`。它会在已有 debug 桌面二进制可复用时直接预览,并在二进制缺失或 Rust / Tauri 输入更新后自动执行一次快速本地重编译。`pnpm run desktop:dev` 保留给完整 Tauri dev 流程、首次初始化,或启动 / 构建链路本身的调试;仅浏览器前端验证时使用 `pnpm run dev:web`。 -3. 改完后按下方最小验证集合执行检查。 +| 模块 | 路径 | Agent 文档 | +|---|---|---| +| Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| Transport 适配层 | `src/crates/transport` | (使用 core 指南) | +| API layer | `src/crates/api-layer` | (使用 core 指南) | +| AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | +| 桌面应用 | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | +| Server | `src/apps/server` | (使用 core 指南) | +| CLI | `src/apps/cli` | (使用 core 指南) | +| 中继服务器 | `src/apps/relay-server` | (使用 core 指南) | +| 共享前端 | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| 安装器 | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | +| E2E 测试 | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | -## 核心命令 +## 最常用命令 ```bash # 安装 pnpm install -pnpm run e2e:install -# 主要开发流程 -pnpm run desktop:dev -pnpm run desktop:preview:debug -pnpm run dev:web -pnpm run cli:dev -pnpm run installer:dev +# 开发 +pnpm run desktop:dev # 完整热更新:Vite HMR + Rust 自动重编译并重启 +pnpm run desktop:preview:debug # 复用预构建二进制 + Vite HMR;无 Rust 自动重编译 +pnpm run dev:web # 纯浏览器前端 +pnpm run cli:dev # CLI 运行时 -# 前端 +# 检查 pnpm run lint:web pnpm run type-check:web -pnpm --dir src/web-ui run test:run -pnpm run build:web - -# Rust cargo check --workspace + +# 测试 +pnpm --dir src/web-ui run test:run cargo test --workspace -cargo test -p bitfun-core -- --nocapture -# Desktop / E2E +# 构建 cargo build -p bitfun-desktop -pnpm run e2e:test:l0 -pnpm --dir tests/e2e exec wdio run ./config/wdio.conf.ts --spec "./specs/.spec.ts" +pnpm run build:web + +# 快速构建(开发 / CI 提速) +pnpm run desktop:build:fast # debug 构建,不打包 +pnpm run desktop:build:release-fast # release 但降低 LTO +pnpm run desktop:build:nsis:fast # Windows 安装器,release-fast profile +pnpm run installer:build:fast # 安装器应用,快速模式 ``` -## 本地桌面快速迭代 +完整脚本列表见 [`package.json`](package.json)。 + +## 全局规则 + +### 日志 + +日志必须只用英文,且不能使用 emoji。 + +- 前端:[src/web-ui/LOGGING.md](src/web-ui/LOGGING.md) +- 后端:[src/crates/LOGGING.md](src/crates/LOGGING.md) + +### Tauri command + +- command 名称:`snake_case` +- TypeScript 可以用 `camelCase` 包装,但调用 Rust 时要传结构化 `request` + +```rust +#[tauri::command] +pub async fn your_command( + state: State<'_, AppState>, + request: YourRequest, +) -> Result +``` -- `pnpm run desktop:preview:debug` 会启动或复用 web dev server,并直接拉起 `target/debug/bitfun-desktop(.exe)`,不会经过 `tauri dev`。当已有 debug 二进制仍然可复用时它会直接预览;当二进制缺失,或 Rust / Tauri 输入比当前二进制更新时,它会先以 `CARGO_PROFILE_DEV_DEBUG=0` 和更高并行 codegen 快速重编 `bitfun-desktop`,再进入预览。 -- `pnpm run desktop:preview:debug -- --force-rebuild` 是显式强制重编后再预览的兜底入口,只有在你明确想忽略时间戳复用判断时才使用。 -- 上面的 preview 流程只是本地迭代加速手段,不能替代下方与改动范围匹配的最小验证集合。 -- 如果用户的意图是“快速看看效果”“本地跑起来看一下”这类人工预览,即使表述里同时出现了“编译”或“调试版本”,也优先使用上面的 preview 命令。 -- `pnpm run desktop:build:fast` 只保留给“明确要一个 debug 构建产物,且不需要顺手启动预览”的场景。 -- 意图示例: - - “本地编译一个调试版本快速看看效果” -> `pnpm run desktop:preview:debug` - - “只编一个 debug 产物给我,不用启动” -> `pnpm run desktop:build:fast` +```ts +await api.invoke('your_command', { request: { ... } }); +``` -## 打包请求 +### 平台边界 -- 当用户提出打包、release 或构建可分发桌面产物,但没有明确点名产物形式时,先确认目标打包类型,再执行构建。 -- 要区分“本地临时产物”和“正式 release 交付物”。除非用户明确要求,否则不要把 `desktop:preview:*`、debug 构建,或 `--no-bundle` 的快速产物当成最终给用户分发的 release。 -- 如果用户的语义明显是“给 Windows 最终用户安装”,优先使用 `pnpm run desktop:build:nsis`。 -- 如果用户明确要“独立可执行文件”而不是安装器,优先使用 `pnpm run desktop:build:exe`。 -- 如果用户已经明确点名目标格式,就不要重复确认,直接走对应打包流程。 +- 不要在 UI 组件里直接调用 Tauri API;应通过 adapter / infrastructure 层访问。 +- 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 回流到共享逻辑。 +- 在共享 core 中避免使用 `tauri::AppHandle` 等宿主 API;优先使用 `bitfun_events::EventEmitter` 等共享抽象。 ## 架构 @@ -90,7 +110,7 @@ pnpm --dir tests/e2e exec wdio run ./config/wdio.conf.ts --spec "./specs/. `src/crates/core` 是代码库中心。 -重要区域: +主要区域: - `agentic/`:agents、prompts、tools、sessions、execution、persistence - `service/`:config、filesystem、terminal、git、LSP、MCP、remote connect、project context、AI memory @@ -104,70 +124,10 @@ SessionManager → Session → DialogTurn → ModelRound 会话数据保存在 `.bitfun/sessions/{session_id}/`。 -### 前端与桌面端边界 - -- `src/web-ui` 同时服务 Tauri 桌面端和 server/web -- 不要在 UI 组件里直接调用 Tauri API;应通过 adapter / infrastructure 层访问 -- 仅桌面端集成应放在 `src/apps/desktop`,再通过 transport / API layer 回流到共享逻辑 -- 在共享 core 中避免使用 `tauri::AppHandle` 等宿主 API;优先使用 `bitfun_events::EventEmitter` 等共享抽象 - -## 仓库规则 - -### 日志 - -日志必须只用英文,且不能使用 emoji。 - -- 前端:`src/web-ui/LOGGING.md` -- 后端:`src/crates/LOGGING.md` - -示例: - -```ts -const log = createLogger('ModuleName'); -log.info('Loaded items', { count }); -``` - -```rust -use log::{debug, error, info, trace, warn}; -info!("Registered adapter for session {}", session_id); -``` - -### Tauri command - -- command 名称:`snake_case` -- Rust 侧:`snake_case` -- TypeScript 可以用 `camelCase` 包装,但调用 Rust 时要传结构化 `request` - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke('your_command', { request: { ... } }); -``` - -### 更细粒度规则 - -- 如果修改 `src/crates/ai-adapters`,需要运行 `src/crates/core/tests` 中的 stream integration tests -- 如果修改 `src/crates/core/src/agentic/execution/stream_processor.rs`,结束前需要运行 stream integration tests - -## 先看哪里 - -- Agent mode:`src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` -- Deep Review / 代码审核团队:`src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/deep_review_agent.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/reviewTeamService.ts`、`src/web-ui/src/flow_chat/services/DeepReviewService.ts`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` -- Tool:`src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` -- MCP / LSP / remote:`src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` -- 桌面端 API:`src/apps/desktop/src/api/`、`src/crates/api-layer/src/`、`src/crates/transport/src/adapters/tauri.rs` -- Web/server 通信:`src/web-ui/src/infrastructure/api/`、`src/crates/transport/src/adapters/websocket.rs`、`src/apps/server/src/routes/`、`src/apps/server/src/main.rs` - ## 验证 | 改动类型 | 最低验证要求 | -| --- | --- | +|---|---| | 前端 UI、状态、适配层或多语言文案 | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | | Deep Review / 代码审核团队行为 | 运行上面的前端验证,再运行 `cargo test -p bitfun-core deep_review -- --nocapture`;如果触及后端或 Tauri API,还需要运行下方 Rust / 桌面端验证 | | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace && cargo test --workspace` | @@ -176,22 +136,18 @@ await api.invoke('your_command', { request: { ... } }); | `src/crates/ai-adapters` | 运行上面相关 Rust 检查,**并且**运行 `src/crates/core/tests` 中的 stream integration tests | | 安装器应用 | `pnpm run installer:build` | -## Agent 文档覆盖 - -这是仓库级总指南。 - -规则优先级: - -- 进入具体目录后,优先遵循离目标文件最近的 `AGENTS.md` / `AGENTS-CN.md` -- 如果局部文档与本文件冲突,以更具体、更近的文档为准 +## 先看哪里 -进入具体模块后,优先看最近的 agent 文档: +| 功能 | 关键路径 | +|---|---| +| Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | +| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/deep_review_agent.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/reviewTeamService.ts`、`src/web-ui/src/flow_chat/services/DeepReviewService.ts`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | +| MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | +| 桌面端 API | `src/apps/desktop/src/api/`、`src/crates/api-layer/src/`、`src/crates/transport/src/adapters/tauri.rs` | +| 中继服务器 | `src/apps/relay-server/` | +| Web/server 通信 | `src/web-ui/src/infrastructure/api/`、`src/crates/transport/src/adapters/websocket.rs`、`src/apps/server/src/routes/`、`src/apps/server/src/main.rs` | -- `src/web-ui/AGENTS.md` -- `src/crates/core/AGENTS.md` -- `src/apps/desktop/AGENTS.md` -- `tests/e2e/AGENTS.md` -- `BitFun-Installer/AGENTS.md` -- `src/crates/ai-adapters/AGENTS.md` -- `src/crates/core/src/agentic/execution/AGENTS.md` +## Agent 文档优先级 +进入具体目录后,优先遵循离目标文件最近的 `AGENTS.md` / `AGENTS-CN.md`。如果局部文档与本文件冲突,以更具体、更近的文档为准。 diff --git a/AGENTS.md b/AGENTS.md index 16b6cfb8e..180652ac1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,77 +2,97 @@ # AGENTS.md -## Overview - BitFun is a Rust workspace plus a shared React frontend. Repository rule: **keep product logic platform-agnostic, then expose it through platform adapters**. -- `src/crates/core`: shared product logic center -- `src/crates/transport`: Tauri / WebSocket / CLI adapters -- `src/crates/api-layer`: shared handlers and DTOs -- `src/apps/desktop`: Tauri host app -- `src/apps/server`: web backend runtime -- `src/apps/cli`: CLI runtime -- `src/web-ui`: shared frontend for desktop and server/web -- `BitFun-Installer`: separate installer app -- `tests/e2e`: desktop E2E tests +## Quick start + +1. Read `README.md` and `CONTRIBUTING.md` before architecture-sensitive changes. +2. For desktop development, prefer `pnpm run desktop:dev` — it provides full hot-reload (Vite HMR + Rust auto-rebuild & restart). Use `pnpm run desktop:preview:debug` only when you need a faster cold-start for frontend-only iteration (Rust changes are not auto-rebuilt). +3. After changes, run the smallest matching verification from the table below. -## 3-step onboarding +## Module index -1. Read `README.md`, `CONTRIBUTING.md`, and this file before architecture-sensitive changes. -2. Prefer `pnpm run desktop:preview:debug` for fast local desktop checks. It reuses the existing debug binary after shared frontend changes and automatically does a fast local rebuild before preview when Rust / Tauri inputs are newer or the binary is missing. Keep `pnpm run desktop:dev` for the full Tauri dev flow, first-time setup, or startup/build-pipeline debugging, and use `pnpm run dev:web` for browser-only frontend work. -3. After changes, run the smallest matching verification set below. +| Module | Path | Agent doc | +|---|---|---| +| Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| Transport adapters | `src/crates/transport` | (use core guide) | +| API layer | `src/crates/api-layer` | (use core guide) | +| AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | +| Desktop app | `src/apps/desktop` | [AGENTS.md](src/apps/desktop/AGENTS.md) | +| Server | `src/apps/server` | (use core guide) | +| CLI | `src/apps/cli` | (use core guide) | +| Relay server | `src/apps/relay-server` | (use core guide) | +| Shared frontend | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| Installer | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | +| E2E tests | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | -## Core commands +## Most-used commands ```bash # Install pnpm install -pnpm run e2e:install -# Main dev flows -pnpm run desktop:dev -pnpm run desktop:preview:debug -pnpm run dev:web -pnpm run cli:dev -pnpm run installer:dev +# Dev +pnpm run desktop:dev # full hot-reload: Vite HMR + Rust auto-rebuild & restart +pnpm run desktop:preview:debug # reuse pre-built binary + Vite HMR; no Rust auto-rebuild +pnpm run dev:web # browser-only frontend +pnpm run cli:dev # CLI runtime -# Frontend +# Check pnpm run lint:web pnpm run type-check:web -pnpm --dir src/web-ui run test:run -pnpm run build:web - -# Rust cargo check --workspace + +# Test +pnpm --dir src/web-ui run test:run cargo test --workspace -cargo test -p bitfun-core -- --nocapture -# Desktop / E2E +# Build cargo build -p bitfun-desktop -pnpm run e2e:test:l0 -pnpm --dir tests/e2e exec wdio run ./config/wdio.conf.ts --spec "./specs/.spec.ts" +pnpm run build:web + +# Fast builds (for development / CI speed) +pnpm run desktop:build:fast # debug build, no bundling +pnpm run desktop:build:release-fast # release with reduced LTO +pnpm run desktop:build:nsis:fast # Windows installer, release-fast profile +pnpm run installer:build:fast # installer app, fast mode ``` -## Fast Local Desktop Loops +For the full script list, see [`package.json`](package.json). -- `pnpm run desktop:preview:debug` starts or reuses the web dev server and launches `target/debug/bitfun-desktop(.exe)` without `tauri dev`. It reuses the existing binary when possible and automatically fast-rebuilds `bitfun-desktop` with `CARGO_PROFILE_DEV_DEBUG=0` and high codegen parallelism when Rust / Tauri inputs are newer or the binary is missing. -- `pnpm run desktop:preview:debug -- --force-rebuild` is the escape hatch when you explicitly want to rebuild before preview even if the timestamp check says the binary is current. -- This preview flow is for local iteration speed only. It does not replace the minimum verification set below before you finish the task. -- If the user intent is to "quickly check the effect", "run locally for a quick look", or similar manual inspection, prefer the preview commands above even when the request also mentions "build" or "debug version". -- Reserve `pnpm run desktop:build:fast` for cases where the user explicitly wants a debug build artifact and does not need the app launched for preview. -- Intent examples: - - "build a local debug version and quickly inspect it" -> `pnpm run desktop:preview:debug` - - "build me a debug artifact only, no need to launch it" -> `pnpm run desktop:build:fast` +## Global rules -## Packaging Requests +### Logging + +Logs must be English-only, with no emojis. -- When the user asks to package, release, or build a distributable desktop artifact without naming the exact output form, confirm the intended package type before running the build. -- Distinguish local temporary artifacts from real release deliverables. Do not treat `desktop:preview:*`, debug builds, or `--no-bundle` fast outputs as the final user-facing release unless the user explicitly asks for that form. -- If the user clearly wants a Windows installer for end users, prefer `pnpm run desktop:build:nsis`. -- If the user clearly wants a standalone Windows executable instead of an installer, prefer `pnpm run desktop:build:exe`. -- If the user already names the exact target format, do not ask again; just use the requested packaging flow. +- Frontend: [`src/web-ui/LOGGING.md`](src/web-ui/LOGGING.md) +- Backend: [`src/crates/LOGGING.md`](src/crates/LOGGING.md) + +### Tauri commands + +- Command names: `snake_case` +- TypeScript may wrap with `camelCase`, but invoke Rust with a structured `request` + +```rust +#[tauri::command] +pub async fn your_command( + state: State<'_, AppState>, + request: YourRequest, +) -> Result +``` + +```ts +await api.invoke('your_command', { request: { ... } }); +``` + +### Platform boundaries + +- Do not call Tauri APIs directly from UI components; go through the adapter/infrastructure layer. +- Desktop-only integrations belong in `src/apps/desktop`, then flow back through transport/API layers. +- In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter`. ## Architecture @@ -104,70 +124,10 @@ SessionManager → Session → DialogTurn → ModelRound Session data is stored under `.bitfun/sessions/{session_id}/`. -### Frontend and desktop boundaries - -- `src/web-ui` serves both Tauri desktop and server/web -- Do not call Tauri APIs directly from UI components; go through the adapter/infrastructure layer -- Desktop-only integrations belong in `src/apps/desktop`, then flow back through transport/API layers -- In shared core, avoid host-specific APIs such as `tauri::AppHandle`; use shared abstractions such as `bitfun_events::EventEmitter` - -## Repository-specific rules - -### Logging - -Logs must be English-only, with no emojis. - -- Frontend: `src/web-ui/LOGGING.md` -- Backend: `src/crates/LOGGING.md` - -Patterns: - -```ts -const log = createLogger('ModuleName'); -log.info('Loaded items', { count }); -``` - -```rust -use log::{debug, error, info, trace, warn}; -info!("Registered adapter for session {}", session_id); -``` - -### Tauri commands - -- command names: `snake_case` -- Rust side: `snake_case` -- TypeScript may wrap with `camelCase`, but invoke Rust with a structured `request` - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke('your_command', { request: { ... } }); -``` - -### Extra narrow rules - -- If you modify `src/crates/ai-adapters`, run the stream integration tests in `src/crates/core/tests` -- If you modify `src/crates/core/src/agentic/execution/stream_processor.rs`, run the stream integration tests before finishing - -## Where to look first - -- Agent modes: `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` -- Deep Review / Code Review Team: `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/reviewTeamService.ts`, `src/web-ui/src/flow_chat/services/DeepReviewService.ts`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` -- Tools: `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` -- MCP / LSP / remote: `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` -- Desktop APIs: `src/apps/desktop/src/api/`, `src/crates/api-layer/src/`, `src/crates/transport/src/adapters/tauri.rs` -- Web/server communication: `src/web-ui/src/infrastructure/api/`, `src/crates/transport/src/adapters/websocket.rs`, `src/apps/server/src/routes/`, `src/apps/server/src/main.rs` - ## Verification | Change type | Minimum verification | -| --- | --- | +|---|---| | Frontend UI, state, adapters, or locales | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | | Deep Review / Code Review Team behavior | Web UI verification above, plus `cargo test -p bitfun-core deep_review -- --nocapture`; also run the Rust / desktop rows below when backend or Tauri APIs are touched | | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace && cargo test --workspace` | @@ -176,22 +136,18 @@ await api.invoke('your_command', { request: { ... } }); | `src/crates/ai-adapters` | Relevant Rust checks above **and** stream integration tests in `src/crates/core/tests` | | Installer app | `pnpm run installer:build` | -## Agent-doc coverage - -This is the repository-wide guide. - -Rule priority: - -- prefer the nearest matching `AGENTS.md` / `AGENTS-CN.md` for the directory you are changing -- if local guidance conflicts with this file, follow the more specific, nearer document +## Where to look first -Prefer the nearest matching agent doc when present: +| Feature | Key paths | +|---|---| +| Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | +| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/reviewTeamService.ts`, `src/web-ui/src/flow_chat/services/DeepReviewService.ts`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | +| MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | +| Desktop APIs | `src/apps/desktop/src/api/`, `src/crates/api-layer/src/`, `src/crates/transport/src/adapters/tauri.rs` | +| Relay server | `src/apps/relay-server/` | +| Web/server communication | `src/web-ui/src/infrastructure/api/`, `src/crates/transport/src/adapters/websocket.rs`, `src/apps/server/src/routes/`, `src/apps/server/src/main.rs` | -- `src/web-ui/AGENTS.md` -- `src/crates/core/AGENTS.md` -- `src/apps/desktop/AGENTS.md` -- `tests/e2e/AGENTS.md` -- `BitFun-Installer/AGENTS.md` -- `src/crates/ai-adapters/AGENTS.md` -- `src/crates/core/src/agentic/execution/AGENTS.md` +## Agent-doc priority +Prefer the nearest matching `AGENTS.md` / `AGENTS-CN.md` for the directory you are changing. If local guidance conflicts with this file, follow the more specific, nearer document. diff --git a/BitFun-Installer/package-lock.json b/BitFun-Installer/package-lock.json index c717a09c3..8a042b6c5 100644 --- a/BitFun-Installer/package-lock.json +++ b/BitFun-Installer/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-installer", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-installer", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-dialog": "^2.6.0", diff --git a/BitFun-Installer/package.json b/BitFun-Installer/package.json index 3b6317eb5..9f4c3ada1 100644 --- a/BitFun-Installer/package.json +++ b/BitFun-Installer/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-installer", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "description": "BitFun Custom Installer - Modern branded installation experience", diff --git a/BitFun-Installer/src-tauri/Cargo.toml b/BitFun-Installer/src-tauri/Cargo.toml index 5fce18147..af96aecba 100644 --- a/BitFun-Installer/src-tauri/Cargo.toml +++ b/BitFun-Installer/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-installer" -version = "0.2.4" +version = "0.2.5" authors = ["BitFun Team"] edition = "2021" description = "BitFun Custom Installer - Modern branded installation experience" diff --git a/BitFun-Installer/src/pages/LanguageSelect.tsx b/BitFun-Installer/src/pages/LanguageSelect.tsx index be99d2c8e..63b417c95 100644 --- a/BitFun-Installer/src/pages/LanguageSelect.tsx +++ b/BitFun-Installer/src/pages/LanguageSelect.tsx @@ -72,7 +72,7 @@ export function LanguageSelect({ onSelect }: LanguageSelectProps) { opacity: 0.6, letterSpacing: '0.5px', }}> - Version 0.2.4 + Version 0.2.5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e616b8b4..1b37e17ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Be respectful, kind, and constructive. We welcome contributors of all background The desktop app includes SSH remote support, which pulls in OpenSSL. On Windows the workspace **does not use vendored OpenSSL**; link against **pre-built** binaries (no Perl/NASM/OpenSSL source build). -- **Default**: `pnpm run desktop:dev` calls `ensure-openssl-windows.mjs` on Windows. `pnpm run desktop:preview:debug` does the same whenever it needs to fast-rebuild `bitfun-desktop` before preview. Every `desktop:build*` script runs via `scripts/desktop-tauri-build.mjs`, which does the same before `tauri build` (first run downloads FireDaemon OpenSSL 3.5.5 into `.bitfun/cache/`; later runs reuse the cache). Extra args: `pnpm run desktop:build -- `. +- **Default**: `pnpm run desktop:dev` calls `ensure-openssl-windows.mjs` on Windows. `pnpm run desktop:preview:debug` does the same whenever it needs to fast-rebuild `bitfun-desktop` before preview. Every `desktop:build*` script runs via `scripts/desktop-tauri-build.mjs`, which does the same before invoking Cargo. - **Manual / CI**: Download the [FireDaemon OpenSSL 3.5.5 LTS ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip), extract, set `OPENSSL_DIR` to the `x64` folder, `OPENSSL_STATIC=1`, or run `scripts/ci/setup-openssl-windows.ps1`. - **Opt out of auto-download**: `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and configure `OPENSSL_DIR` yourself. - **`desktop:dev:raw`** skips the dev script (no OpenSSL bootstrap); set `OPENSSL_DIR` yourself, run `scripts/ci/setup-openssl-windows.ps1`, or `node scripts/ensure-openssl-windows.mjs` (warms `.bitfun/cache/` and prints PowerShell `OPENSSL_*` lines to paste). @@ -35,29 +35,40 @@ pnpm install ### Common commands ```bash -# Desktop -pnpm run desktop:dev -pnpm run desktop:preview:debug +# Desktop (recommended for daily development) +pnpm run desktop:dev # full hot-reload: Vite HMR + Rust auto-rebuild & restart + +# Desktop (lightweight preview, no Rust auto-rebuild) +pnpm run desktop:preview:debug # reuse pre-built binary + Vite HMR; Rust changes require manual restart + +# Desktop (production build) pnpm run desktop:build # E2E pnpm run e2e:test ``` -For local iteration: +> **`desktop:dev` vs `desktop:preview:debug`**: `desktop:dev` runs `tauri dev`, which provides **full hot-reload** — frontend changes apply instantly via Vite HMR, and Rust/backend changes trigger an incremental rebuild followed by an automatic app restart. This is the recommended workflow for active development. `desktop:preview:debug` launches a pre-built debug binary alongside a Vite dev server; frontend edits still get HMR, but **Rust-side changes are not auto-rebuilt** — you must stop and re-run the command (or use `--force-rebuild`). Use `desktop:preview:debug` when you only need to iterate on frontend code or want a faster cold-start without waiting for `tauri dev` initialization. + +> For the full script list, see [`package.json`](package.json). For agent-specific commands, verification, and architecture rules, see [`AGENTS.md`](AGENTS.md). -- `desktop:preview:debug` avoids `tauri dev`, reuses the existing debug desktop binary when it is still current, and automatically does a fast rebuild with reduced debug info before preview when Rust / Tauri inputs are newer or the binary is missing. -- Add `-- --force-rebuild` only when you explicitly want to rebuild before preview even if the timestamp check says the binary is current. -- Keep `desktop:dev` for the full Tauri watcher/startup flow, and keep the project verification commands aligned with the actual files you changed. +### Desktop debugging tools -For packaging and release work: +When working on desktop UI/UX, the `devtools` Cargo feature provides additional debugging capabilities. It is automatically enabled in `dev` builds and `release-fast` profile builds, but never in `release` builds for end users. -- Confirm the intended output format if the request only says "package" or "release" without naming the artifact type. -- For Windows end-user installer delivery, prefer `pnpm run desktop:build:nsis`. -- For a standalone Windows executable, use `pnpm run desktop:build:exe` only when that is the explicit ask. -- Do not confuse local fast/debug artifacts with real release deliverables. +| Shortcut | Action | +|---|---| +| `Cmd/Ctrl + Shift + I` | Toggle element inspector — hover to highlight elements, click to capture metadata | +| `Cmd/Ctrl + Shift + J` | Open native webview DevTools window | -> Note: More granular scripts are available (e.g. `dev:web`, `cli:dev`, `website:dev`). See `package.json` for details. +The element inspector injects a lightweight script into the main webview. When you click an element, it captures: +- Tag, id, class, CSS selector path +- Computed styles and CSS variables +- Box model (margin, padding, border) +- Color values (text, background, border) +- Element attributes + +Captured data is logged as structured JSON under the `bitfun::devtools` target. ## Code Standards and Architecture Constraints @@ -156,8 +167,6 @@ pnpm run e2e:test If you cannot run tests, explain why in the PR and provide manual verification steps. - - ## Security and Compliance - Do not commit secrets, tokens, certificates, or any sensitive data diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index d1bcfc7d3..0421c9c02 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -21,7 +21,7 @@ 桌面端包含 SSH 远程功能,会链接 OpenSSL。Windows 上**不使用 OpenSSL 源码编译(vendored)**,需使用**预编译**库。 -- **默认**:Windows 下 `pnpm run desktop:dev` 会调用 `ensure-openssl-windows.mjs`;`pnpm run desktop:preview:debug` 在需要为预览执行快速本地 `cargo build -p bitfun-desktop` 时,也会做同样的 OpenSSL 引导。所有 `desktop:build*` 均通过 `scripts/desktop-tauri-build.mjs` 执行,在 `tauri build` 前做相同引导(首次下载到 `.bitfun/cache/`,之后走缓存)。额外参数:`pnpm run desktop:build -- `。 +- **默认**:Windows 下 `pnpm run desktop:dev` 会调用 `ensure-openssl-windows.mjs`;`pnpm run desktop:preview:debug` 在需要为预览执行快速本地 `cargo build -p bitfun-desktop` 时,也会做同样的 OpenSSL 引导。所有 `desktop:build*` 均通过 `scripts/desktop-tauri-build.mjs` 执行,在 `tauri build` 前做相同引导(首次下载到 `.bitfun/cache/`,之后走缓存)。 - **手动 / CI**:下载 [FireDaemon ZIP](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip),解压后将 `OPENSSL_DIR` 指向 `x64`,并设 `OPENSSL_STATIC=1`,或运行 `scripts/ci/setup-openssl-windows.ps1`。 - **关闭自动下载**:设置 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_DIR`。 - **`desktop:dev:raw`** 不经过 `dev.cjs`(无 OpenSSL 引导);请自行设置 `OPENSSL_DIR`、运行 `scripts/ci/setup-openssl-windows.ps1`,或执行 `node scripts/ensure-openssl-windows.mjs`(会预热 `.bitfun/cache/` 并打印可在 PowerShell 中粘贴的 `OPENSSL_*` 命令)。 @@ -35,29 +35,40 @@ pnpm install ### 常用命令 ```bash -# Desktop -pnpm run desktop:dev -pnpm run desktop:preview:debug +# Desktop(日常开发推荐) +pnpm run desktop:dev # 完整热更新:Vite HMR + Rust 自动重编译并重启 + +# Desktop(轻量预览,无 Rust 自动重编译) +pnpm run desktop:preview:debug # 复用预构建二进制 + Vite HMR;Rust 改动需手动重启 + +# Desktop(生产构建) pnpm run desktop:build # E2E pnpm run e2e:test ``` -本地迭代时建议这样选: +> **`desktop:dev` 与 `desktop:preview:debug` 的区别**:`desktop:dev` 运行 `tauri dev`,提供**完整热更新** — 前端改动通过 Vite HMR 即时生效,Rust/后端改动会触发增量重编译并自动重启应用,是日常开发的首选方式。`desktop:preview:debug` 启动预构建的 debug 二进制和 Vite dev server;前端编辑仍可 HMR,但 **Rust 侧改动不会自动重编译** — 需要手动停止并重新运行命令(或使用 `--force-rebuild`)。适合仅需迭代前端代码、或希望跳过 `tauri dev` 初始化以更快冷启动的场景。 + +> 完整脚本列表见 [`package.json`](package.json)。agent 专用命令、验证与架构规则见 [`AGENTS.md`](AGENTS.md)。 + +### 桌面端调试工具 -- `desktop:preview:debug` 不经过 `tauri dev`;当已有 debug 桌面二进制仍然可复用时会直接预览,而在 Rust / Tauri 输入更新或二进制缺失时,会先用较轻的 dev 调试信息配置快速重编 `bitfun-desktop` 再预览。 -- 只有在你明确想忽略时间戳复用判断、强制先重编时,才额外使用 `pnpm run desktop:preview:debug -- --force-rebuild`。 -- `desktop:dev` 保留给完整的 Tauri watcher / 启动链路;正式收尾时,仍要按真实改动范围执行对应验证命令。 +开发桌面端 UI/UX 时,`devtools` Cargo feature 提供额外的调试能力。它在 `dev` 构建和 `release-fast` profile 构建中自动启用,但在面向最终用户的 `release` 构建中永不启用。 -涉及打包与 release 时建议这样处理: +| 快捷键 | 功能 | +|---|---| +| `Cmd/Ctrl + Shift + I` | 切换元素检查器 — 悬停高亮元素,点击采集元数据 | +| `Cmd/Ctrl + Shift + J` | 打开原生 webview DevTools 窗口 | -- 如果请求里只说“打包”或“release”,但没有明确产物类型,先确认目标输出形式。 -- 对于 Windows 最终用户安装交付,优先使用 `pnpm run desktop:build:nsis`。 -- 对于独立 Windows 可执行文件,只有在用户明确提出时才使用 `pnpm run desktop:build:exe`。 -- 不要把本地快速/debug 产物与正式 release 交付物混淆。 +元素检查器向主 webview 注入一个轻量脚本。点击元素后会采集: +- 标签、id、class、CSS 选择器路径 +- Computed styles 和 CSS 变量 +- Box model(margin、padding、border) +- 颜色值(文本、背景、边框) +- 元素属性 -> 说明:仓库提供更细粒度的脚本(例如 `dev:web`、`cli:dev`、`website:dev`),详情见 `package.json`。 +采集的数据以结构化 JSON 形式输出到 `bitfun::devtools` 日志目标下。 ## 代码规范与架构约束 @@ -93,13 +104,12 @@ await api.invoke("your_command", { request: { /* ... */ } }); ``` ## 重点关注的贡献方向 -1. 贡献好的想法/创意(功能、交互、视觉等),提交问题 - > 欢迎产品经理、UI设计师通过PI快速提交创意,我们会帮助完善开发 -2. 优化Agent系统和效果 -3. 对提升系统稳定性和完善基础能力 -4. 扩展生态(SKill、MCP、LSP插件,或者对某些垂域开发场景的更好支持) - +1. 贡献好的想法/创意(功能、交互、视觉等),提交 Issue + > 欢迎产品经理、UI 设计师通过 PI 快速提交创意,我们会帮助完善开发 +2. 优化 Agent 系统和效果 +3. 对提升系统稳定性和完善基础能力 +4. 扩展生态(Skills、MCP、LSP 插件,或者对某些垂域开发场景的更好支持) ## 贡献流程与 PR 约定 @@ -136,6 +146,7 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 如为 AI 辅助产出,请在 PR 中注明并说明测试程度(未测/轻测/已测),便于评审风险。 ### 分支管理 + **`main` 分支为默认协作分支,并接受特性 PR。** 本仓库欢迎产品经理、开发者使用 AI 生成代码进行快速验证或提交想法,因此 **所有 PR 请直接提交到 `main` 分支**。 ### 变更范围 diff --git a/Cargo.toml b/Cargo.toml index 996080345..8196c1248 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "src/crates/events", "src/crates/ai-adapters", + "src/crates/acp", "src/crates/core", "src/crates/transport", "src/crates/api-layer", @@ -20,7 +21,7 @@ resolver = "2" # Shared package metadata — single source of truth for version [workspace.package] -version = "0.2.4" # x-release-please-version +version = "0.2.5" # x-release-please-version authors = ["BitFun Team"] edition = "2021" diff --git a/README.md b/README.md index 586a95d67..c8103d1e2 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ BitFun aims to pack **the coding power of Code Agents, the office productivity o --- +## What's New + +BitFun combines **flashgrep** with **ripgrep** into an enhanced code-search pipeline. On very large repositories such as Chromium, search time drops by up to about **94.6%**, with an average speedup of about **36.1×**, significantly reducing the time you spend exploring a project. + +![flashgrep feature](./png/feat_flashgrep.png) + +--- + ## Cutting Edge · Ready Out of the Box New paradigms appear almost weekly in the Agent space. BitFun’s pace is: **when we see something great, we ship it on the desktop and make it work seamlessly with what you already have.** diff --git a/README.zh-CN.md b/README.zh-CN.md index d802348ad..58b602f4e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -21,7 +21,6 @@ **BitFun 是一个桌面级 Agent 运行时(Local Agent Runtime),同时也是一套开箱即用的桌面 Agent 应用。** - 它是**基座**——Rust 内核 + Tauri 外壳,内置会话、工具、记忆、MCP、LSP、远程控制协议,为长期运行而生; - - 它是**产品**——下载安装就拥有 Code / Cowork / Computer Use / 个人助理四大官方 Agent,几乎覆盖了当前业界所有主流 Agent 能力形态。 > **一次安装,既能当 Agent 用,也能当 Runtime 做。** @@ -31,7 +30,6 @@ BitFun 的野心是把 **Code Agent 的编码力、Cowork 的办公力、OpenCla ![readme_hero_CN](./png/readme_hero_CN.png) - --- ## 为什么选 BitFun @@ -46,6 +44,14 @@ BitFun 的野心是把 **Code Agent 的编码力、Cowork 的办公力、OpenCla --- +## 最新特性 + +BitFun 通过引入 flashgrep 与 ripgrep 联动形成增强版本的检索链路,在 Chromium 这类超大代码仓库中将代码搜索耗时最高降低约 94.6%、平均加速约 36.1×,显著缩短项目探索时间。 + +![flashgrep 检索增强](./png/feat_flashgrep.png) + +--- + ## 紧追前沿 · 开箱即用 Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看到好东西,就把它装进桌面,并让它和已有能力无缝协同**。 @@ -55,22 +61,24 @@ Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看 以下是 BitFun 已装箱的**官方 Agent 和能力清单**和对业界最前沿 Agent 范式的复现进度。零配置,下载即用: -| 能力 | 说明 | -| --- | --- | -| **Code Agent** | 四种模式:Agentic(自主读改跑验证)/ Plan(先规划后执行)/ Debug(插桩取证 → 根因定位)/ Review(基于仓库规范审查) | -| **深度审查** | 面向高风险代码变更的并行代码审核团队,内置专项审核员、质量把关和用户确认后的修复流程 | -| **Cowork Agent** | PDF / DOCX / XLSX / PPTX 原生处理能力,可从 Skill 市场按需扩展 | -| **文档协作** | 在文档里边写边问,AI 直接在段落上改写、续写、总结、排版 | -| **Computer Use** | 看屏幕、动鼠标键盘,操作浏览器与任意桌面应用,把"手动点点点"交给 Agent | -| **个人助理** | 长期记忆、个性设定,按需调度 Code / Cowork / Computer Use / 自定义 Agent | -| **远程控制 / IM 接入** | 手机扫码、Telegram、飞书 Bot、微信 Bot 远程下达指令,实时查看进度 | -| **MCP / MCP App** | 任意外部工具一键接入,MCP 也能打包成可安装的 App | -| **生成式 UI** | 对话过程中按需生成可交互 UI 组件,嵌在消息流里直接用 | -| **Mini App** | 一句话生成独立可运行的应用,即生即跑,一键打包成桌面端 | -| **Markdown 定义 Agent** | 写一个 `.md` 文件,立即在 Runtime 里跑起来,满足大多数领域化需求 | -| **长期记忆 + 项目上下文** | 跨会话积累,任意 Agent 可读 | -| **自我迭代** | Code Agent 直接改 BitFun 自己的仓库 | -| **⋯⋯** | 下一个热点持续跟进中,欢迎 Issue 提需求 | + +| 能力 | 说明 | +| --------------------- | ------------------------------------------------------------------------- | +| **Code Agent** | 四种模式:Agentic(自主读改跑验证)/ Plan(先规划后执行)/ Debug(插桩取证 → 根因定位)/ Review(基于仓库规范审核) | +| **深度审核** | 面向高风险代码变更的并行代码审核团队,内置专项审核员、质量把关和用户确认后的修复流程 | +| **Cowork Agent** | PDF / DOCX / XLSX / PPTX 原生处理能力,可从 Skill 市场按需扩展 | +| **文档协作** | 在文档里边写边问,AI 直接在段落上改写、续写、总结、排版 | +| **Computer Use** | 看屏幕、动鼠标键盘,操作浏览器与任意桌面应用,把"手动点点点"交给 Agent | +| **个人助理** | 长期记忆、个性设定,按需调度 Code / Cowork / Computer Use / 自定义 Agent | +| **远程控制 / IM 接入** | 手机扫码、Telegram、飞书 Bot、微信 Bot 远程下达指令,实时查看进度 | +| **MCP / MCP App** | 任意外部工具一键接入,MCP 也能打包成可安装的 App | +| **生成式 UI** | 对话过程中按需生成可交互 UI 组件,嵌在消息流里直接用 | +| **Mini App** | 一句话生成独立可运行的应用,即生即跑,一键打包成桌面端 | +| **Markdown 定义 Agent** | 写一个 `.md` 文件,立即在 Runtime 里跑起来,满足大多数领域化需求 | +| **长期记忆 + 项目上下文** | 跨会话积累,任意 Agent 可读 | +| **自我迭代** | Code Agent 直接改 BitFun 自己的仓库 | +| **⋯⋯** | 下一个热点持续跟进中,欢迎 Issue 提需求 | + --- @@ -78,22 +86,26 @@ Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看 不同深度的定制需求,对应不同成本的扩展路径。按"从轻到重"依次选择即可: -| 层级 | 方式 | 适合做什么 | 改动成本 | -| --- | --- | --- | --- | -| **L1** | **Markdown 自定义 Agent** | 换提示词 + 挑选工具组合,即可定义一个**新的 Agent 能力**,满足大多数领域化需求 | 写一个 `.md` 文件 | -| **L2** | **Mini App** | 需要用界面交互的能力(面板、表单、可视化、业务流程) | 一句话生成,即生即跑 | -| **L3** | **源码级添加工具** | 新工具、新模型适配、新协议接入——给自定义 Agent 补齐它需要但 BitFun 还没有的 `tool` | 用 BitFun 的 Code Agent 改 BitFun 自己的源码 | -| **L4** | **自由改源码** | 换品牌、重做 UI、改会话模型、做完全不一样的产品 | 整仓 fork,天然亲和 Vibe Coding 开发模式 | + +| 层级 | 方式 | 适合做什么 | 改动成本 | +| ------ | ---------------------- | ----------------------------------------------------- | ------------------------------------ | +| **L1** | **Markdown 自定义 Agent** | 换提示词 + 挑选工具组合,即可定义一个**新的 Agent 能力**,满足大多数领域化需求 | 写一个 `.md` 文件 | +| **L2** | **Mini App** | 需要用界面交互的能力(面板、表单、可视化、业务流程) | 一句话生成,即生即跑 | +| **L3** | **源码级添加工具** | 新工具、新模型适配、新协议接入——给自定义 Agent 补齐它需要但 BitFun 还没有的 `tool` | 用 BitFun 的 Code Agent 改 BitFun 自己的源码 | +| **L4** | **自由改源码** | 换品牌、重做 UI、改会话模型、做完全不一样的产品 | 整仓 fork,天然亲和 Vibe Coding 开发模式 | + ### 一个例子:Code Agent 和 Cowork Agent 的差别其实很小 在 BitFun 里,一个 Agent = **一段提示词(系统角色 + 行为约束)+ 一组它能调用的工具**。官方的 Code Agent 和 Cowork Agent 区别就仅在于此: -| | Code Agent | Cowork Agent | -| --- | --- | --- | -| **提示词** | 面向仓库工作的角色、规范、四种工作模式 | 面向知识工作的角色、文档处理流程 | -| **工具集** | 文件 / 终端 / Git / LSP / 构建与测试 | PDF / DOCX / XLSX / PPTX / Skill 市场 | -| **共用底盘** | 同一套会话、记忆、MCP、远控、UI、模型适配 | 同一套会话、记忆、MCP、远控、UI、模型适配 | + +| | Code Agent | Cowork Agent | +| -------- | --------------------------- | ----------------------------------- | +| **提示词** | 面向仓库工作的角色、规范、四种工作模式 | 面向知识工作的角色、文档处理流程 | +| **工具集** | 文件 / 终端 / Git / LSP / 构建与测试 | PDF / DOCX / XLSX / PPTX / Skill 市场 | +| **共用底盘** | 同一套会话、记忆、MCP、远控、UI、模型适配 | 同一套会话、记忆、MCP、远控、UI、模型适配 | + **所以,如果你想做一个"法律审阅 Agent"、"科研文献 Agent"或者"运维应急 Agent"——L1 就够了**: @@ -104,6 +116,7 @@ Agent 领域几乎每周都有新范式出现。BitFun 的节奏是——**看 5. 如果你要做一个完全不一样的产品 —— 走 **L4**,fork 整个仓库,让 Code Agent 陪你改 **关键点**:L3 和 L4 都不用你离开 BitFun——**打开 BitFun,对 Code Agent 说你要改什么,它就改给你看**。**你定制它的方式,就是用它本身** + > 从一个 Markdown 文件到完整 fork,中间没有断点。这正是"会自我迭代的基座"的含义。 --- @@ -182,4 +195,3 @@ src/web-ui # 桌面 / Web 共用前端 3. 本项目依赖和参考了众多开源软件,感谢所有开源作者。**如侵犯您的相关权益请联系我们整改。** --- - diff --git a/package-lock.json b/package-lock.json index 63d79a6da..166152b65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "BitFun", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "BitFun", - "version": "0.2.4", + "version": "0.2.5", "hasInstallScript": true, "dependencies": { "pnpm": "^10.32.1", diff --git a/package.json b/package.json index 15f520d2d..8f7c74cdb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "BitFun", "private": true, - "version": "0.2.4", + "version": "0.2.5", "type": "module", "engines": { "node": ">=18.0.0" @@ -32,9 +32,10 @@ "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", - "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast", + "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", "desktop:build:exe": "node scripts/desktop-tauri-build.mjs --no-bundle", "desktop:build:nsis": "node scripts/desktop-tauri-build.mjs --bundles nsis", + "desktop:build:nsis:fast": "node scripts/desktop-tauri-build.mjs --bundles nsis -- --profile release-fast --features devtools", "desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles dmg", "desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles dmg", "desktop:build:linux": "node scripts/desktop-tauri-build.mjs", diff --git a/png/feat_flashgrep.png b/png/feat_flashgrep.png new file mode 100644 index 000000000..400c6dfc7 Binary files /dev/null and b/png/feat_flashgrep.png differ diff --git a/resources/flashgrep/README.md b/resources/flashgrep/README.md new file mode 100644 index 000000000..0f0e876c7 --- /dev/null +++ b/resources/flashgrep/README.md @@ -0,0 +1,12 @@ +Place the prebuilt `flashgrep` daemon binary in this directory. + +Expected filenames: + +- macOS x86_64: `flashgrep-x86_64-apple-darwin` +- macOS arm64: `flashgrep-aarch64-apple-darwin` +- Linux x86_64: `flashgrep-x86_64-unknown-linux-gnu` +- Linux arm64: `flashgrep-aarch64-unknown-linux-gnu` +- Windows x86_64: `flashgrep-x86_64-pc-windows-msvc.exe` +- Windows arm64: `flashgrep-aarch64-pc-windows-msvc.exe` + +BitFun dev/build scripts load the daemon from this repository-relative path. diff --git a/resources/flashgrep/flashgrep-aarch64-apple-darwin b/resources/flashgrep/flashgrep-aarch64-apple-darwin new file mode 100755 index 000000000..fc6b2e52d Binary files /dev/null and b/resources/flashgrep/flashgrep-aarch64-apple-darwin differ diff --git a/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe b/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe new file mode 100644 index 000000000..3c4771c66 Binary files /dev/null and b/resources/flashgrep/flashgrep-aarch64-pc-windows-msvc.exe differ diff --git a/resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu b/resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu new file mode 100755 index 000000000..0a6c14ee5 Binary files /dev/null and b/resources/flashgrep/flashgrep-aarch64-unknown-linux-gnu differ diff --git a/resources/flashgrep/flashgrep-x86_64-apple-darwin b/resources/flashgrep/flashgrep-x86_64-apple-darwin new file mode 100755 index 000000000..28baf781c Binary files /dev/null and b/resources/flashgrep/flashgrep-x86_64-apple-darwin differ diff --git a/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe b/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe new file mode 100644 index 000000000..0143f13fa Binary files /dev/null and b/resources/flashgrep/flashgrep-x86_64-pc-windows-msvc.exe differ diff --git a/resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu b/resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu new file mode 100755 index 000000000..ba206a6fb Binary files /dev/null and b/resources/flashgrep/flashgrep-x86_64-unknown-linux-gnu differ diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index 0ef8efaf5..fa2596c43 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { readdirSync } from 'fs'; import { ensureOpenSslWindows } from './ensure-openssl-windows.mjs'; +import { ensureFlashgrepBinary } from './prepare-flashgrep-resource.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, '..'); @@ -26,6 +27,7 @@ async function main() { const forward = tauriBuildArgsFromArgv(); await ensureOpenSslWindows(); + process.env.FLASHGREP_DAEMON_BIN = ensureFlashgrepBinary(); const desktopDir = join(ROOT, 'src', 'apps', 'desktop'); // Tauri CLI reads CI and rejects numeric "1" (common in CI providers). diff --git a/scripts/dev.cjs b/scripts/dev.cjs index ce8f08623..6237dfd3b 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -165,13 +165,16 @@ function runCommand(command, cwd = ROOT_DIR) { /** * Spawn a command with explicit args array (no shell interpolation, safe for paths with spaces) */ -function spawnCommand(cmd, args, cwd = ROOT_DIR, env = process.env, shell = false) { +function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = false) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { cwd, stdio: 'inherit', shell, - env, + env: { + ...process.env, + ...envOverrides, + }, }); child.on('close', (code) => { @@ -541,6 +544,57 @@ async function startDesktopPreview() { await new Promise(() => {}); } +function flashgrepBinaryNames() { + if (process.platform === 'win32' && process.arch === 'x64') { + return ['flashgrep-x86_64-pc-windows-msvc.exe']; + } + if (process.platform === 'win32' && process.arch === 'arm64') { + return ['flashgrep-aarch64-pc-windows-msvc.exe']; + } + if (process.platform === 'darwin' && process.arch === 'x64') { + return ['flashgrep-x86_64-apple-darwin']; + } + if (process.platform === 'darwin' && process.arch === 'arm64') { + return ['flashgrep-aarch64-apple-darwin']; + } + if (process.platform === 'linux' && process.arch === 'x64') { + return ['flashgrep-x86_64-unknown-linux-gnu']; + } + if (process.platform === 'linux' && process.arch === 'arm64') { + return ['flashgrep-aarch64-unknown-linux-gnu']; + } + return [process.platform === 'win32' ? 'flashgrep.exe' : 'flashgrep']; +} + +function flashgrepBinaryName() { + return flashgrepBinaryNames()[0]; +} + +function ensureFlashgrepBinary() { + for (const binaryName of flashgrepBinaryNames()) { + const binaryPath = path.join(ROOT_DIR, 'resources', 'flashgrep', binaryName); + if (!fs.existsSync(binaryPath)) { + continue; + } + return { ok: true, binaryPath }; + } + + return { + ok: false, + error: new Error( + `flashgrep binary not found for ${process.platform}/${process.arch}. Expected one of: ${flashgrepBinaryNames() + .map((name) => `resources/flashgrep/${name}`) + .join(', ')}` + ), + }; +} + +async function ensureFlashgrepBundleResource() { + const helperUrl = pathToFileURL(path.join(__dirname, 'prepare-flashgrep-resource.mjs')).href; + const helper = await import(helperUrl); + return helper.ensureFlashgrepBinary(); +} + /** * Main entry */ @@ -566,7 +620,7 @@ async function main() { printHeader(`BitFun ${modeLabel} Development`); printBlank(); - const totalSteps = desktopMode ? 4 : 3; + const totalSteps = desktopMode ? 5 : 3; let currentStep = 1; // Step 1: Copy resources @@ -617,6 +671,28 @@ async function main() { if (!mobileWebResult.ok) { process.exit(1); } + + printStep(currentStep++, totalSteps, 'Build workspace search daemon'); + const flashgrepResult = ensureFlashgrepBinary(); + if (!flashgrepResult.ok) { + printError('Workspace search daemon is missing'); + if (flashgrepResult.error && flashgrepResult.error.message) { + printError(flashgrepResult.error.message); + } + if (flashgrepResult.error && flashgrepResult.error.status !== undefined) { + printError(`Exit code: ${flashgrepResult.error.status}`); + } + process.exit(1); + } + process.env.FLASHGREP_DAEMON_BIN = flashgrepResult.binaryPath; + + try { + await ensureFlashgrepBundleResource(); + } catch (error) { + printError('Validate workspace search daemon failed'); + printError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } } // Final step: Start dev server @@ -632,7 +708,7 @@ async function main() { if (mode === 'desktop') { await ensureDesktopOpenSslIfNeeded(); const desktopDir = path.join(ROOT_DIR, 'src/apps/desktop'); - const tauriConfig = path.join(desktopDir, 'tauri.conf.json'); + const tauriConfig = path.join(desktopDir, 'tauri.dev.conf.json'); if (process.platform === 'win32') { // Running the generated .cmd shim directly via spawn is flaky on Windows. // Use cmd.exe with an explicit args array so the desktop app directory diff --git a/scripts/prepare-flashgrep-resource.mjs b/scripts/prepare-flashgrep-resource.mjs new file mode 100644 index 000000000..0d8876585 --- /dev/null +++ b/scripts/prepare-flashgrep-resource.mjs @@ -0,0 +1,57 @@ +import { chmodSync, existsSync, statSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const RESOURCE_DIR = join(ROOT, 'resources', 'flashgrep'); + +export function flashgrepBinaryNames() { + if (process.platform === 'win32' && process.arch === 'x64') { + return ['flashgrep-x86_64-pc-windows-msvc.exe']; + } + if (process.platform === 'win32' && process.arch === 'arm64') { + return ['flashgrep-aarch64-pc-windows-msvc.exe']; + } + if (process.platform === 'darwin' && process.arch === 'x64') { + return ['flashgrep-x86_64-apple-darwin']; + } + if (process.platform === 'darwin' && process.arch === 'arm64') { + return ['flashgrep-aarch64-apple-darwin']; + } + if (process.platform === 'linux' && process.arch === 'x64') { + return ['flashgrep-x86_64-unknown-linux-gnu']; + } + if (process.platform === 'linux' && process.arch === 'arm64') { + return ['flashgrep-aarch64-unknown-linux-gnu']; + } + return [process.platform === 'win32' ? 'flashgrep.exe' : 'flashgrep']; +} + +export function flashgrepBinaryName() { + return flashgrepBinaryNames()[0]; +} + +export function flashgrepBinaryPath() { + return join(RESOURCE_DIR, flashgrepBinaryName()); +} + +export function ensureFlashgrepBinary() { + for (const binaryName of flashgrepBinaryNames()) { + const binaryPath = join(RESOURCE_DIR, binaryName); + if (!existsSync(binaryPath)) { + continue; + } + + if (process.platform !== 'win32') { + chmodSync(binaryPath, statSync(binaryPath).mode | 0o111); + } + return binaryPath; + } + + throw new Error( + `flashgrep binary not found for ${process.platform}/${process.arch}. Expected one of: ${flashgrepBinaryNames() + .map((name) => `resources/flashgrep/${name}`) + .join(', ')}` + ); +} diff --git a/scripts/test-acp.js b/scripts/test-acp.js index f6bfd5c09..afe8d0236 100644 --- a/scripts/test-acp.js +++ b/scripts/test-acp.js @@ -1,98 +1,101 @@ -// Simple test client for BitFun ACP server +// Simple test client for BitFun ACP server. // Run with: node scripts/test-acp.js -const { spawn } = require('child_process'); -const path = require('path'); +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -// Check if bitfun-cli exists +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.join(__dirname, '..', 'target', 'debug', 'bitfun-cli'); const cliReleasePath = path.join(__dirname, '..', 'target', 'release', 'bitfun-cli'); +const usePath = fs.existsSync(cliPath) + ? cliPath + : fs.existsSync(cliReleasePath) + ? cliReleasePath + : 'bitfun-cli'; -const usePath = require('fs').existsSync(cliPath) ? cliPath : - require('fs').existsSync(cliReleasePath) ? cliReleasePath : - 'bitfun-cli'; +const cwd = '/tmp/test-acp-node'; +fs.mkdirSync(cwd, { recursive: true }); console.log('=== BitFun ACP Server Test (Node.js) ===\n'); -// Test requests -const testRequests = [ - { - name: 'Initialize', - request: { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - terminal: true - }, - clientInfo: { name: 'NodeTestClient', version: '1.0' } - } - } - }, - { - name: 'Create Session', - request: { - jsonrpc: '2.0', - id: 2, - method: 'session/new', - params: { - cwd: '/tmp/test-acp-node' - } - } - }, - { - name: 'List Tools', - request: { - jsonrpc: '2.0', - id: 3, - method: 'tools/list' - } - } -]; +const child = spawn(usePath, ['acp'], { + stdio: ['pipe', 'pipe', 'inherit'], +}); + +let buffer = ''; +let sessionId = null; + +function send(request) { + child.stdin.write(`${JSON.stringify(request)}\n`); +} -// Run individual tests -async function runTest(test) { - console.log(`Test: ${test.name}`); - console.log('Request:', JSON.stringify(test.request, null, 2)); - - const child = spawn(usePath, ['acp'], { - stdio: ['pipe', 'pipe', 'inherit'] - }); - - let output = ''; - - child.stdout.on('data', (data) => { - output += data.toString(); - }); - - child.stdin.write(JSON.stringify(test.request) + '\n'); +function stopChild() { child.stdin.end(); - - return new Promise((resolve) => { - child.on('close', (code) => { - console.log('Response:', output); - try { - const response = JSON.parse(output); - console.log('Parsed:', JSON.stringify(response, null, 2)); - } catch (e) { - console.log('Parse error:', e.message); - } - console.log('\n'); - resolve(); - }); - }); + setTimeout(() => { + if (!child.killed) { + child.kill('SIGTERM'); + } + }, 500); } -// Run all tests sequentially -async function runAllTests() { - for (const test of testRequests) { - await runTest(test); +child.stdout.on('data', (data) => { + buffer += data.toString(); + const lines = buffer.split(/\n/); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line); + console.log(JSON.stringify(message, null, 2)); + + if (message.id === 2) { + sessionId = message.result.sessionId; + send({ + jsonrpc: '2.0', + id: 3, + method: 'session/list', + params: { cwd }, + }); + } else if (message.id === 3) { + send({ + jsonrpc: '2.0', + id: 4, + method: 'session/prompt', + params: { + sessionId, + prompt: [{ type: 'text', text: '你好' }], + }, + }); + } else if (message.id === 4) { + stopChild(); + } } - - console.log('=== Tests Complete ==='); -} +}); + +child.on('close', (code) => { + console.log(`\n=== Tests Complete: exit ${code} ===`); + process.exit(code); +}); + +send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + clientInfo: { name: 'NodeTestClient', version: '1.0' }, + }, +}); -runAllTests().catch(console.error); \ No newline at end of file +send({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd, mcpServers: [] }, +}); diff --git a/scripts/test-acp.sh b/scripts/test-acp.sh index ca57fd292..21780ab2c 100644 --- a/scripts/test-acp.sh +++ b/scripts/test-acp.sh @@ -5,34 +5,62 @@ echo "=== BitFun ACP Server Test ===" echo "" -# Check if bitfun-cli is built -if ! command -v bitfun-cli &> /dev/null; then - echo "Error: bitfun-cli not found in PATH" - echo "Please build the CLI first: cargo build --package bitfun-cli" - exit 1 -fi +BINARY="${BITFUN_CLI:-target/debug/bitfun-cli}" +WORKSPACE="/tmp/test-acp" +PIPE_DIR="$(mktemp -d /tmp/bitfun-acp-test-sh.XXXXXX)" +ACP_IN="$PIPE_DIR/in" +ACP_OUT="$PIPE_DIR/out" +mkdir -p "$WORKSPACE" +mkfifo "$ACP_IN" "$ACP_OUT" -echo "Test 1: Initialize" -echo "Sending: initialize request" -echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},"clientInfo":{"name":"TestClient","version":"1.0"}}}' | bitfun-cli acp -echo "" +cleanup() { + exec 3>&- 2>/dev/null || true + exec 4<&- 2>/dev/null || true + if [[ -n "${ACP_PID:-}" ]]; then + kill "$ACP_PID" 2>/dev/null || true + wait "$ACP_PID" 2>/dev/null || true + fi + rm -rf "$PIPE_DIR" +} +trap cleanup EXIT +echo "Test 1: Initialize" echo "Test 2: Create Session" -echo "Sending: session/new request" -echo '{"jsonrpc":"2.0","id":2,"method":"session/new","params":{"cwd":"/tmp/test-acp"}}' | bitfun-cli acp -echo "" +echo "Test 3: List Sessions" +"$BINARY" acp <"$ACP_IN" >"$ACP_OUT" & +ACP_PID="$!" +exec 3>"$ACP_IN" +exec 4<"$ACP_OUT" -echo "Test 3: List Tools" -echo "Sending: tools/list request" -echo '{"jsonrpc":"2.0","id":3,"method":"tools/list"}' | bitfun-cli acp -echo "" +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},"clientInfo":{"name":"TestClient","version":"1.0"}}}' \ + >&3 + +responses=0 +while [[ "$responses" -lt 3 ]]; do + if ! IFS= read -r -t 15 line <&4; then + echo "Timed out waiting for ACP response" >&2 + exit 1 + fi + + echo "$line" + if [[ "$line" == *'"id":'* ]]; then + responses=$((responses + 1)) + fi -echo "Test 4: List Sessions" -echo "Sending: session/list request" -echo '{"jsonrpc":"2.0","id":4,"method":"session/list"}' | bitfun-cli acp + if [[ "$line" == *'"id":1'* ]]; then + printf '%s\n' \ + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"session/new\",\"params\":{\"cwd\":\"$WORKSPACE\",\"mcpServers\":[]}}" \ + >&3 + elif [[ "$line" == *'"id":2'* ]]; then + printf '%s\n' \ + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"session/list\",\"params\":{\"cwd\":\"$WORKSPACE\"}}" \ + >&3 + fi +done +exec 3>&- echo "" echo "=== Tests Complete ===" echo "" -echo "Note: This is a basic test of the protocol layer." -echo "Full agentic workflow execution is not yet implemented." \ No newline at end of file +echo "Note: This is a basic test of the typed ACP protocol layer." diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 5795fc02b..6dd42ccf0 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" # Internal crates bitfun-core = { path = "../../crates/core" } bitfun-events = { path = "../../crates/events" } +bitfun-acp = { path = "../../crates/acp" } # CLI framework clap = { version = "4", features = ["derive"] } @@ -49,4 +50,3 @@ tracing-subscriber = { workspace = true } [features] default = [] - diff --git a/src/apps/cli/src/acp/handlers.rs b/src/apps/cli/src/acp/handlers.rs deleted file mode 100644 index ae056dd29..000000000 --- a/src/apps/cli/src/acp/handlers.rs +++ /dev/null @@ -1,711 +0,0 @@ -//! ACP Request Handlers -//! -//! Implements handlers for all ACP methods. - -use anyhow::{anyhow, Context, Result}; -use std::sync::Arc; -use tokio::io::{AsyncWriteExt, Stdout}; - -use crate::acp::protocol::*; -use crate::acp::session::{AcpSession, AcpSessionManager}; -use crate::agent::AgenticSystem; -use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; -use bitfun_core::agentic::core::SessionConfig; -use bitfun_core::agentic::tools::framework::{ToolResult, ToolUseContext}; -use bitfun_events::{AgenticEvent as CoreEvent, ToolEventData}; - -/// Handle an ACP method call -pub async fn handle_method( - request: JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result> { - let method = request.method.as_str(); - - tracing::info!("Handling ACP method: {}", method); - - let result = match method { - // Lifecycle methods - "initialize" => handle_initialize(&request)?, - "authenticate" => handle_authenticate(&request)?, - - // Session methods - "session/new" => handle_session_new(&request, session_manager, agentic_system).await?, - "session/load" => handle_session_load(&request)?, - "session/prompt" => { - handle_session_prompt(&request, agentic_system, session_manager).await? - } - "session/cancel" => { - // Notification - no response - handle_session_cancel(&request, session_manager).await?; - return Ok(None); - } - "session/list" => handle_session_list(&request, session_manager)?, - - // Tools methods - "tools/list" => handle_tools_list(&request, agentic_system).await?, - "tools/call" => handle_tools_call(&request, agentic_system, session_manager).await?, - - // Config methods - "session/set_config_option" => handle_set_config_option(&request)?, - "session/set_mode" => handle_set_mode(&request)?, - - // Unknown method - _ => { - if let Some(id) = request.id { - return Ok(Some(JsonRpcResponse::error( - id, - -32601, - format!("Method not found: {}", method), - ))); - } else { - // Notification for unknown method, just ignore - return Ok(None); - } - } - }; - - if let Some(id) = request.id { - Ok(Some(JsonRpcResponse::success(id, result))) - } else { - // Notification - Ok(None) - } -} - -/// Handle initialize request -fn handle_initialize(request: &JsonRpcRequest) -> Result { - let params: InitializeParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for initialize"))? - .clone(), - ) - .context("Failed to parse initialize params")?; - - tracing::info!( - "ACP initialization: protocol_version={}, client_name={:?}", - params.protocol_version, - params.client_info.as_ref().map(|i| &i.name) - ); - - let result = InitializeResult { - protocol_version: "0.1.0".to_string(), // ACP protocol version - agent_capabilities: AgentCapabilities { - load_session: true, - mcp_capabilities: McpCapabilities { - http: true, - sse: true, - }, - prompt_capabilities: PromptCapabilities { - audio: false, - embedded_context: true, - image: true, - }, - session_capabilities: SessionCapabilities { list: true }, - }, - agent_info: Some(AgentInfo { - name: "BitFun".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }), - auth_methods: vec![], // No authentication required - }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle authenticate request -fn handle_authenticate(_request: &JsonRpcRequest) -> Result { - // BitFun doesn't require authentication - Ok(serde_json::json!({ "success": true })) -} - -/// Handle session/new request -async fn handle_session_new( - request: &JsonRpcRequest, - session_manager: &Arc, - agentic_system: &AgenticSystem, -) -> Result { - let params: SessionNewParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for session/new"))? - .clone(), - ) - .context("Failed to parse session/new params")?; - - tracing::info!("Creating new ACP session: cwd={}", params.cwd); - - // Create ACP session - let client_caps = ClientCapabilities::default(); // TODO: Get from previous initialize - let acp_session = session_manager.create_session(params.cwd.clone(), client_caps)?; - - // Create a BitFun session via ConversationCoordinator - let workspace_path = Some(params.cwd.clone()); - let session = agentic_system - .coordinator - .create_session( - format!( - "ACP Session - {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - ), - "agentic".to_string(), - SessionConfig { - workspace_path, - ..Default::default() - }, - ) - .await?; - - // Update the ACP session with the real BitFun session ID - session_manager - .update_bitfun_session_id(&acp_session.acp_session_id, session.session_id.clone()); - - tracing::info!( - "Created BitFun session for ACP: acp_id={}, bitfun_id={}", - acp_session.acp_session_id, - session.session_id - ); - - let result = SessionNewResult { - session_id: acp_session.acp_session_id.clone(), - config_options: Some(vec![]), // No special config options - modes: Some(SessionModes { - available_modes: vec![ - ModeInfo { - id: "ask".to_string(), - name: Some("Ask".to_string()), - description: Some("Ask questions and get information".to_string()), - }, - ModeInfo { - id: "architect".to_string(), - name: Some("Architect".to_string()), - description: Some("Design and plan architecture".to_string()), - }, - ModeInfo { - id: "code".to_string(), - name: Some("Code".to_string()), - description: Some("Write and modify code".to_string()), - }, - ], - current_mode: Some("code".to_string()), - }), - }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle session/load request -fn handle_session_load(_request: &JsonRpcRequest) -> Result { - // TODO: Implement session loading - Err(anyhow!("Session loading not yet implemented")) -} - -/// Handle session/prompt request - executes user message with BitFun's agentic system -async fn handle_session_prompt( - request: &JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result { - let params: SessionPromptParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for session/prompt"))? - .clone(), - ) - .context("Failed to parse session/prompt params")?; - - tracing::info!( - "Processing session/prompt: session_id={}, prompt_blocks={}", - params.session_id, - params.prompt.len() - ); - - // Get ACP session - let acp_session = session_manager - .get_session(¶ms.session_id) - .ok_or_else(|| anyhow!("Session not found: {}", params.session_id))?; - - // Extract text from prompt content blocks - let user_message = params - .prompt - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\n"); - - if user_message.is_empty() { - return Err(anyhow!("Empty user message")); - } - - tracing::info!( - "User message for session {}: {}", - acp_session.bitfun_session_id, - user_message.chars().take(100).collect::() - ); - - // Get stdout for sending notifications - let stdout = tokio::io::stdout(); - - // Execute the message through ConversationCoordinator - let result = execute_prompt_turn(agentic_system, &acp_session, user_message, stdout).await?; - - Ok(serde_json::to_value(result)?) -} - -/// Execute a prompt turn using BitFun's ConversationCoordinator -async fn execute_prompt_turn( - agentic_system: &AgenticSystem, - acp_session: &AcpSession, - user_message: String, - mut stdout: Stdout, -) -> Result { - let session_id = acp_session.bitfun_session_id.clone(); - let agent_type = "agentic".to_string(); - - tracing::info!("Starting dialog turn for session: {}", session_id); - - // Start the dialog turn - agentic_system - .coordinator - .start_dialog_turn( - session_id.clone(), - user_message.clone(), - None, - None, - agent_type.clone(), - Some(acp_session.cwd.clone()), - DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), - ) - .await?; - - let event_queue = agentic_system.event_queue.clone(); - let mut stop_reason: Option = None; - let mut accumulated_text = String::new(); - - loop { - let events = event_queue.dequeue_batch(10).await; - - if events.is_empty() { - if stop_reason.is_some() { - break; - } - - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - continue; - } - - for envelope in events { - let event = envelope.event; - - if event.session_id() != Some(&session_id) { - continue; - } - - match event { - CoreEvent::TextChunk { text, .. } => { - accumulated_text.push_str(&text); - let notification = SessionUpdateNotification { - session_id: acp_session.acp_session_id.clone(), - update: SessionUpdate::AgentMessageChunk { - content: ContentBlock::Text { text }, - }, - }; - send_notification(&mut stdout, "session/update", ¬ification).await?; - } - - CoreEvent::ToolEvent { tool_event, .. } => { - handle_tool_event(&mut stdout, &acp_session.acp_session_id, tool_event).await?; - } - - CoreEvent::DialogTurnCompleted { .. } => { - tracing::info!("Dialog turn completed in ACP handler"); - stop_reason = Some(StopReason::EndTurn); - break; - } - - CoreEvent::DialogTurnFailed { error, .. } => { - tracing::error!("Dialog turn failed: {}", error); - stop_reason = Some(StopReason::Error); - let notification = SessionUpdateNotification { - session_id: acp_session.acp_session_id.clone(), - update: SessionUpdate::AgentMessageChunk { - content: ContentBlock::Text { - text: format!("Error: {}", error), - }, - }, - }; - send_notification(&mut stdout, "session/update", ¬ification).await?; - break; - } - - CoreEvent::SystemError { error, .. } => { - tracing::error!("System error: {}", error); - stop_reason = Some(StopReason::Error); - break; - } - - _ => { - tracing::debug!("Ignoring event: {:?}", event); - } - } - } - } - - tracing::info!( - "Dialog turn finished: stop_reason={:?}, text_len={}", - stop_reason, - accumulated_text.len(), - ); - - Ok(SessionPromptResult { - stop_reason: stop_reason.unwrap_or(StopReason::EndTurn), - }) -} - -/// Handle tool event and send appropriate notification -async fn handle_tool_event( - stdout: &mut Stdout, - session_id: &str, - tool_event: ToolEventData, -) -> Result<()> { - match tool_event { - ToolEventData::Started { - tool_id, - tool_name, - params: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id, - name: tool_name, - title: None, - kind: None, - status: Some(ToolCallStatus::Pending), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - } - - ToolEventData::Progress { - tool_id, - tool_name, - message, - percentage: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: Some(ToolCallStatus::InProgress), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result with progress message - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: message }], - status: Some(ToolCallStatus::InProgress), - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::Completed { - tool_id, - tool_name, - result, - duration_ms: _, - .. - } => { - let result_text = - serde_json::to_string(&result).unwrap_or_else(|_| "Success".to_string()); - - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: Some(ToolCallStatus::Completed), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: result_text }], - status: Some(ToolCallStatus::Completed), - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::Failed { - tool_id, - tool_name, - error, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id.clone(), - name: tool_name.clone(), - title: None, - kind: None, - status: None, // Failed - no specific status - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - - // Send tool result with error - let result_notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolResult { - tool_call_id: tool_id, - content: vec![ToolResultContent::Text { text: error }], - status: None, // Failed - }, - }; - send_notification(stdout, "session/update", &result_notification).await?; - } - - ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, - params: _, - } => { - let notification = SessionUpdateNotification { - session_id: session_id.to_string(), - update: SessionUpdate::ToolCall { - tool_call_id: tool_id, - name: tool_name, - title: None, - kind: None, - status: Some(ToolCallStatus::Pending), - }, - }; - send_notification(stdout, "session/update", ¬ification).await?; - } - - _ => { - // Ignore other tool events for now - } - } - - Ok(()) -} - -/// Send a JSON-RPC notification via stdout -async fn send_notification( - stdout: &mut Stdout, - method: &str, - params: &impl serde::Serialize, -) -> Result<()> { - let notification = JsonRpcRequest::new( - None, - method.to_string(), - Some(serde_json::to_value(params)?), - ); - - let notification_json = serde_json::to_string(¬ification)?; - tracing::debug!("Sending notification: {}", notification_json); - - stdout.write_all(notification_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - - Ok(()) -} - -/// Handle session/cancel notification -async fn handle_session_cancel( - _request: &JsonRpcRequest, - _session_manager: &Arc, -) -> Result<()> { - tracing::info!("Received session/cancel notification"); - - // TODO: Implement cancellation - // 1. Stop ongoing model requests - // 2. Abort tool invocations - // 3. Send pending session/update notifications - // 4. Mark session as cancelled - - Ok(()) -} - -/// Handle session/list request -fn handle_session_list( - _request: &JsonRpcRequest, - session_manager: &Arc, -) -> Result { - let sessions = session_manager.list_sessions(); - - let session_infos: Vec = sessions - .iter() - .map(|s| { - serde_json::json!({ - "sessionId": s.acp_session_id, - "cwd": s.cwd, - }) - }) - .collect(); - - Ok(serde_json::json!({ - "sessions": session_infos, - })) -} - -/// Handle tools/list request -async fn handle_tools_list( - _request: &JsonRpcRequest, - _agentic_system: &AgenticSystem, -) -> Result { - tracing::info!("Listing available tools"); - - // Get tools from BitFun's tool registry - let registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = registry.read().await; - let all_tools = registry_lock.get_all_tools(); - - // Build tool definitions (need to await description) - let mut tools: Vec = Vec::new(); - for tool in all_tools.iter() { - let desc = tool - .description() - .await - .map_err(|e| anyhow!("Failed to get tool description: {}", e))?; - tools.push(ToolDefinition { - name: tool.name().to_string(), - description: Some(desc), - input_schema: Some(tool.input_schema().clone()), - }); - } - - let result = ToolsListResult { tools }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle tools/call request - executes a tool directly -async fn handle_tools_call( - request: &JsonRpcRequest, - agentic_system: &AgenticSystem, - session_manager: &Arc, -) -> Result { - let params: ToolsCallParams = serde_json::from_value( - request - .params - .as_ref() - .ok_or_else(|| anyhow!("Missing params for tools/call"))? - .clone(), - ) - .context("Failed to parse tools/call params")?; - - tracing::info!( - "Tool call request: session_id={}, tool_name={}", - params.session_id, - params.name - ); - - // Get ACP session - let acp_session = session_manager - .get_session(¶ms.session_id) - .ok_or_else(|| anyhow!("Session not found: {}", params.session_id))?; - - // Get tool from registry - let registry = bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = registry.read().await; - let tool = registry_lock - .get_tool(¶ms.name) - .ok_or_else(|| anyhow!("Tool not found: {}", params.name))?; - - // Create tool use context - let context = ToolUseContext { - tool_call_id: None, - agent_type: Some("agentic".to_string()), - session_id: Some(acp_session.bitfun_session_id.clone()), - dialog_turn_id: None, - workspace: None, - custom_data: std::collections::HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: Default::default(), - workspace_services: None, - }; - - tracing::info!( - "Executing tool {} with arguments: {:?}", - params.name, - params.arguments - ); - - // Execute the tool - let tool_results = tool - .call(¶ms.arguments, &context) - .await - .map_err(|e| anyhow!("Tool execution failed: {}", e))?; - - // Convert tool results to ACP content blocks - let content: Vec = tool_results - .into_iter() - .filter_map(|result| { - match result { - ToolResult::Result { - data, - result_for_assistant, - .. - } => { - // Use result_for_assistant if available, otherwise serialize data - let text = result_for_assistant.unwrap_or_else(|| { - serde_json::to_string(&data).unwrap_or_else(|_| "Success".to_string()) - }); - Some(ToolResultContent::Text { text }) - } - ToolResult::Progress { content: data, .. } => { - let text = - serde_json::to_string(&data).unwrap_or_else(|_| "Progress".to_string()); - Some(ToolResultContent::Text { text }) - } - ToolResult::StreamChunk { data, .. } => { - let text = - serde_json::to_string(&data).unwrap_or_else(|_| "Stream chunk".to_string()); - Some(ToolResultContent::Text { text }) - } - } - }) - .collect(); - - let result = ToolsCallResult { content }; - - Ok(serde_json::to_value(result)?) -} - -/// Handle session/set_config_option request -fn handle_set_config_option(_request: &JsonRpcRequest) -> Result { - // TODO: Implement config options - Ok(serde_json::json!({ "success": true })) -} - -/// Handle session/set_mode request -fn handle_set_mode(_request: &JsonRpcRequest) -> Result { - // TODO: Implement mode switching - Ok(serde_json::json!({ "success": true })) -} diff --git a/src/apps/cli/src/acp/mod.rs b/src/apps/cli/src/acp/mod.rs deleted file mode 100644 index 3d1b42e9d..000000000 --- a/src/apps/cli/src/acp/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! Agent Client Protocol (ACP) Support -//! -//! This module implements the Agent Client Protocol for BitFun CLI, -//! enabling integration with ACP-compatible editors and IDEs. -//! -//! ACP is a JSON-RPC 2.0 based protocol for communication between -//! code editors/IDEs and AI coding agents. - -pub mod handlers; -pub mod protocol; -pub mod session; - -use anyhow::{Context, Result}; -use std::sync::Arc; - -use crate::agent::AgenticSystem; - -pub use protocol::*; -pub use session::*; - -/// ACP Server - handles JSON-RPC communication over stdio -pub struct AcpServer { - agentic_system: AgenticSystem, - session_manager: Arc, -} - -impl AcpServer { - /// Create a new ACP server - pub fn new(agentic_system: AgenticSystem) -> Self { - Self { - session_manager: Arc::new(AcpSessionManager::new()), - agentic_system, - } - } - - /// Run the ACP server - reads JSON-RPC from stdin, writes to stdout - pub async fn run(&self) -> Result<()> { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - - tracing::info!("Starting ACP server (JSON-RPC over stdio)"); - - let stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - let mut reader = BufReader::new(stdin); - let mut line = String::new(); - - loop { - line.clear(); - let bytes_read = reader.read_line(&mut line).await?; - - if bytes_read == 0 { - tracing::info!("EOF received, shutting down ACP server"); - break; - } - - let request = line.trim(); - if request.is_empty() { - continue; - } - - tracing::debug!("Received ACP request: {}", request); - - match self.handle_request(request).await { - Ok(Some(response)) => { - let response_json = serde_json::to_string(&response)?; - tracing::debug!("Sending ACP response: {}", response_json); - stdout.write_all(response_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - Ok(None) => { - // Notification, no response needed - tracing::debug!("ACP notification processed (no response needed)"); - } - Err(e) => { - tracing::error!("Error handling ACP request: {}", e); - // Send error response if we can parse the request ID - if let Ok(json_value) = serde_json::from_str::(request) { - if let Some(id) = json_value.get("id") { - let error_response = JsonRpcResponse::error( - id.clone(), - -32603, - format!("Internal error: {}", e), - ); - let error_json = serde_json::to_string(&error_response)?; - stdout.write_all(error_json.as_bytes()).await?; - stdout.write_all(b"\n").await?; - stdout.flush().await?; - } - } - } - } - } - - Ok(()) - } - - /// Handle a single JSON-RPC request - async fn handle_request(&self, request: &str) -> Result> { - let rpc_request: JsonRpcRequest = - serde_json::from_str(request).context("Failed to parse JSON-RPC request")?; - - handlers::handle_method(rpc_request, &self.agentic_system, &self.session_manager).await - } -} diff --git a/src/apps/cli/src/acp/protocol.rs b/src/apps/cli/src/acp/protocol.rs deleted file mode 100644 index 3cc264fb3..000000000 --- a/src/apps/cli/src/acp/protocol.rs +++ /dev/null @@ -1,436 +0,0 @@ -//! JSON-RPC Protocol Types for ACP -//! -//! Defines the JSON-RPC 2.0 message structures used by ACP. - -use serde::{Deserialize, Serialize}; - -/// JSON-RPC 2.0 Request -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl JsonRpcRequest { - /// Create a new JSON-RPC request - pub fn new( - id: Option, - method: String, - params: Option, - ) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id, - method, - params, - } - } - - /// Check if this is a notification (no response expected) - pub fn is_notification(&self) -> bool { - self.id.is_none() - } -} - -/// JSON-RPC 2.0 Response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -impl JsonRpcResponse { - /// Create a successful response - pub fn success(id: serde_json::Value, result: serde_json::Value) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: Some(id), - result: Some(result), - error: None, - } - } - - /// Create an error response - pub fn error(id: serde_json::Value, code: i32, message: String) -> Self { - Self { - jsonrpc: "2.0".to_string(), - id: Some(id), - result: None, - error: Some(JsonRpcError { code, message }), - } - } -} - -/// JSON-RPC Error -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, -} - -// ============================================================================ -// ACP Protocol Types -// ============================================================================ - -/// Initialize request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeParams { - /// Protocol version as string (e.g., "0.1.0") - #[serde(rename = "protocolVersion")] - pub protocol_version: String, - #[serde(default)] - pub client_capabilities: ClientCapabilities, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_info: Option, -} - -/// Client capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct ClientCapabilities { - #[serde(default)] - pub fs: FsCapabilities, - #[serde(default)] - pub terminal: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct FsCapabilities { - #[serde(default)] - pub read_text_file: bool, - #[serde(default)] - pub write_text_file: bool, -} - -/// Client info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientInfo { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, -} - -/// Initialize response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResult { - /// Protocol version as string (e.g., "0.1.0") - #[serde(rename = "protocolVersion")] - pub protocol_version: String, - #[serde(default)] - pub agent_capabilities: AgentCapabilities, - #[serde(default)] - pub agent_info: Option, - #[serde(default)] - pub auth_methods: Vec, -} - -/// Agent capabilities -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AgentCapabilities { - #[serde(default)] - pub load_session: bool, - #[serde(default)] - pub mcp_capabilities: McpCapabilities, - #[serde(default)] - pub prompt_capabilities: PromptCapabilities, - #[serde(default)] - pub session_capabilities: SessionCapabilities, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct McpCapabilities { - #[serde(default)] - pub http: bool, - #[serde(default)] - pub sse: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct PromptCapabilities { - #[serde(default)] - pub audio: bool, - #[serde(default)] - pub embedded_context: bool, - #[serde(default)] - pub image: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct SessionCapabilities { - #[serde(default)] - pub list: bool, -} - -/// Agent info -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentInfo { - pub name: String, - pub version: String, -} - -/// Auth method -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthMethod { - pub method_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -// ============================================================================ -// Session Types -// ============================================================================ - -/// Session/new request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionNewParams { - /// Working directory for the session (optional, defaults to current directory) - #[serde(default = "default_cwd")] - pub cwd: String, - #[serde(default)] - pub mcp_servers: Vec, -} - -fn default_cwd() -> String { - std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| ".".to_string()) -} - -/// MCP server configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct McpServerConfig { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub transport: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum McpTransport { - Stdio { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: std::collections::HashMap, - }, - Http { - url: String, - }, - Sse { - url: String, - }, -} - -/// Session/new response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionNewResult { - pub session_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub config_options: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub modes: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigOption { - pub key: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub value: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionModes { - pub available_modes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub current_mode: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModeInfo { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Session/prompt request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPromptParams { - pub session_id: String, - pub prompt: Vec, -} - -/// Content block for messages -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ContentBlock { - Text { - text: String, - }, - Image { - source: ImageSource, - }, - #[serde(rename = "embedded_context")] - EmbeddedContext { - resources: Vec, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageSource { - #[serde(rename = "type")] - source_type: String, - media_type: String, - data: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Resource { - pub uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -/// Session/prompt response -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionPromptResult { - pub stop_reason: StopReason, -} - -/// Stop reason for prompt turn -/// See ACP spec: https://agentclientprotocol.com/protocol/prompt-turn#stop-reasons -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum StopReason { - #[serde(rename = "end_turn")] - EndTurn, - #[serde(rename = "cancelled")] - Cancelled, - #[serde(rename = "tool_use")] - ToolUse, - #[serde(rename = "tool_error")] - ToolError, - #[serde(rename = "error")] - Error, -} - -// ============================================================================ -// Tool Types -// ============================================================================ - -/// Tool definition -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub input_schema: Option, -} - -/// Tools/list response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsListResult { - pub tools: Vec, -} - -/// Tools/call request parameters -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ToolsCallParams { - pub session_id: String, - pub name: String, - #[serde(default)] - pub arguments: serde_json::Value, -} - -/// Tools/call response -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolsCallResult { - pub content: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum ToolResultContent { - Text { text: String }, - Image { source: ImageSource }, -} - -// ============================================================================ -// Notification Types -// ============================================================================ - -/// Session/update notification -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionUpdateNotification { - pub session_id: String, - pub update: SessionUpdate, -} - -/// Session update notification types -/// See ACP spec: https://agentclientprotocol.com/protocol/session-lifecycle -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "sessionUpdate", rename_all = "snake_case")] -pub enum SessionUpdate { - AgentMessageChunk { - content: ContentBlock, - }, - AgentThoughtChunk { - content: ContentBlock, - }, - ToolCall { - tool_call_id: String, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - }, - ToolResult { - tool_call_id: String, - content: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - }, -} - -/// Tool call status -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolCallStatus { - Pending, - InProgress, - Completed, -} diff --git a/src/apps/cli/src/acp/session.rs b/src/apps/cli/src/acp/session.rs deleted file mode 100644 index 247138863..000000000 --- a/src/apps/cli/src/acp/session.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! ACP Session Management -//! -//! Manages ACP sessions and maps them to BitFun sessions. - -use anyhow::Result; -use dashmap::DashMap; -use std::sync::Arc; - -/// ACP Session Manager -/// -/// Maps ACP session IDs to BitFun session IDs -pub struct AcpSessionManager { - /// ACP session ID -> BitFun session ID mapping - sessions: Arc>, -} - -/// ACP Session metadata -#[derive(Debug, Clone)] -pub struct AcpSession { - /// ACP session ID (used in ACP protocol) - pub acp_session_id: String, - /// BitFun session ID - pub bitfun_session_id: String, - /// Working directory - pub cwd: String, - /// Client capabilities - pub client_capabilities: crate::acp::protocol::ClientCapabilities, -} - -impl AcpSessionManager { - /// Create a new session manager - pub fn new() -> Self { - Self { - sessions: Arc::new(DashMap::new()), - } - } - - /// Create a new ACP session - pub fn create_session( - &self, - cwd: String, - client_capabilities: crate::acp::protocol::ClientCapabilities, - ) -> Result { - let acp_session_id = uuid::Uuid::new_v4().to_string(); - let bitfun_session_id = uuid::Uuid::new_v4().to_string(); - - let session = AcpSession { - acp_session_id: acp_session_id.clone(), - bitfun_session_id, - cwd, - client_capabilities, - }; - - self.sessions - .insert(acp_session_id.clone(), session.clone()); - - tracing::info!( - "Created ACP session: acp_id={}, bitfun_id={}", - session.acp_session_id, - session.bitfun_session_id - ); - - Ok(session) - } - - /// Get an ACP session by ID - pub fn get_session(&self, acp_session_id: &str) -> Option { - self.sessions.get(acp_session_id).map(|s| s.clone()) - } - - /// Remove an ACP session - pub fn remove_session(&self, acp_session_id: &str) -> Option { - self.sessions - .remove(acp_session_id) - .map(|(_, session)| session) - } - - /// List all sessions - pub fn list_sessions(&self) -> Vec { - self.sessions.iter().map(|s| s.clone()).collect() - } - - /// Update the BitFun session ID for an ACP session - pub fn update_bitfun_session_id( - &self, - acp_session_id: &str, - bitfun_session_id: String, - ) -> Option { - self.sessions.get_mut(acp_session_id).map(|mut mut_ref| { - mut_ref.bitfun_session_id = bitfun_session_id; - mut_ref.clone() - }) - } -} - -impl Default for AcpSessionManager { - fn default() -> Self { - Self::new() - } -} diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 1c491c7aa..d271853b7 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -199,6 +199,7 @@ impl Agent for CoreAgentAdapter { tool_id, tool_name, params, + timeout_seconds: _, } => { tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { tool_id: Some(tool_id.clone()), diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 9772f8c26..2d3851f73 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,11 +1,10 @@ /// Agent integration module /// /// Wraps interaction with bitfun-core's Agent system -pub mod agentic_system; pub mod core_adapter; // Re-export AgenticSystem for use in other modules -pub use agentic_system::AgenticSystem; +pub use bitfun_core::agentic::system::AgenticSystem; use anyhow::Result; use tokio::sync::mpsc; diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index f37f023c4..b9c476f0b 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,4 +1,3 @@ -mod acp; mod agent; /// BitFun CLI /// @@ -164,6 +163,7 @@ async fn main() -> Result<()> { }; let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); + let is_acp_mode = matches!(cli.command, Some(Commands::Acp { .. })); if is_tui_mode { use std::fs::OpenOptions; @@ -197,6 +197,13 @@ async fn main() -> Result<()> { .with_target(false) .init(); } + } else if is_acp_mode { + tracing_subscriber::fmt() + .with_max_level(log_level) + .with_writer(std::io::stderr) + .with_ansi(false) + .with_target(false) + .init(); } else { tracing_subscriber::fmt() .with_max_level(log_level) @@ -271,7 +278,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); @@ -338,7 +345,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); @@ -407,15 +414,14 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); // Start ACP server tracing::info!("Starting ACP server..."); - let acp_server = acp::AcpServer::new(agentic_system); - acp_server.run().await?; + bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; } None => { @@ -463,7 +469,7 @@ async fn main() -> Result<()> { .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - let agentic_system = agent::agentic_system::init_agentic_system() + let agentic_system = bitfun_core::agentic::system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index cd20e4f0c..537b322fb 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; -use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; +use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgenticSystem}; use crate::config::CliConfig; use crate::session::Session; use crate::ui::chat::ChatView; diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index eca043967..314d44c44 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,6 +1,4 @@ -use crate::agent::{ - agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent, AgentEvent, -}; +use crate::agent::{core_adapter::CoreAgentAdapter, Agent, AgentEvent, AgenticSystem}; use crate::config::CliConfig; /// Exec mode implementation /// diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 408ec19c9..18070cfca 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1032,6 +1032,8 @@ impl StartupPage { key: KeyEvent, page: &mut WorkspaceSelectPage, ) -> Result<()> { + page.custom_cursor = normalize_byte_cursor(&page.custom_input, page.custom_cursor); + match key.code { KeyCode::Enter => { // If input is empty, use current directory @@ -1051,8 +1053,10 @@ impl StartupPage { } KeyCode::Backspace => { if page.custom_cursor > 0 && page.custom_cursor <= page.custom_input.len() { - page.custom_input.remove(page.custom_cursor - 1); - page.custom_cursor -= 1; + let prev_cursor = + previous_char_boundary(&page.custom_input, page.custom_cursor); + page.custom_input.remove(prev_cursor); + page.custom_cursor = prev_cursor; } } KeyCode::Delete => { @@ -1062,12 +1066,13 @@ impl StartupPage { } KeyCode::Left => { if page.custom_cursor > 0 { - page.custom_cursor -= 1; + page.custom_cursor = + previous_char_boundary(&page.custom_input, page.custom_cursor); } } KeyCode::Right => { if page.custom_cursor < page.custom_input.len() { - page.custom_cursor += 1; + page.custom_cursor = next_char_boundary(&page.custom_input, page.custom_cursor); } } KeyCode::Home => { @@ -1078,7 +1083,7 @@ impl StartupPage { } KeyCode::Char(c) => { page.custom_input.insert(page.custom_cursor, c); - page.custom_cursor += 1; + page.custom_cursor += c.len_utf8(); } _ => {} } @@ -1380,3 +1385,59 @@ impl StartupPage { Ok(()) } } + +fn normalize_byte_cursor(input: &str, cursor: usize) -> usize { + let mut cursor = cursor.min(input.len()); + while cursor > 0 && !input.is_char_boundary(cursor) { + cursor -= 1; + } + cursor +} + +fn previous_char_boundary(input: &str, cursor: usize) -> usize { + let mut cursor = normalize_byte_cursor(input, cursor); + if cursor == 0 { + return 0; + } + cursor -= 1; + normalize_byte_cursor(input, cursor) +} + +fn next_char_boundary(input: &str, cursor: usize) -> usize { + let cursor = normalize_byte_cursor(input, cursor); + if cursor >= input.len() { + return input.len(); + } + + let mut next = cursor + 1; + while next < input.len() && !input.is_char_boundary(next) { + next += 1; + } + next +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_cursor_moves_on_utf8_boundaries() { + let input = "a你b"; + + assert_eq!(next_char_boundary(input, 0), 1); + assert_eq!(next_char_boundary(input, 1), 4); + assert_eq!(next_char_boundary(input, 4), 5); + assert_eq!(previous_char_boundary(input, 5), 4); + assert_eq!(previous_char_boundary(input, 4), 1); + assert_eq!(previous_char_boundary(input, 1), 0); + } + + #[test] + fn workspace_cursor_normalizes_invalid_byte_offsets() { + let input = "你"; + + assert_eq!(normalize_byte_cursor(input, 1), 0); + assert_eq!(normalize_byte_cursor(input, 2), 0); + assert_eq!(normalize_byte_cursor(input, 3), 3); + } +} diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 0d955b9fa..239ee278a 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -20,24 +20,8 @@ ## 本模块规则 -- 保持 Tauri command 一致:名称使用 `snake_case`,调用使用结构化 `request` - 桌面端专属集成留在这里,不要下沉到共享 core -- 本地临时调试时,无论是共享前端改动还是 Rust / Tauri 改动,都优先使用 `pnpm run desktop:preview:debug`;它会在现有 debug 二进制仍然可复用时直接预览,并在桌面侧输入更新或二进制缺失时自动重编后再预览。只有在需要完整 Tauri dev watcher,或正在排查启动 / 构建集成本身时,才回到 `pnpm run desktop:dev` -- 当表述里同时出现“编译/调试版本”和“快速看看效果/先看一下”时,按更高层的“预览”意图处理,优先使用 preview 命令,而不是 `pnpm run desktop:build:fast` - -推荐命令形状: - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke('your_command', { request: { ... } }); -``` +- 涉及打包或 release 请求时,参见顶层 `AGENTS.md` ## 命令 @@ -50,6 +34,25 @@ cargo build -p bitfun-desktop pnpm run desktop:build:fast ``` +## 快速构建 + +| 命令 | 使用场景 | +|---|---| +| `pnpm run desktop:build:fast` | Debug 构建,不打包;手动测试时编译最快 | +| `pnpm run desktop:build:release-fast` | 类 Release 构建,降低 LTO;需要 release 行为但无法等待完整 LTO 时使用 | +| `pnpm run desktop:build:nsis:fast` | Windows 安装器,使用 `release-fast` profile;快速验证安装器 | + +`release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 + +## DevTools feature(模型规则) + +`devtools` Cargo feature 用于桌面端 UI/UX 调试。添加或修改调试相关代码时: + +- 所有调试专用 API 和 command 必须用 `#[cfg(any(debug_assertions, feature = "devtools"))]` 保护 +- 在 `#[cfg(not(any(debug_assertions, feature = "devtools")))]` 下提供 no-op stub,确保 command 始终可以注册到 `invoke_handler` +- 该 feature 通过 `--features devtools` 在 `dev` 构建和 `release-fast` profile 构建中自动启用 +- 面向最终用户的 `release` profile 构建中永不启用 + ## 验证 ```bash @@ -61,15 +64,3 @@ cargo check -p bitfun-desktop && cargo test -p bitfun-desktop ```bash cargo build -p bitfun-desktop ``` - -上面的 preview 命令只是迭代捷径,完成任务前仍要按要求执行最小 Rust 检查,以及必要的 build / E2E 验证。 - -只有在你明确想忽略时间戳复用判断、强制先重编再预览时,才使用 `pnpm run desktop:preview:debug -- --force-rebuild`。 - -`pnpm run desktop:build:fast` 只用于用户明确要 debug 构建产物、且不需要启动应用预览的场景。 - -涉及打包或 release 请求时: - -- 如果用户没有明确说明要的是本地快速产物、独立可执行文件,还是安装器,先确认目标打包形式。 -- 不要用 preview/debug 产物替代正式 release 交付物。 -- 在 Windows 上,面向安装交付优先使用 `pnpm run desktop:build:nsis`;只有用户明确要独立可执行文件时,才使用 `pnpm run desktop:build:exe`。 diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index d8192e318..db24f9b10 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -20,24 +20,8 @@ If a change affects shared product behavior across runtimes, the implementation ## Local rules -- Keep Tauri commands consistent: `snake_case` names, structured `request` - Keep desktop-only integrations here; do not move them into shared core -- For local temporary debugging, prefer `pnpm run desktop:preview:debug` for both frontend-only shared-UI changes and Rust / Tauri changes. It reuses the existing debug binary when it is still current and auto-rebuilds before preview when desktop-side inputs are newer or the binary is missing. Use `pnpm run desktop:dev` only when you need the full Tauri dev watcher or are debugging startup/build integration itself -- When the wording mixes "build/debug version" with "quickly inspect the effect", treat the higher-level intent as preview and use the preview commands instead of `pnpm run desktop:build:fast` - -Preferred command shape: - -```rust -#[tauri::command] -pub async fn your_command( - state: State<'_, AppState>, - request: YourRequest, -) -> Result -``` - -```ts -await api.invoke('your_command', { request: { ... } }); -``` +- For packaging or release asks, see the top-level `AGENTS.md` ## Commands @@ -50,6 +34,25 @@ cargo build -p bitfun-desktop pnpm run desktop:build:fast ``` +## Fast builds + +| Command | When to use | +|---|---| +| `pnpm run desktop:build:fast` | Debug build without bundling; fastest compile for manual testing | +| `pnpm run desktop:build:release-fast` | Release-like build with reduced LTO; use when you need release behavior but can't wait for full LTO | +| `pnpm run desktop:build:nsis:fast` | Windows installer using `release-fast` profile; for quick installer validation | + +`release-fast` profile (`Cargo.toml`): inherits `release` but disables LTO, increases `codegen-units` to 16, enables incremental compilation. Significantly faster at the cost of binary size and marginal runtime performance. + +## DevTools feature (model rule) + +The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When adding or modifying debug-related code: + +- Guard all debug-only APIs and commands with `#[cfg(any(debug_assertions, feature = "devtools"))]` +- Provide no-op stubs under `#[cfg(not(any(debug_assertions, feature = "devtools")))]` so commands can always be registered in `invoke_handler` +- The feature is enabled automatically in `dev` builds and `release-fast` profile builds via `--features devtools` +- Never enable in `release` profile builds intended for end users + ## Verification ```bash @@ -61,15 +64,3 @@ If the change affects startup, WebDriver, browser/computer-use, or packaged beha ```bash cargo build -p bitfun-desktop ``` - -The preview commands above are iteration shortcuts only; keep using the minimum Rust checks and any required build / E2E verification before finishing. - -Use `pnpm run desktop:preview:debug -- --force-rebuild` only when you explicitly want to rebuild before preview even if the timestamp check says the binary is current. - -Use `pnpm run desktop:build:fast` only when the user explicitly wants a debug build artifact without launching the app. - -For packaging or release asks: - -- Confirm the package form when the user did not specify whether they want a local fast artifact, a standalone executable, or an installer. -- Do not substitute preview/debug outputs for a real release deliverable. -- On Windows, prefer `pnpm run desktop:build:nsis` for installer-style delivery and `pnpm run desktop:build:exe` only when the user explicitly wants a standalone executable. diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 0213818c8..f1eb298e5 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -22,6 +22,7 @@ serde_json = { workspace = true } bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] } bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/webdriver" } +bitfun-acp = { path = "../../crates/acp" } # Tauri tauri = { workspace = true } @@ -39,12 +40,14 @@ serde_json = { workspace = true } anyhow = { workspace = true } log = { workspace = true } chrono = { workspace = true } +uuid = { workspace = true } regex = { workspace = true } dirs = { workspace = true } similar = { workspace = true } ignore = { workspace = true } urlencoding = { workspace = true } reqwest = { workspace = true } +zip = { workspace = true } thiserror = "1.0" futures = { workspace = true } async-trait = { workspace = true } @@ -76,5 +79,13 @@ windows = { version = "0.61.3", features = [ ] } windows-core = "0.61.2" +[features] +default = [] +# Enable webview devtools and element inspector for development builds. +# Only active in debug builds or when explicitly requested via --features devtools. +# Never enabled in release profile intended for end users. +devtools = ["tauri/devtools"] + [target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies] leptess = "0.14.0" +atspi = "0.29" diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs new file mode 100644 index 000000000..5123ed9ff --- /dev/null +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -0,0 +1,524 @@ +//! ACP client API + +use crate::api::app_state::AppState; +use crate::api::session_storage_path::desktop_effective_session_storage_path; +use bitfun_acp::client::{ + AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, + AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, + SubmitAcpPermissionResponseRequest, +}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientIdRequest { + pub client_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionRequest { + pub client_id: String, + #[serde(default)] + pub session_name: Option, + pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +pub type CreateAcpFlowSessionResponse = CreateAcpFlowSessionRecordResponse; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StartAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + pub user_input: String, + #[serde(default)] + pub original_user_input: Option, + pub turn_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + #[serde(default)] + pub timeout_seconds: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelAcpDialogTurnRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetAcpSessionOptionsRequest { + pub session_id: String, + pub client_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[tauri::command] +pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.initialize_all().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.list_clients().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn probe_acp_client_requirements( + state: State<'_, AppState>, +) -> Result, String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .probe_client_requirements() + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn predownload_acp_client_adapter( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .predownload_client_adapter(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn install_acp_client_cli( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .install_client_cli(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn create_acp_flow_session( + state: State<'_, AppState>, + app_handle: AppHandle, + request: CreateAcpFlowSessionRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + + let session_storage_path = desktop_effective_session_storage_path( + &state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let response = service + .create_flow_session_record( + &session_storage_path, + &request.workspace_path, + &request.client_id, + request.session_name, + ) + .await + .map_err(|e| e.to_string())?; + if let Err(error) = service + .start_client_for_session(&request.client_id, &response.session_id) + .await + { + if let Err(cleanup_error) = service + .delete_flow_session_record(&session_storage_path, &response.session_id) + .await + { + log::warn!( + "Failed to delete ACP session record after client start failure: session_id={}, error={}", + response.session_id, + cleanup_error + ); + } + return Err(error.to_string()); + } + + let _ = app_handle.emit( + "agentic://session-created", + serde_json::json!({ + "sessionId": response.session_id.clone(), + "sessionName": response.session_name.clone(), + "agentType": response.agent_type.clone(), + "workspacePath": request.workspace_path, + "remoteConnectionId": request.remote_connection_id, + "remoteSshHost": request.remote_ssh_host, + }), + ); + + Ok(response) +} + +#[tauri::command] +pub async fn start_acp_dialog_turn( + state: State<'_, AppState>, + app_handle: AppHandle, + request: StartAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())? + .clone(); + + let session_id = request.session_id.clone(); + let turn_id = request.turn_id.clone(); + let user_input = request.user_input.clone(); + let original_user_input = request + .original_user_input + .clone() + .unwrap_or_else(|| request.user_input.clone()); + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + + app_handle + .emit( + "agentic://dialog-turn-started", + serde_json::json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": null, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": null, + "subagentParentInfo": null, + }), + ) + .map_err(|e| e.to_string())?; + tokio::spawn(async move { + let mut current_round_id: Option = None; + let result = service + .prompt_agent_stream( + &request.client_id, + request.user_input, + request.workspace_path, + Some(request.session_id.clone()), + session_storage_path, + request.timeout_seconds, + |event| { + match event { + AcpClientStreamEvent::ModelRoundStarted { + round_id, + round_index, + disable_explore_grouping, + } => { + current_round_id = Some(round_id.clone()); + app_handle + .emit( + "agentic://model-round-started", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "roundIndex": round_index, + "renderHints": { + "disableExploreGrouping": disable_explore_grouping, + }, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::AgentText(text) => { + let round_id = current_round_id.clone().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::service( + "ACP text arrived before model round start".to_string(), + ) + })?; + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::AgentThought(text) => { + let round_id = current_round_id.clone().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::service( + "ACP thought arrived before model round start".to_string(), + ) + })?; + app_handle + .emit( + "agentic://text-chunk", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "roundId": round_id, + "text": text, + "contentType": "thinking", + "isThinkingEnd": false, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::ToolEvent(tool_event) => { + app_handle + .emit( + "agentic://tool-event", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "toolEvent": tool_event, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Completed => { + app_handle + .emit( + "agentic://dialog-turn-completed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + "partialRecoveryReason": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + AcpClientStreamEvent::Cancelled => { + app_handle + .emit( + "agentic://dialog-turn-cancelled", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } + } + Ok(()) + }, + ) + .await; + + if let Err(error) = result { + let _ = app_handle.emit( + "agentic://dialog-turn-failed", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "error": error.to_string(), + "errorCategory": null, + "errorDetail": null, + "subagentParentInfo": null, + }), + ); + } + }); + + Ok(()) +} + +#[tauri::command] +pub async fn cancel_acp_dialog_turn( + state: State<'_, AppState>, + request: CancelAcpDialogTurnRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .cancel_agent_session( + &request.client_id, + request.workspace_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_acp_session_options( + state: State<'_, AppState>, + request: GetAcpSessionOptionsRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .get_session_options( + &request.client_id, + request.workspace_path, + session_storage_path, + Some(request.session_id), + ) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn set_acp_session_model( + state: State<'_, AppState>, + request: SetAcpSessionModelRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .set_session_model(request, session_storage_path) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_acp_client( + state: State<'_, AppState>, + request: AcpClientIdRequest, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .stop_client(&request.client_id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn load_acp_json_config(state: State<'_, AppState>) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.load_json_config().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn save_acp_json_config( + state: State<'_, AppState>, + json_config: String, +) -> Result<(), String> { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .save_json_config(&json_config) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn submit_acp_permission_response( + state: State<'_, AppState>, + request: SubmitAcpPermissionResponseRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .submit_permission_response(request) + .await + .map_err(|e| e.to_string()) +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 8ab2afcbf..87e1bca41 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -579,8 +579,28 @@ fn resolve_missing_image_payloads( #[tauri::command] pub async fn cancel_dialog_turn( coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, request: CancelDialogTurnRequest, ) -> Result<(), String> { + if let Some(acp_client_service) = app_state.acp_client_service.as_ref() { + match acp_client_service + .cancel_bitfun_session(&request.session_id) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => { + log::error!( + "Failed to cancel ACP dialog turn: session_id={}, dialog_turn_id={}, error={}", + request.session_id, + request.dialog_turn_id, + error + ); + return Err(format!("Failed to cancel ACP dialog turn: {}", error)); + } + } + } + coordinator .cancel_dialog_turn(&request.session_id, &request.dialog_turn_id) .await @@ -696,6 +716,11 @@ pub async fn delete_session( request.remote_ssh_host.as_deref(), ) .await; + if let Some(acp_client_service) = app_state.acp_client_service.as_ref() { + acp_client_service + .release_bitfun_session(&request.session_id) + .await; + } coordinator .delete_session(&effective_path, &request.session_id) .await diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 3e88ce4c0..6df1e11ff 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -3,6 +3,7 @@ //! Application state management +use crate::api::workspace_activation::spawn_workspace_background_warmup; use bitfun_core::agentic::side_question::SideQuestionRuntime; use bitfun_core::agentic::{agents, tools}; use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory}; @@ -13,7 +14,7 @@ use bitfun_core::service::remote_ssh::{ init_remote_workspace_manager, RemoteFileService, RemoteTerminalManager, SSHConnectionManager, }; use bitfun_core::service::{ - ai_rules, announcement, config, filesystem, mcp, token_usage, workspace, + ai_rules, announcement, config, filesystem, mcp, search, token_usage, workspace, }; use bitfun_core::util::errors::*; @@ -72,9 +73,11 @@ pub struct AppState { pub workspace_path: Arc>>, pub config_service: Arc, pub filesystem_service: Arc, + pub workspace_search_service: Arc, pub ai_rules_service: Arc, pub agent_registry: Arc, pub mcp_service: Option>, + pub acp_client_service: Option>, pub token_usage_service: Arc, pub miniapp_manager: Arc, pub js_worker_pool: Option>, @@ -118,6 +121,8 @@ impl AppState { ); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); + let workspace_search_service = Arc::new(search::WorkspaceSearchService::new()); + search::set_global_workspace_search_service(workspace_search_service.clone()); ai_rules::initialize_global_ai_rules_service() .await @@ -143,6 +148,12 @@ impl AppState { } }; let path_manager = workspace_service.path_manager().clone(); + let acp_client_service = Some( + bitfun_acp::AcpClientService::new(config_service.clone(), path_manager.clone()) + .map_err(|e| { + BitFunError::service(format!("Failed to initialize ACP client service: {}", e)) + })?, + ); let announcement_scheduler = Arc::new( announcement::AnnouncementScheduler::new(&path_manager) @@ -194,35 +205,6 @@ impl AppState { .map(|workspace| workspace.root_path.clone()); if let Some(workspace_path) = initial_workspace_path.clone() { - let skip_startup_snapshot_restore = initial_workspace - .as_ref() - .map(|workspace| { - matches!( - workspace.workspace_kind, - bitfun_core::service::workspace::WorkspaceKind::Remote - ) - }) - .unwrap_or(false); - if skip_startup_snapshot_restore { - log::debug!( - "Skipping snapshot restore on startup for remote workspace: path={}", - workspace_path.display() - ); - } else { - if let Err(e) = - bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - workspace_path.clone(), - None, - ) - .await - { - log::warn!( - "Failed to restore snapshot system on startup: path={}, error={}", - workspace_path.display(), - e - ); - } - } if let Err(e) = ai_rules_service.set_workspace(workspace_path).await { log::warn!("Failed to restore AI rules workspace on startup: {}", e); } @@ -316,9 +298,11 @@ impl AppState { workspace_path: Arc::new(RwLock::new(initial_workspace_path)), config_service, filesystem_service, + workspace_search_service, ai_rules_service, agent_registry, mcp_service, + acp_client_service, token_usage_service, miniapp_manager, js_worker_pool, @@ -334,6 +318,10 @@ impl AppState { announcement_scheduler, }; + if let Some(workspace_info) = initial_workspace { + spawn_workspace_background_warmup(&app_state, workspace_info); + } + log::info!("AppState initialized successfully"); Ok(app_state) } @@ -347,6 +335,7 @@ impl AppState { services.insert("workspace_service".to_string(), true); services.insert("config_service".to_string(), true); services.insert("filesystem_service".to_string(), true); + services.insert("workspace_search_service".to_string(), true); let all_healthy = services.values().all(|&status| status); diff --git a/src/apps/desktop/src/api/btw_api.rs b/src/apps/desktop/src/api/btw_api.rs index c49652f5f..aa13f4382 100644 --- a/src/apps/desktop/src/api/btw_api.rs +++ b/src/apps/desktop/src/api/btw_api.rs @@ -1,40 +1,18 @@ //! BTW (side question) API //! -//! Desktop adapter for the core side-question service: -//! - Reads current session context without mutating the parent session -//! - Streams answer via `btw://...` events -//! - Supports cancellation by request id +//! Desktop adapter for the core `/btw` feature. +//! +//! `/btw` runs as a hidden transient child session that reuses the parent +//! session's full context snapshot while still flowing through the normal +//! agentic event pipeline. -use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; +use tauri::State; use crate::api::app_state::AppState; use bitfun_core::agentic::coordination::ConversationCoordinator; -use bitfun_core::agentic::side_question::{ - SideQuestionPersistTarget, SideQuestionService, SideQuestionStreamEvent, - SideQuestionStreamRequest, -}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BtwAskRequest { - pub session_id: String, - pub question: String, - /// Optional model id override. Supports "fast"/"primary" aliases. - pub model_id: Option, - /// Limit how many context messages are included (from the end). - pub max_context_messages: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BtwAskResponse { - pub answer: String, -} #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -42,14 +20,10 @@ pub struct BtwAskStreamRequest { pub request_id: String, pub session_id: String, pub question: String, + pub child_session_id: String, + pub child_session_name: Option, /// Optional model id override. Supports "fast"/"primary" aliases. pub model_id: Option, - /// Limit how many context messages are included (from the end). - pub max_context_messages: Option, - pub child_session_id: Option, - pub workspace_path: Option, - pub parent_dialog_turn_id: Option, - pub parent_turn_index: Option, } #[derive(Debug, Clone, Serialize)] @@ -64,42 +38,6 @@ pub struct BtwCancelRequest { pub request_id: String, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BtwTextChunkEvent { - pub request_id: String, - pub session_id: String, - pub text: String, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BtwCompletedEvent { - pub request_id: String, - pub session_id: String, - pub full_text: String, - pub finish_reason: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BtwErrorEvent { - pub request_id: String, - pub session_id: String, - pub error: String, -} - -fn side_question_service( - state: &AppState, - coordinator: Arc, -) -> SideQuestionService { - SideQuestionService::new( - coordinator, - state.ai_client_factory.clone(), - state.side_question_runtime.clone(), - ) -} - #[tauri::command] pub async fn btw_cancel( state: State<'_, AppState>, @@ -110,14 +48,23 @@ pub async fn btw_cancel( return Err("requestId is required".to_string()); } - let svc = side_question_service(&state, coordinator.inner().clone()); - svc.cancel(&request.request_id).await; + state.side_question_runtime.cancel(&request.request_id).await; + if let Some(active_turn) = state + .side_question_runtime + .get_btw_turn(&request.request_id) + .await + { + coordinator + .cancel_dialog_turn(&active_turn.session_id, &active_turn.turn_id) + .await + .map_err(|e| e.to_string())?; + state.side_question_runtime.remove(&request.request_id).await; + } Ok(()) } #[tauri::command] pub async fn btw_ask_stream( - app: AppHandle, state: State<'_, AppState>, coordinator: State<'_, Arc>, request: BtwAskStreamRequest, @@ -131,82 +78,52 @@ pub async fn btw_ask_stream( if request.question.trim().is_empty() { return Err("question is required".to_string()); } + let child_session_id = request.child_session_id.trim(); + if child_session_id.is_empty() { + return Err("childSessionId is required".to_string()); + } - let svc = side_question_service(&state, coordinator.inner().clone()); - - let rx = svc - .start_stream(SideQuestionStreamRequest { - request_id: request.request_id.clone(), - session_id: request.session_id.clone(), - question: request.question.clone(), - model_id: request.model_id.clone(), - max_context_messages: request.max_context_messages, - persist_target: match (&request.child_session_id, &request.workspace_path) { - (Some(child_session_id), Some(workspace_path)) - if !child_session_id.trim().is_empty() && !workspace_path.trim().is_empty() => - { - Some(SideQuestionPersistTarget { - child_session_id: child_session_id.clone(), - workspace_path: PathBuf::from(workspace_path), - parent_session_id: request.session_id.clone(), - parent_dialog_turn_id: request.parent_dialog_turn_id.clone(), - parent_turn_index: request.parent_turn_index, - }) - } - _ => None, - }, - }) + let turn_id = coordinator + .start_hidden_btw_turn( + &request.request_id, + &request.session_id, + child_session_id, + request.child_session_name.as_deref(), + &request.question, + request.model_id.as_deref(), + ) .await .map_err(|e| e.to_string())?; - let app_handle = app.clone(); + state + .side_question_runtime + .register_btw_turn( + request.request_id.clone(), + child_session_id.to_string(), + turn_id.clone(), + ) + .await; + let runtime = state.side_question_runtime.clone(); + let request_id = request.request_id.clone(); + let child_session_id = child_session_id.to_string(); + let turn_id = turn_id; + let coordinator = coordinator.inner().clone(); tokio::spawn(async move { - let mut rx = rx; - while let Some(evt) = rx.recv().await { - match evt { - SideQuestionStreamEvent::TextChunk { - request_id, - session_id, - text, - } => { - let payload = BtwTextChunkEvent { - request_id, - session_id, - text, - }; - if let Err(e) = app_handle.emit("btw://text-chunk", payload) { - warn!("Failed to emit btw text chunk: {}", e); - } + loop { + let Some(session) = coordinator.get_session_manager().get_session(&child_session_id) else { + runtime.remove(&request_id).await; + break; + }; + + match session.state { + bitfun_core::agentic::core::SessionState::Processing { + current_turn_id, .. + } if current_turn_id == turn_id => { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; } - SideQuestionStreamEvent::Completed { - request_id, - session_id, - full_text, - finish_reason, - } => { - let payload = BtwCompletedEvent { - request_id, - session_id, - full_text, - finish_reason, - }; - if let Err(e) = app_handle.emit("btw://completed", payload) { - warn!("Failed to emit btw completed: {}", e); - } - } - SideQuestionStreamEvent::Error { - request_id, - session_id, - error: err, - } => { - let payload = BtwErrorEvent { - request_id, - session_id, - error: err, - }; - if let Err(e) = app_handle.emit("btw://error", payload) { - warn!("Failed to emit btw error: {}", e); - } + _ => { + runtime.remove(&request_id).await; + break; } } } @@ -214,33 +131,3 @@ pub async fn btw_ask_stream( Ok(BtwAskStreamResponse { ok: true }) } - -#[tauri::command] -pub async fn btw_ask( - state: State<'_, AppState>, - coordinator: State<'_, Arc>, - request: BtwAskRequest, -) -> Result { - let svc = side_question_service(&state, coordinator.inner().clone()); - - let answer = svc - .ask( - &request.session_id, - &request.question, - request.model_id.as_deref(), - request.max_context_messages, - ) - .await - .map_err(|e| { - error!("BTW ask failed: {}", e); - e.to_string() - })?; - - info!( - "BTW ask completed: session_id={}, answer_len={}", - request.session_id, - answer.len() - ); - - Ok(BtwAskResponse { answer }) -} diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index fb2278899..aafaf1ba8 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -8,6 +8,11 @@ use crate::api::path_target::{ get_path_metadata, path_exists, read_text_file, rename_path, resolve_desktop_path_target, write_text_file, DesktopPathTarget, }; +use crate::api::search_api::{ + group_search_results, search_file_contents_via_workspace_search, + search_metadata_from_content_result, should_use_workspace_search, SearchMetadataResponse, +}; +use crate::api::workspace_activation::spawn_workspace_background_warmup; use bitfun_core::infrastructure::{ BatchedFileSearchProgressSink, FileSearchResult, FileSearchResultGroup, FileTreeNode, SearchMatchType, @@ -20,7 +25,7 @@ use bitfun_core::service::workspace::{ }; use log::{debug, error, info, warn}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -204,6 +209,8 @@ struct SearchCompleteEvent { limit: usize, truncated: bool, total_results: usize, + #[serde(skip_serializing_if = "Option::is_none")] + search_metadata: Option, } #[derive(Debug, Clone, Serialize)] @@ -262,6 +269,7 @@ fn emit_search_complete( limit: usize, truncated: bool, total_results: usize, + search_metadata: Option, ) { if let Err(error) = app_handle.emit( FILE_SEARCH_COMPLETE_EVENT, @@ -271,6 +279,7 @@ fn emit_search_complete( limit, truncated, total_results, + search_metadata, }, ) { warn!( @@ -311,18 +320,24 @@ struct SearchCommandResponse { results: Vec, limit: usize, truncated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + search_metadata: Option, } fn serialize_search_response( outcome: bitfun_core::infrastructure::FileSearchOutcome, limit: usize, + search_metadata: Option, ) -> serde_json::Value { serde_json::to_value(SearchCommandResponse { results: serialize_search_results(outcome.results), limit, truncated: outcome.truncated, + search_metadata, + }) + .unwrap_or_else(|_| { + serde_json::json!({ "results": [], "limit": limit, "truncated": false, "searchMetadata": null }) }) - .unwrap_or_else(|_| serde_json::json!({ "results": [], "limit": limit, "truncated": false })) } #[derive(Debug, Deserialize)] @@ -418,6 +433,36 @@ pub struct ReadFileContentRequest { pub remote_connection_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportAgentCompanionPetPackageRequest { + pub path: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteAgentCompanionPetPackageRequest { + pub package_path: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentCompanionPetPackageDto { + pub id: String, + pub display_name: String, + pub description: Option, + pub source: String, + pub package_path: String, + pub spritesheet_path: String, + pub spritesheet_mime_type: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListAgentCompanionPetsResponse { + pub pets: Vec, +} + #[derive(Debug, Deserialize)] pub struct WriteFileContentRequest { #[serde(rename = "workspacePath")] @@ -594,8 +639,18 @@ async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHa #[cfg(not(target_os = "macos"))] let _ = app; + let previous_workspace_path = state.workspace_path.read().await.clone(); *state.workspace_path.write().await = None; + if let Some(previous_workspace_path) = previous_workspace_path { + let root_str = previous_workspace_path.to_string_lossy().to_string(); + if !is_remote_path(root_str.trim()).await { + state + .workspace_search_service + .schedule_repo_release(previous_workspace_path); + } + } + if let Some(ref pool) = state.js_worker_pool { pool.stop_all().await; } @@ -635,34 +690,6 @@ async fn apply_active_workspace_context( // Remote workspace roots are POSIX paths on the SSH host — not writable local directories on // Windows. Snapshot hooks already skip file tracking for registered remote paths; avoid // creating `/.bitfun` (or drive root) here which fails with access denied. - let root_str = workspace_info.root_path.to_string_lossy().to_string(); - let skip_local_snapshot = workspace_info.workspace_kind == WorkspaceKind::Remote - || is_remote_path(root_str.trim()).await; - if !skip_local_snapshot { - if let Err(e) = bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - workspace_info.root_path.clone(), - None, - ) - .await - { - warn!( - "Failed to initialize snapshot system: path={}, error={}", - workspace_info.root_path.display(), - e - ); - } - } else { - debug!( - "Skipping local snapshot manager init for remote/non-local workspace root_path={}", - workspace_info.root_path.display() - ); - } - - state - .agent_registry - .load_custom_subagents(&workspace_info.root_path) - .await; - if let Err(e) = state .ai_rules_service .set_workspace(workspace_info.root_path.clone()) @@ -675,6 +702,8 @@ async fn apply_active_workspace_context( ); } + spawn_workspace_background_warmup(&*state, workspace_info.clone()); + #[cfg(target_os = "macos")] { let language = state @@ -1944,6 +1973,311 @@ pub async fn read_file_content( .await } +struct PetPackageSource { + pet_json: Vec, + spritesheet_name: PathBuf, + spritesheet: Vec, +} + +fn sanitize_pet_id(id: &str) -> String { + let sanitized: String = id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + "custom-pet".to_string() + } else { + trimmed.to_string() + } +} + +fn spritesheet_mime_type(file_name: &str) -> &'static str { + match Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + _ => "image/webp", + } +} + +fn load_pet_manifest_from_bytes(bytes: &[u8]) -> Result<(serde_json::Value, PathBuf), String> { + let manifest: serde_json::Value = + serde_json::from_slice(bytes).map_err(|e| format!("Failed to parse pet.json: {}", e))?; + let spritesheet_path = manifest + .get("spritesheetPath") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "pet.json is missing spritesheetPath".to_string())? + .to_string(); + Ok((manifest, PathBuf::from(spritesheet_path))) +} + +fn load_pet_package_source(source_path: &Path) -> Result { + if source_path.is_dir() { + let pet_json_path = source_path.join("pet.json"); + let pet_json = std::fs::read(&pet_json_path) + .map_err(|e| format!("Failed to read pet.json: {}", e))?; + let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?; + let spritesheet_path = source_path.join(&spritesheet_name); + let spritesheet = std::fs::read(&spritesheet_path) + .map_err(|e| format!("Failed to read spritesheet: {}", e))?; + return Ok(PetPackageSource { + pet_json, + spritesheet_name, + spritesheet, + }); + } + + let file = std::fs::File::open(source_path) + .map_err(|e| format!("Failed to open pet zip package: {}", e))?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|e| format!("Failed to read pet zip package: {}", e))?; + + let mut manifest_index = None; + for index in 0..archive.len() { + let entry = archive + .by_index(index) + .map_err(|e| format!("Failed to inspect pet zip package: {}", e))?; + if Path::new(entry.name()).file_name().and_then(|n| n.to_str()) == Some("pet.json") { + manifest_index = Some(index); + break; + } + } + let manifest_index = + manifest_index.ok_or_else(|| "Pet package must contain pet.json".to_string())?; + + let mut pet_json = Vec::new(); + let manifest_name = { + let mut manifest_file = archive + .by_index(manifest_index) + .map_err(|e| format!("Failed to open pet.json in zip package: {}", e))?; + std::io::copy(&mut manifest_file, &mut pet_json) + .map_err(|e| format!("Failed to read pet.json from zip package: {}", e))?; + PathBuf::from(manifest_file.name()) + }; + let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?; + let spritesheet_zip_path = manifest_name + .parent() + .unwrap_or_else(|| Path::new("")) + .join(&spritesheet_name) + .to_string_lossy() + .replace('\\', "/"); + + let mut spritesheet = Vec::new(); + let mut spritesheet_file = archive + .by_name(&spritesheet_zip_path) + .map_err(|e| format!("Failed to open spritesheet in zip package: {}", e))?; + std::io::copy(&mut spritesheet_file, &mut spritesheet) + .map_err(|e| format!("Failed to read spritesheet from zip package: {}", e))?; + + Ok(PetPackageSource { + pet_json, + spritesheet_name, + spritesheet, + }) +} + +fn companion_user_packages_dir(state: &AppState) -> PathBuf { + state + .workspace_service + .path_manager() + .user_data_dir() + .join("agent-companions") +} + +fn pet_package_dto_from_dir(dir: &Path, source: &str) -> Result { + let pet_json_path = dir.join("pet.json"); + let pet_json = std::fs::read(&pet_json_path) + .map_err(|e| format!("Failed to read {}: {}", pet_json_path.display(), e))?; + let (manifest, spritesheet_rel_path) = load_pet_manifest_from_bytes(&pet_json)?; + let raw_id = manifest + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or_else(|| dir.file_name().and_then(|name| name.to_str()).unwrap_or("pet")); + let display_name = manifest + .get("displayName") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(raw_id) + .trim() + .to_string(); + let description = manifest + .get("description") + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let spritesheet_path = dir.join(&spritesheet_rel_path); + if !spritesheet_path.is_file() { + return Err(format!("Spritesheet not found: {}", spritesheet_path.display())); + } + let spritesheet_file_name = spritesheet_rel_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("spritesheet.webp"); + + Ok(AgentCompanionPetPackageDto { + id: sanitize_pet_id(raw_id), + display_name, + description, + source: source.to_string(), + package_path: dir.to_string_lossy().to_string(), + spritesheet_path: spritesheet_path.to_string_lossy().to_string(), + spritesheet_mime_type: spritesheet_mime_type(spritesheet_file_name).to_string(), + }) +} + +fn scan_pet_package_dirs(root: &Path, source: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut pets = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() || !path.join("pet.json").is_file() { + continue; + } + match pet_package_dto_from_dir(&path, source) { + Ok(dto) => pets.push(dto), + Err(err) => warn!("Skipping invalid Agent companion pet package: {}", err), + } + } + pets.sort_by(|a, b| a.display_name.to_lowercase().cmp(&b.display_name.to_lowercase())); + pets +} + +#[tauri::command] +pub async fn list_agent_companion_pets( + state: State<'_, AppState>, +) -> Result { + let pets = scan_pet_package_dirs(&companion_user_packages_dir(&state), "user"); + Ok(ListAgentCompanionPetsResponse { pets }) +} + +#[tauri::command] +pub async fn import_agent_companion_pet_package( + state: State<'_, AppState>, + request: ImportAgentCompanionPetPackageRequest, +) -> Result { + let source_path = PathBuf::from(request.path); + let source = load_pet_package_source(&source_path)?; + let (pet_json, _) = load_pet_manifest_from_bytes(&source.pet_json)?; + + let raw_id = pet_json + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("custom-pet"); + let id = sanitize_pet_id(raw_id); + let display_name = pet_json + .get("displayName") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(raw_id) + .trim() + .to_string(); + let description = pet_json + .get("description") + .and_then(|value| value.as_str()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + + let package_dir = state + .workspace_service + .path_manager() + .user_data_dir() + .join("agent-companions") + .join(format!("{}-{}", id, uuid::Uuid::new_v4().simple())); + + std::fs::create_dir_all(&package_dir) + .map_err(|e| format!("Failed to create pet package directory: {}", e))?; + + let spritesheet_file_name = source + .spritesheet_name + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("spritesheet.webp") + .to_string(); + let spritesheet_path = package_dir.join(&spritesheet_file_name); + + let mut normalized_manifest = pet_json; + if let Some(obj) = normalized_manifest.as_object_mut() { + obj.insert( + "spritesheetPath".to_string(), + serde_json::Value::String(spritesheet_file_name.clone()), + ); + } + + let manifest_bytes = serde_json::to_vec_pretty(&normalized_manifest) + .map_err(|e| format!("Failed to serialize pet.json: {}", e))?; + std::fs::write(package_dir.join("pet.json"), manifest_bytes) + .map_err(|e| format!("Failed to write pet.json: {}", e))?; + std::fs::write(&spritesheet_path, source.spritesheet) + .map_err(|e| format!("Failed to write spritesheet: {}", e))?; + + info!( + "Imported Agent companion pet package '{}' into {}", + id, + package_dir.display() + ); + + Ok(AgentCompanionPetPackageDto { + id, + display_name, + description, + source: "user".to_string(), + package_path: package_dir.to_string_lossy().to_string(), + spritesheet_path: spritesheet_path.to_string_lossy().to_string(), + spritesheet_mime_type: spritesheet_mime_type(&spritesheet_file_name).to_string(), + }) +} + +#[tauri::command] +pub async fn delete_agent_companion_pet_package( + state: State<'_, AppState>, + request: DeleteAgentCompanionPetPackageRequest, +) -> Result<(), String> { + let root = companion_user_packages_dir(&state); + if !root.exists() { + return Err("Agent companion packages directory does not exist".to_string()); + } + let root = root + .canonicalize() + .map_err(|e| format!("Failed to resolve Agent companion packages root: {}", e))?; + + let candidate = PathBuf::from(&request.package_path); + let resolved = candidate + .canonicalize() + .map_err(|e| format!("Pet package path not found: {}", e))?; + + if !resolved.starts_with(&root) { + return Err("Refusing to delete path outside imported Agent companion packages".to_string()); + } + if !resolved.is_dir() { + return Err("Pet package is not a directory".to_string()); + } + + std::fs::remove_dir_all(&resolved) + .map_err(|e| format!("Failed to delete pet package: {}", e))?; + + info!( + "Deleted Agent companion pet package at {}", + resolved.display() + ); + Ok(()) +} + #[tauri::command] pub async fn write_file_content( state: State<'_, AppState>, @@ -2301,6 +2635,8 @@ pub async fn search_files( include_directories: request.include_directories, }; + let use_workspace_search = + request.search_content && should_use_workspace_search(&request.root_path).await; let result = if request.search_content { let filename_outcome = state .filesystem_service @@ -2320,20 +2656,35 @@ pub async fn search_files( if filename_results.len() >= max_results { Ok(filename_results) } else { - let mut content_outcome = state - .filesystem_service - .search_file_contents( + let remaining = max_results - filename_results.len(); + let mut content_outcome = if use_workspace_search { + search_file_contents_via_workspace_search( + &state, &request.root_path, &request.pattern, - FileSearchOptions { - include_content: true, - include_directories: false, - max_results: Some(max_results - filename_results.len()), - ..options - }, - cancel_flag, + request.case_sensitive, + request.use_regex, + request.whole_word, + remaining, ) - .await?; + .await + .map(|result| result.outcome)? + } else { + state + .filesystem_service + .search_file_contents( + &request.root_path, + &request.pattern, + FileSearchOptions { + include_content: true, + include_directories: false, + max_results: Some(remaining), + ..options + }, + cancel_flag, + ) + .await? + }; if filename_outcome.truncated || content_outcome.truncated { debug!( "Legacy search truncated: root_path={}, pattern={}, search_content={}, limit={}", @@ -2412,7 +2763,7 @@ pub async fn search_filenames( limit, outcome.truncated ); - Ok(serialize_search_response(outcome, limit)) + Ok(serialize_search_response(outcome, limit, None)) } Err(error) => { error!( @@ -2444,14 +2795,33 @@ pub async fn search_file_contents( include_directories: false, }; - let result = state - .filesystem_service - .search_file_contents(&request.root_path, &request.pattern, options, cancel_flag) - .await; + let result = if should_use_workspace_search(&request.root_path).await { + search_file_contents_via_workspace_search( + &state, + &request.root_path, + &request.pattern, + request.case_sensitive, + request.use_regex, + request.whole_word, + limit, + ) + .await + .map(|result| { + let search_metadata = search_metadata_from_content_result(&result); + (result.outcome, Some(search_metadata)) + }) + } else { + state + .filesystem_service + .search_file_contents(&request.root_path, &request.pattern, options, cancel_flag) + .await + .map(|outcome| (outcome, None)) + .map_err(|error| format!("Failed to search file contents: {}", error)) + }; unregister_search(&state, search_id.as_deref()); match result { - Ok(outcome) => { + Ok((outcome, search_metadata)) => { info!( "Content search completed: root_path={}, pattern={}, results_count={}, limit={}, truncated={}", request.root_path, @@ -2460,7 +2830,7 @@ pub async fn search_file_contents( limit, outcome.truncated ); - Ok(serialize_search_response(outcome, limit)) + Ok(serialize_search_response(outcome, limit, search_metadata)) } Err(error) => { error!( @@ -2543,6 +2913,7 @@ pub async fn start_search_filenames_stream( limit, outcome.truncated, count_search_result_groups(&outcome.results), + None, ); } Err(error) => { @@ -2590,9 +2961,14 @@ pub async fn start_search_file_contents_stream( }; let filesystem_service = state.filesystem_service.clone(); + let workspace_search_service = state.workspace_search_service.clone(); let active_searches = state.active_searches.clone(); let root_path = request.root_path.clone(); let pattern = request.pattern.clone(); + let case_sensitive = request.case_sensitive; + let use_regex = request.use_regex; + let whole_word = request.whole_word; + let use_workspace_search = should_use_workspace_search(&root_path).await; let response_search_id = search_id.clone(); let progress_search_id = search_id.clone(); let progress_app_handle = app_handle.clone(); @@ -2610,20 +2986,77 @@ pub async fn start_search_file_contents_stream( )); tokio::spawn(async move { - let result = filesystem_service - .search_file_contents_with_progress( - &root_path, - &pattern, - options, - cancel_flag, - Some(progress_sink), - ) - .await; + let result = if use_workspace_search { + let result = workspace_search_service + .search_content(bitfun_core::service::search::ContentSearchRequest { + repo_root: root_path.clone().into(), + search_path: None, + pattern: pattern.clone(), + output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + case_sensitive, + use_regex, + whole_word, + multiline: false, + before_context: 0, + after_context: 0, + max_results: Some(limit), + globs: Vec::new(), + file_types: Vec::new(), + exclude_file_types: Vec::new(), + }) + .await + .map(|result| { + let search_metadata = search_metadata_from_content_result(&result); + (result.outcome, Some(search_metadata)) + }); + + if let Ok((outcome, _)) = &result { + if !cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + for group in group_search_results(outcome.results.clone()) { + bitfun_core::infrastructure::FileSearchProgressSink::report( + progress_sink.as_ref(), + group, + ); + } + bitfun_core::infrastructure::FileSearchProgressSink::flush( + progress_sink.as_ref(), + ); + } + } + + result.map_err(|error| { + bitfun_core::util::errors::BitFunError::service(format!( + "Failed to search file contents via workspace search: {}", + error + )) + }) + } else { + filesystem_service + .search_file_contents_with_progress( + &root_path, + &pattern, + options, + cancel_flag.clone(), + Some(progress_sink), + ) + .await + .map(|outcome| (outcome, None)) + }; unregister_search_registry(&active_searches, Some(&search_id)); + if cancel_flag + .as_ref() + .is_some_and(|flag| flag.load(Ordering::Relaxed)) + { + return; + } + match result { - Ok(outcome) => { + Ok((outcome, search_metadata)) => { info!( "Content search stream completed: root_path={}, pattern={}, results_count={}, limit={}, truncated={}", root_path, @@ -2639,6 +3072,7 @@ pub async fn start_search_file_contents_stream( limit, outcome.truncated, count_search_result_groups(&outcome.results), + search_metadata, ); } Err(error) => { diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index bd776d058..b14149665 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -60,18 +60,6 @@ pub async fn set_config( .await { Ok(_) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!( - "Failed to sync global config after set_config: path={}, error={}", - request.path, e - ); - } else { - info!( - "Global config synced after set_config: path={}", - request.path - ); - } - if request.path.starts_with("ai.models") || request.path.starts_with("ai.default_models") || request.path.starts_with("ai.agent_models") @@ -103,18 +91,6 @@ pub async fn reset_config( match config_service.reset_config(request.path.as_deref()).await { Ok(_) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!( - "Failed to sync global config after reset_config: path={:?}, error={}", - request.path, e - ); - } else { - info!( - "Global config synced after reset_config: path={:?}", - request.path - ); - } - let message = if let Some(path) = &request.path { format!("Configuration '{}' reset successfully", path) } else { @@ -167,11 +143,6 @@ pub async fn import_config(state: State<'_, AppState>, config: Value) -> Result< match config_service.import_config(export_data).await { Ok(result) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!("Failed to sync global config after import_config: {}", e); - } else { - info!("Global config synced after import_config"); - } state.ai_client_factory.invalidate_cache(); info!("Config imported, AI client cache invalidated"); Ok(to_json_value(result, "import config result")?) @@ -280,18 +251,6 @@ pub async fn set_mode_config( .await { Ok(_) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!( - "Failed to reload global config after mode config change: mode_id={}, error={}", - mode_id, e - ); - } else { - info!( - "Global config reloaded after mode config change: mode_id={}", - mode_id - ); - } - Ok(format!("Mode '{}' configuration set successfully", mode_id)) } Err(e) => { @@ -315,18 +274,6 @@ pub async fn reset_mode_config( .await { Ok(_) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!( - "Failed to reload global config after mode config reset: mode_id={}, error={}", - mode_id, e - ); - } else { - info!( - "Global config reloaded after mode config reset: mode_id={}", - mode_id - ); - } - Ok(format!( "Mode '{}' configuration reset successfully", mode_id @@ -402,12 +349,6 @@ pub async fn set_subagent_config( match config_service.set_config(&path, config_value).await { Ok(_) => { - if let Err(e) = bitfun_core::service::config::reload_global_config().await { - warn!("Failed to reload global config after subagent config change: subagent_id={}, error={}", subagent_id, e); - } else { - info!("Global config reloaded after subagent config change: subagent_id={}, enabled={}", subagent_id, enabled); - } - Ok(format!( "SubAgent '{}' configuration set successfully", subagent_id diff --git a/src/apps/desktop/src/api/debug_api.rs b/src/apps/desktop/src/api/debug_api.rs new file mode 100644 index 000000000..d1a1de6ce --- /dev/null +++ b/src/apps/desktop/src/api/debug_api.rs @@ -0,0 +1,111 @@ +//! Debug API for desktop development. +//! +//! Provides element inspector, devtools control, and screenshot debugging. +//! +//! # Compilation guards +//! All public items in this module are guarded by `#[cfg(any(debug_assertions, feature = "devtools"))]`. +//! This ensures zero debug code is compiled into release builds intended for end users. + +use serde::Deserialize; + +#[cfg(any(debug_assertions, feature = "devtools"))] +use tauri::Manager; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +/// Payload sent by the injected inspector script when user clicks an element. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugElementPickedRequest { + pub tag_name: String, + pub path: String, + pub id: Option, + pub class_name: Option, + pub text_content: String, + pub outer_html: String, + pub computed_styles: serde_json::Value, + pub css_variables: serde_json::Value, + pub color_info: serde_json::Value, + pub box_model: serde_json::Value, + pub attributes: serde_json::Value, +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Called by the injected inspector script when user clicks an element. +/// +/// Logs the full element information as structured JSON so developers can +/// inspect tag, classes, computed styles, colors, box-model, etc. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_element_picked(request: DebugElementPickedRequest) -> Result<(), String> { + let payload = serde_json::json!({ + "tag_name": request.tag_name, + "path": request.path, + "id": request.id, + "class_name": request.class_name, + "text_content": request.text_content, + "outer_html_preview": request.outer_html, + "computed_styles": request.computed_styles, + "css_variables": request.css_variables, + "color_info": request.color_info, + "box_model": request.box_model, + "attributes": request.attributes, + }); + + log::info!( + target: "bitfun::devtools", + "Element picked: {}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + + Ok(()) +} + +/// Open the native webview DevTools window for the main window. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_open_devtools(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("Main window not found")?; + window.open_devtools(); + Ok(()) +} + +/// Close the native webview DevTools window for the main window. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_close_devtools(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("Main window not found")?; + window.close_devtools(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// No-op stubs for release builds (so the module always compiles) +// --------------------------------------------------------------------------- + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_element_picked(_request: DebugElementPickedRequest) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_open_devtools(_app: tauri::AppHandle) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_close_devtools(_app: tauri::AppHandle) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 692f27076..4707c3540 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -1,5 +1,6 @@ //! API layer module +pub mod acp_client_api; pub mod agentic_api; pub mod ai_memory_api; pub mod ai_rules_api; @@ -14,6 +15,7 @@ pub mod computer_use_api; pub mod config_api; pub mod context_upload_api; pub mod cron_api; +pub mod debug_api; pub mod diff_api; pub mod dto; pub mod editor_ai_api; @@ -29,6 +31,7 @@ pub mod path_target; pub mod project_context_api; pub mod remote_connect_api; pub mod runtime_api; +pub mod search_api; pub mod session_api; pub mod session_storage_path; pub mod skill_api; @@ -41,5 +44,6 @@ pub mod system_api; pub mod terminal_api; pub mod tool_api; pub mod ohos; +pub mod workspace_activation; pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace}; diff --git a/src/apps/desktop/src/api/search_api.rs b/src/apps/desktop/src/api/search_api.rs new file mode 100644 index 000000000..3659ec5c1 --- /dev/null +++ b/src/apps/desktop/src/api/search_api.rs @@ -0,0 +1,184 @@ +use crate::api::app_state::AppState; +use bitfun_core::infrastructure::{FileSearchResult, FileSearchResultGroup, SearchMatchType}; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::search::{ + workspace_search_daemon_available, workspace_search_feature_enabled, ContentSearchResult, + WorkspaceSearchBackend, WorkspaceSearchRepoPhase, +}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchRepoIndexRequest { + pub root_path: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SearchMetadataResponse { + pub backend: WorkspaceSearchBackend, + pub repo_phase: WorkspaceSearchRepoPhase, + pub rebuild_recommended: bool, + pub candidate_docs: usize, + pub matched_lines: usize, + pub matched_occurrences: usize, +} + +async fn workspace_search_unavailable_message(root_path: &str) -> Option { + if is_remote_path(root_path.trim()).await { + return Some( + "Remote workspace search status is not managed by BitFun workspace search".to_string(), + ); + } + + if !workspace_search_feature_enabled().await { + return Some( + "Workspace search is disabled. Enable it in Settings > Session Config to use accelerated workspace search.".to_string(), + ); + } + + if !workspace_search_daemon_available() { + return Some( + "Workspace search daemon is unavailable. BitFun will continue using legacy search." + .to_string(), + ); + } + + None +} + +pub(crate) async fn should_use_workspace_search(root_path: &str) -> bool { + workspace_search_unavailable_message(root_path).await.is_none() +} + +pub(crate) async fn search_file_contents_via_workspace_search( + state: &State<'_, AppState>, + root_path: &str, + pattern: &str, + case_sensitive: bool, + use_regex: bool, + whole_word: bool, + max_results: usize, +) -> Result { + state + .workspace_search_service + .search_content(bitfun_core::service::search::ContentSearchRequest { + repo_root: root_path.into(), + search_path: None, + pattern: pattern.to_string(), + output_mode: bitfun_core::service::search::ContentSearchOutputMode::Content, + case_sensitive, + use_regex, + whole_word, + multiline: false, + before_context: 0, + after_context: 0, + max_results: Some(max_results), + globs: Vec::new(), + file_types: Vec::new(), + exclude_file_types: Vec::new(), + }) + .await + .map_err(|error| { + format!( + "Failed to search file contents via workspace search: {}", + error + ) + }) +} + +pub(crate) fn group_search_results(results: Vec) -> Vec { + let mut grouped = Vec::::new(); + let mut positions = std::collections::HashMap::::new(); + + for result in results { + let path = result.path.clone(); + let position = if let Some(position) = positions.get(&path).copied() { + position + } else { + let position = grouped.len(); + positions.insert(path.clone(), position); + grouped.push(FileSearchResultGroup { + path, + name: result.name.clone(), + is_directory: result.is_directory, + file_name_match: None, + content_matches: Vec::new(), + }); + position + }; + let group = &mut grouped[position]; + + match result.match_type { + SearchMatchType::FileName => group.file_name_match = Some(result), + SearchMatchType::Content => group.content_matches.push(result), + } + } + + grouped +} + +pub(crate) fn search_metadata_from_content_result( + result: &ContentSearchResult, +) -> SearchMetadataResponse { + SearchMetadataResponse { + backend: result.backend, + repo_phase: result.repo_status.phase, + rebuild_recommended: result.repo_status.rebuild_recommended, + candidate_docs: result.candidate_docs, + matched_lines: result.matched_lines, + matched_occurrences: result.matched_occurrences, + } +} + +#[tauri::command] +pub async fn search_get_repo_status( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + return Err(message); + } + + state + .workspace_search_service + .get_index_status(&request.root_path) + .await + .map(|status| serde_json::to_value(status).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to get search repository status: {}", error)) +} + +#[tauri::command] +pub async fn search_build_index( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + return Err(message); + } + + state + .workspace_search_service + .build_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to build workspace index: {}", error)) +} + +#[tauri::command] +pub async fn search_rebuild_index( + state: State<'_, AppState>, + request: SearchRepoIndexRequest, +) -> Result { + if let Some(message) = workspace_search_unavailable_message(&request.root_path).await { + return Err(message); + } + + state + .workspace_search_service + .rebuild_index(&request.root_path) + .await + .map(|task| serde_json::to_value(task).unwrap_or_else(|_| serde_json::json!({}))) + .map_err(|error| format!("Failed to rebuild workspace index: {}", error)) +} diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index e1461d18b..6736937e6 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -2,7 +2,9 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; -use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::agentic::persistence::{ + PersistenceManager, SessionBranchRequest, SessionBranchResult, +}; use bitfun_core::infrastructure::PathManager; use bitfun_core::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, @@ -104,6 +106,19 @@ pub struct LoadPersistedSessionMetadataRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForkSessionRequest { + pub source_session_id: String, + pub source_turn_id: String, + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +pub type ForkSessionResponse = SessionBranchResult; + #[tauri::command] pub async fn list_persisted_sessions( request: ListPersistedSessionsRequest, @@ -297,3 +312,31 @@ pub async fn load_persisted_session_metadata( Ok(metadata.filter(|metadata| !metadata.should_hide_from_user_lists())) } + +#[tauri::command] +pub async fn fork_session( + request: ForkSessionRequest, + app_state: State<'_, AppState>, + path_manager: State<'_, Arc>, +) -> Result { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + + manager + .branch_session( + &workspace_path, + &SessionBranchRequest { + source_session_id: request.source_session_id, + source_turn_id: request.source_turn_id, + }, + ) + .await + .map_err(|e| format!("Failed to fork session: {}", e)) +} diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 1dbaa4563..e8b0593d4 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -132,6 +132,14 @@ pub struct GetOperationDiffRequest { pub workspace_path: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSessionFileDiffStatsRequest { + pub sessionId: String, + pub filePath: String, + #[serde(alias = "workspacePath")] + pub workspace_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GetOperationSummaryRequest { pub sessionId: String, @@ -668,6 +676,20 @@ pub async fn get_operation_diff( })) } +#[tauri::command] +pub async fn get_session_file_diff_stats( + request: GetSessionFileDiffStatsRequest, +) -> Result { + let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + + let stats = manager + .get_session_file_diff_stats(&request.sessionId, &request.filePath) + .await + .map_err(|e| format!("Failed to get session file diff stats: {}", e))?; + + serde_json::to_value(&stats).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn get_operation_summary( request: GetOperationSummaryRequest, diff --git a/src/apps/desktop/src/api/workspace_activation.rs b/src/apps/desktop/src/api/workspace_activation.rs new file mode 100644 index 000000000..43d24cae8 --- /dev/null +++ b/src/apps/desktop/src/api/workspace_activation.rs @@ -0,0 +1,120 @@ +use crate::api::app_state::AppState; +use bitfun_core::service::search::workspace_search_runtime_available; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::workspace::{WorkspaceInfo, WorkspaceKind}; +use log::{debug, info, warn}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; + +pub fn spawn_workspace_background_warmup(state: &AppState, workspace_info: WorkspaceInfo) { + let workspace_path = state.workspace_path.clone(); + let agent_registry = state.agent_registry.clone(); + let workspace_search_service = state.workspace_search_service.clone(); + + tokio::spawn(async move { + warm_workspace_background_services( + workspace_path, + agent_registry, + workspace_search_service, + workspace_info, + ) + .await; + }); +} + +async fn warm_workspace_background_services( + workspace_path: Arc>>, + agent_registry: Arc, + workspace_search_service: Arc, + workspace_info: WorkspaceInfo, +) { + let started_at = Instant::now(); + let target_path = workspace_info.root_path.clone(); + let root_str = target_path.to_string_lossy().to_string(); + let skip_local_snapshot = workspace_info.workspace_kind == WorkspaceKind::Remote + || is_remote_path(root_str.trim()).await; + + if !skip_local_snapshot && is_workspace_active(&workspace_path, &target_path).await { + let snapshot_started_at = Instant::now(); + if let Err(error) = + bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( + target_path.clone(), + None, + ) + .await + { + warn!( + "Failed to initialize snapshot system during workspace warmup: path={}, error={}", + target_path.display(), + error + ); + } else { + debug!( + "Workspace snapshot warmup completed: path={}, elapsed_ms={}", + target_path.display(), + snapshot_started_at.elapsed().as_millis() + ); + } + } + + if is_workspace_active(&workspace_path, &target_path).await { + let subagents_started_at = Instant::now(); + agent_registry.load_custom_subagents(&target_path).await; + debug!( + "Workspace custom subagent warmup completed: path={}, elapsed_ms={}", + target_path.display(), + subagents_started_at.elapsed().as_millis() + ); + } + + if workspace_info.workspace_kind != WorkspaceKind::Remote + && is_workspace_active(&workspace_path, &target_path).await + && workspace_search_runtime_available().await + { + let search_started_at = Instant::now(); + match workspace_search_service.open_repo(&target_path).await { + Ok(_) => { + let still_active = is_workspace_active(&workspace_path, &target_path).await; + if !still_active { + workspace_search_service.schedule_repo_release(target_path.clone()); + debug!( + "Released flashgrep warmup session for inactive workspace: path={}", + target_path.display() + ); + } + info!( + "Workspace search warmup completed: path={}, elapsed_ms={}, active_after_open={}", + target_path.display(), + search_started_at.elapsed().as_millis(), + still_active + ); + } + Err(error) => { + warn!( + "Failed to open workspace search repository session during warmup: path={}, error={}", + target_path.display(), + error + ); + } + } + } + + debug!( + "Workspace background warmup completed: path={}, total_elapsed_ms={}", + target_path.display(), + started_at.elapsed().as_millis() + ); +} + +async fn is_workspace_active( + workspace_path: &Arc>>, + target_path: &Path, +) -> bool { + workspace_path + .read() + .await + .as_ref() + .is_some_and(|current| current == target_path) +} diff --git a/src/apps/desktop/src/computer_use/desktop_host.rs b/src/apps/desktop/src/computer_use/desktop_host.rs index e812389d8..7fbd2a2db 100644 --- a/src/apps/desktop/src/computer_use/desktop_host.rs +++ b/src/apps/desktop/src/computer_use/desktop_host.rs @@ -2374,7 +2374,7 @@ tell application "System Events" to get unix id of first process whose frontmost #[cfg(target_os = "windows")] { let result = tokio::task::spawn_blocking(move || -> BitFunResult { - let output = std::process::Command::new("cmd") + let output = bitfun_core::util::process_manager::create_command("cmd") .args(["/c", "start", "", &name]) .output() .map_err(|e| BitFunError::tool(format!("open_app: {}", e)))?; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 014ed14af..a31d0be9b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -9,9 +9,11 @@ pub mod theme; use bitfun_core::agentic::tools::computer_use_capability::set_computer_use_desktop_available; use bitfun_core::infrastructure::ai::AIClientFactory; use bitfun_core::infrastructure::{get_path_manager_arc, try_get_path_manager_arc}; +use bitfun_core::service::search::get_global_workspace_search_service; use bitfun_core::service::workspace::get_global_workspace_service; use bitfun_core::util::{elapsed_ms, TimingCollector}; use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; +use serde::Deserialize; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -30,6 +32,7 @@ use crate::ohos::window::{ window_is_minimized, window_start_dragging }; use std::path::PathBuf; +use api::acp_client_api::*; use api::ai_rules_api::*; use api::clipboard_file_api::*; use api::commands::*; @@ -44,6 +47,7 @@ use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; use api::runtime_api::*; +use api::search_api::*; use api::session_api::*; use api::skill_api::*; use api::snapshot_service::*; @@ -52,8 +56,6 @@ use api::storage_commands::*; use api::subagent_api::*; use api::system_api::*; use api::tool_api::*; -use std::ffi::CString; -use std::ptr; /// Agentic Coordinator state #[derive(Clone)] @@ -67,11 +69,6 @@ pub struct SchedulerState { pub scheduler: Arc, } -#[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) -} - pub struct OhosPlatform { pub version: String, pub devic_type: String, @@ -169,6 +166,9 @@ pub async fn _run() { } startup_timings.record_elapsed("init_function_agents", step_started); + let workspace_search_enabled = bitfun_core::service::search::workspace_search_feature_enabled().await; + let startup_flashgrep_path = configure_workspace_search_daemon_env(); + let step_started = Instant::now(); let app_state = match AppState::new_async(token_usage_service).await { Ok(state) => state, @@ -191,7 +191,7 @@ pub async fn _run() { let path_manager = get_path_manager_arc(); - let run_result = tauri::Builder::default() + let app = tauri::Builder::default() .plugin(logging::build_log_plugin(log_targets)) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_fs::init()) @@ -217,7 +217,6 @@ pub async fn _run() { } logging::register_runtime_log_state(startup_log_level, session_log_dir.clone()); - for step in startup_timings.steps() { log::debug!( "Desktop startup step completed: step={}, duration_ms={}", @@ -226,12 +225,58 @@ pub async fn _run() { ); } + if workspace_search_enabled { + let flashgrep_path = startup_flashgrep_path.clone().or_else(|| { + let binary_names = + bitfun_core::service::search::workspace_search_daemon_binary_names(); + for binary_name in binary_names { + let primary = format!("flashgrep/{}", binary_name); + if let Ok(path) = app + .path() + .resolve(&primary, tauri::path::BaseDirectory::Resource) + { + if path.exists() { + return Some(path); + } + } + } + + if let Ok(resource_dir) = app.path().resource_dir() { + for binary_name in binary_names { + for candidate in [ + resource_dir.join("flashgrep").join(binary_name), + resource_dir.join("resources").join("flashgrep").join(binary_name), + resource_dir.join(binary_name), + ] { + if candidate.exists() { + return Some(candidate); + } + } + } + } + + None + }); + if let Some(path) = flashgrep_path { + std::env::set_var("FLASHGREP_DAEMON_BIN", &path); + log::info!( + "Workspace search daemon startup check passed: path={}", + path.display() + ); + } else { + log::warn!( + "Workspace search daemon startup check failed: {}", + bitfun_core::service::search::workspace_search_daemon_missing_hint() + ); + } + } + // Register bundled mobile-web resource path for remote connect. // tauri.conf.json maps "../../mobile-web/dist" -> "mobile-web/dist", // so the primary candidate is "mobile-web/dist". Additional fallbacks // handle legacy or non-standard bundle layouts. { - let candidates = ["mobile-web/dist","mobile-web","dist"]; + let candidates = ["mobile-web/dist", "mobile-web", "dist"]; let mut found = false; let path = PathBuf::from("/data/storage/el2/base/files/dist"); if path.join("index.html").exists() { @@ -326,6 +371,7 @@ pub async fn _run() { } init_mcp_servers(app_handle.clone()); + init_acp_clients(app_handle.clone()); init_services(app_handle.clone(), startup_log_level); @@ -335,22 +381,12 @@ pub async fn _run() { Ok(()) }) .on_window_event({ - static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); - move |window, event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if let tauri::WindowEvent::CloseRequested { .. } = event { if window.label() == "main" { - if CLEANUP_DONE - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { + if perform_process_exit_cleanup() { log::info!("Main window close requested, cleaning up"); - bitfun_core::util::process_manager::cleanup_all_processes(); - api::remote_connect_api::cleanup_on_exit(); - window.app_handle().exit(0); - } else { - api.prevent_close(); } } } @@ -376,7 +412,6 @@ pub async fn _run() { api::agentic_api::cancel_tool, api::agentic_api::generate_session_title, api::agentic_api::get_available_modes, - api::btw_api::btw_ask, api::btw_api::btw_ask_stream, api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, @@ -406,6 +441,12 @@ pub async fn _run() { fix_mermaid_code, get_app_state, update_app_status, + theme::show_agent_companion_desktop_pet, + theme::hide_agent_companion_desktop_pet, + theme::resize_agent_companion_desktop_pet, + list_agent_companion_pets, + import_agent_companion_pet_package, + delete_agent_companion_pet_package, read_file_content, write_file_content, reset_workspace_persona_files, @@ -424,6 +465,9 @@ pub async fn _run() { search_files, search_filenames, search_file_contents, + search_get_repo_status, + search_build_index, + search_rebuild_index, start_search_filenames_stream, start_search_file_contents_stream, cancel_search, @@ -523,6 +567,7 @@ pub async fn _run() { get_turn_files, get_file_diff, get_operation_diff, + get_session_file_diff_stats, get_operation_summary, get_session_operations, accept_operation, @@ -558,6 +603,7 @@ pub async fn _run() { delete_persisted_session, touch_session_activity, load_persisted_session_metadata, + fork_session, // AI Memory API api::ai_memory_api::get_all_memories, api::ai_memory_api::add_memory, @@ -601,6 +647,20 @@ pub async fn _run() { api::mcp_api::start_mcp_remote_oauth, api::mcp_api::get_mcp_remote_oauth_session, api::mcp_api::cancel_mcp_remote_oauth, + initialize_acp_clients, + get_acp_clients, + probe_acp_client_requirements, + predownload_acp_client_adapter, + install_acp_client_cli, + stop_acp_client, + load_acp_json_config, + save_acp_json_config, + submit_acp_permission_response, + create_acp_flow_session, + start_acp_dialog_turn, + cancel_acp_dialog_turn, + get_acp_session_options, + set_acp_session_model, lsp_initialize, lsp_start_server_for_file, lsp_stop_server, @@ -795,10 +855,27 @@ pub async fn _run() { close_window, set_theme_mode, + // Debug API (no-op stubs in release builds) + api::debug_api::debug_element_picked, + api::debug_api::debug_open_devtools, + api::debug_api::debug_close_devtools, ]) - .run(tauri::generate_context!()); - if let Err(e) = run_result { - log::error!("Error while running tauri application: {}", e); + .build(tauri::generate_context!()); + + match app { + Ok(app) => { + app.run(|_app_handle, event| { + if matches!( + event, + tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit + ) { + perform_process_exit_cleanup(); + } + }); + } + Err(e) => { + log::error!("Error while running tauri application: {}", e); + } } } @@ -811,6 +888,7 @@ async fn init_agentic_system() -> anyhow::Result<( Arc, )> { use bitfun_core::agentic::*; + use bitfun_core::service::config::get_global_config_service; let ai_client_factory = AIClientFactory::get_global().await?; @@ -925,6 +1003,113 @@ fn init_mcp_servers(app_handle: tauri::AppHandle) { }); } +fn init_acp_clients(app_handle: tauri::AppHandle) { + tauri::async_runtime::spawn(async move { + let state: tauri::State<'_, api::AppState> = app_handle.state(); + if let Some(service) = state.acp_client_service.as_ref() { + if let Err(error) = service.initialize_all().await { + log::warn!("Failed to initialize ACP clients: {}", error); + } + } + }); +} + +fn setup_panic_hook() { + std::panic::set_hook(Box::new(move |panic_info| { + let location = panic_info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_else(|| "unknown location".to_string()); + + let message = panic_info + .payload() + .downcast_ref::<&str>() + .copied() + .or_else(|| { + panic_info + .payload() + .downcast_ref::() + .map(String::as_str) + }) + .unwrap_or("unknown panic message"); + + log::error!("Application panic at {}: {}", location, message); + + // Known wry bug: WKWebView.URL() returns nil after navigating to an + // invalid address, causing url_from_webview to panic on unwrap(). + // This is non-fatal — the webview is still alive — so we log and + // continue instead of killing the process. + // See: https://github.com/tauri-apps/wry/pull/1554 + if location.contains("wry") && location.contains("wkwebview") { + log::warn!("Suppressed non-fatal wry/wkwebview panic, application continues"); + return; + } + + if message.contains("WSAStartup") || message.contains("10093") || message.contains("hyper") + { + log::error!("Network-related crash detected, possible solutions:"); + log::error!(" 1) Restart the application"); + log::error!(" 2) Check Windows network service status"); + log::error!(" 3) Run as administrator"); + } + + perform_process_exit_cleanup(); + std::process::exit(1); + })); +} + +fn perform_process_exit_cleanup() -> bool { + static CLEANUP_DONE: AtomicBool = AtomicBool::new(false); + + if CLEANUP_DONE + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return false; + } + + if let Some(search_service) = get_global_workspace_search_service() { + let shutdown_thread = std::thread::Builder::new() + .name("workspace-search-shutdown".to_string()) + .spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => { + runtime.block_on(async move { + search_service.shutdown_all_daemons().await; + }); + } + Err(error) => { + log::warn!( + "Failed to create runtime for workspace search shutdown: {}", + error + ); + } + } + }); + + if let Err(error) = shutdown_thread { + log::warn!( + "Failed to spawn workspace search shutdown thread: {}", + error + ); + } + } + bitfun_core::util::process_manager::cleanup_all_processes(); + api::remote_connect_api::cleanup_on_exit(); + true +} + +fn configure_workspace_search_daemon_env() -> Option { + let path = bitfun_core::service::search::resolve_workspace_search_daemon_program_path(); + if let Some(path) = path.as_ref() { + std::env::set_var("FLASHGREP_DAEMON_BIN", path); + } + path +} + fn start_event_loop_with_transport( event_queue: Arc, event_router: Arc, @@ -960,6 +1145,7 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt spawn_ingest_server_with_config_listener(); spawn_runtime_log_level_listener(default_log_level); + spawn_workspace_search_feature_listener(app_handle.clone()); tauri::async_runtime::spawn(async move { let transport = Arc::new(TauriTransportAdapter::new(app_handle.clone())); @@ -1058,6 +1244,93 @@ fn create_event_emitter( Arc::new(TransportEmitter::new(transport)) } +fn spawn_workspace_search_feature_listener(app_handle: tauri::AppHandle) { + use bitfun_core::service::config::{subscribe_config_updates, ConfigUpdateEvent}; + + let app_state: tauri::State<'_, api::AppState> = app_handle.state(); + let workspace_search_service = app_state.workspace_search_service.clone(); + let workspace_path = app_state.workspace_path.clone(); + + tauri::async_runtime::spawn(async move { + let mut feature_enabled = + bitfun_core::service::search::workspace_search_feature_enabled().await; + + let Some(mut receiver) = subscribe_config_updates() else { + log::warn!("Config update subscription unavailable for workspace search listener"); + return; + }; + + loop { + match receiver.recv().await { + Ok(ConfigUpdateEvent::AppUpdated) | Ok(ConfigUpdateEvent::ConfigReloaded) => { + let next_enabled = + bitfun_core::service::search::workspace_search_feature_enabled().await; + + if next_enabled == feature_enabled { + continue; + } + + if !next_enabled { + workspace_search_service.stop_all_daemons().await; + log::info!( + "Workspace search feature disabled; stopped flashgrep daemon and cleared sessions" + ); + feature_enabled = false; + continue; + } + + let resolved_path = configure_workspace_search_daemon_env(); + if !bitfun_core::service::search::workspace_search_daemon_available() { + log::warn!( + "Workspace search feature enabled but daemon is unavailable: path={:?}, hint={}", + resolved_path.as_ref().map(|path| path.display().to_string()), + bitfun_core::service::search::workspace_search_daemon_missing_hint() + ); + feature_enabled = true; + continue; + } + + let current_workspace = workspace_path.read().await.clone(); + if let Some(current_workspace) = current_workspace { + let workspace_str = current_workspace.to_string_lossy().to_string(); + if !bitfun_core::service::remote_ssh::workspace_state::is_remote_path( + workspace_str.trim(), + ) + .await + { + match workspace_search_service.open_repo(¤t_workspace).await { + Ok(_) => { + log::info!( + "Workspace search feature enabled; warmed current workspace: path={}", + current_workspace.display() + ); + } + Err(error) => { + log::warn!( + "Workspace search feature enabled but failed to warm current workspace: path={}, error={}", + current_workspace.display(), + error + ); + } + } + } + } + + feature_enabled = true; + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + log::warn!("Workspace search feature listener channel closed"); + break; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log::warn!("Workspace search feature listener lagged by {} messages", n); + } + } + } + }); +} + fn spawn_ingest_server_with_config_listener() { use bitfun_core::infrastructure::debug_log::IngestServerManager; use bitfun_core::service::config::{ diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 8a14383ec..f7351b433 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,9 +1,23 @@ //! Theme System +use std::sync::OnceLock; + use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use log::{debug, error, warn}; -use tauri::WebviewUrl; +use tauri::{Manager, WebviewUrl}; + +const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; +const AGENT_COMPANION_WINDOW_MIN_SIZE: f64 = 96.0; +const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; +const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; +const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; + +static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); + +fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> { + AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(())) +} #[derive(Debug, Clone)] pub struct ThemeConfig { @@ -266,17 +280,17 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { match builder.build() { Ok(window) => { - #[cfg(debug_assertions)] + #[cfg(any(debug_assertions, feature = "devtools"))] { if std::env::var("BITFUN_OPEN_DEVTOOLS") .map(|v| v == "1") .unwrap_or(false) { - window.open_devtools(); + let _ = window.open_devtools(); } } - #[cfg(not(debug_assertions))] + #[cfg(not(any(debug_assertions, feature = "devtools")))] let _ = window; } Err(e) => { @@ -285,7 +299,100 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { } } +fn app_url(path: &str) -> WebviewUrl { + if cfg!(debug_assertions) { + match format!("http://localhost:1422/{}", path).parse() { + Ok(url) => WebviewUrl::External(url), + Err(e) => { + error!("Invalid dev URL, fallback to app URL: {}", e); + WebviewUrl::App(path.into()) + } + } + } else { + let app_path = if path.starts_with('?') { + format!("index.html{}", path) + } else { + path.to_string() + }; + WebviewUrl::App(app_path.into()) + } +} + +fn agent_companion_default_position( + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, +) -> Option> { + let monitor: Option = window + .current_monitor() + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()); + + let monitor = monitor?; + + let scale_factor = monitor.scale_factor(); + let area = monitor.work_area(); + let area_position = area.position.to_logical::(scale_factor); + let area_size = area.size.to_logical::(scale_factor); + let window_size = window + .outer_size() + .ok() + .map(|size| size.to_logical::(scale_factor)); + let window_width = window_size + .as_ref() + .map(|size| size.width) + .unwrap_or(AGENT_COMPANION_WINDOW_MIN_SIZE); + let window_height = window_size + .as_ref() + .map(|size| size.height) + .unwrap_or(AGENT_COMPANION_WINDOW_MIN_SIZE); + let x = area_position.x + area_size.width + - window_width + - f64::from(AGENT_COMPANION_WINDOW_MARGIN); + let y = area_position.y + area_size.height + - window_height + - f64::from(AGENT_COMPANION_WINDOW_MARGIN); + + Some(tauri::LogicalPosition::new( + x.max(area_position.x), + y.max(area_position.y), + )) +} + +fn position_agent_companion_window(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + + warn!("Failed to position Agent companion window"); +} + +fn resize_agent_companion_window( + app: &tauri::AppHandle, + window: &tauri::WebviewWindow, + width: f64, + height: f64, +) { + warn!("Failed to position Agent companion window") +} + +#[tauri::command] +pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + Err("Failed to create Agent companion window".to_string()); +} + +#[tauri::command] +pub async fn resize_agent_companion_desktop_pet( + app: tauri::AppHandle, + width: f64, + height: f64, +) -> Result<(), String> { + Err("Main window not found".to_string()) +} + +#[tauri::command] +pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + Err("Main window not found".to_string()) +} + #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { - Ok(()) + Err("Main window not found".to_string()) } diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json new file mode 100644 index 000000000..b0f92544d --- /dev/null +++ b/src/apps/desktop/tauri.dev.conf.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "BitFun", + "identifier": "com.bitfun.desktop", + "build": { + "beforeDevCommand": "pnpm run dev:web", + "devUrl": "http://localhost:1422", + "beforeBuildCommand": "pnpm run build:web && pnpm run prepare:mobile-web", + "frontendDist": "../../../dist" + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/icon.icns", + "icons/icon.ico", + "icons/icon.png" + ], + "resources": { + "../../mobile-web/dist": "mobile-web/dist", + "resources/worker_host.js": "resources/worker_host.js" + }, + "linux": { + "deb": { + "depends": [ + "libwebkit2gtk-4.1-0", + "libgtk-3-0" + ], + "files": { + "/usr/share/icons/hicolor/16x16/apps/bitfun-desktop.png": "icons/hicolor/16x16/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/32x32/apps/bitfun-desktop.png": "icons/hicolor/32x32/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/48x48/apps/bitfun-desktop.png": "icons/hicolor/48x48/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/64x64/apps/bitfun-desktop.png": "icons/hicolor/64x64/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/96x96/apps/bitfun-desktop.png": "icons/hicolor/96x96/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/128x128/apps/bitfun-desktop.png": "icons/hicolor/128x128/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/256x256/apps/bitfun-desktop.png": "icons/hicolor/256x256/apps/bitfun-desktop.png", + "/usr/share/icons/hicolor/512x512/apps/bitfun-desktop.png": "icons/hicolor/512x512/apps/bitfun-desktop.png" + }, + "postInstallScript": "scripts/post-install-icons.sh" + }, + "appimage": { + "bundleMediaFramework": false + } + } + }, + "app": { + "windows": [], + "security": { + "csp": null + }, + "macOSPrivateApi": true, + "withGlobalTauri": true + } +} diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index 64ef282d6..2d608295b 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bitfun-relay-server" -version = "0.2.4" +version = "0.2.5" authors = ["BitFun Team"] edition = "2021" description = "BitFun Relay Server - WebSocket relay for Remote Connect" diff --git a/src/apps/server/src/bootstrap.rs b/src/apps/server/src/bootstrap.rs index c3263189a..4c5a101bf 100644 --- a/src/apps/server/src/bootstrap.rs +++ b/src/apps/server/src/bootstrap.rs @@ -83,12 +83,21 @@ pub async fn initialize(workspace: Option) -> anyhow::Result, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientConfig { + #[serde(default)] + pub name: Option, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: HashMap, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub readonly: bool, + #[serde(default)] + pub permission_mode: AcpClientPermissionMode, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientPermissionMode { + Ask, + AllowOnce, + RejectOnce, +} + +impl Default for AcpClientPermissionMode { + fn default() -> Self { + Self::Ask + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientInfo { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub enabled: bool, + pub readonly: bool, + pub permission_mode: AcpClientPermissionMode, + pub status: AcpClientStatus, + pub tool_name: String, + pub session_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientRequirementProbe { + pub id: String, + pub tool: AcpRequirementProbeItem, + #[serde(default)] + pub adapter: Option, + pub runnable: bool, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpRequirementProbeItem { + pub name: String, + pub installed: bool, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub path: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpClientStatus { + Configured, + Starting, + Running, + Stopped, + Failed, +} + +fn default_true() -> bool { + true +} diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs new file mode 100644 index 000000000..f72b09789 --- /dev/null +++ b/src/crates/acp/src/client/manager.rs @@ -0,0 +1,1629 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, ClientCapabilities, CloseSessionRequest, Implementation, + InitializeRequest, LoadSessionRequest, LoadSessionResponse, NewSessionRequest, + NewSessionResponse, PermissionOption, PermissionOptionKind, ProtocolVersion, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + ResumeSessionRequest, ResumeSessionResponse, SelectedPermissionOutcome, SessionConfigOption, + SessionConfigOptionValue, SessionModelState, SetSessionConfigOptionRequest, + SetSessionModelRequest, StopReason, +}; +use agent_client_protocol::{ + ActiveSession, Agent, ByteStreams, Client, ConnectionTo, Error, SessionMessage, +}; +use bitfun_core::agentic::tools::registry::get_global_tool_registry; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::config::ConfigService; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use dashmap::DashMap; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::process::{Child, Command}; +use tokio::sync::{oneshot, Mutex, RwLock}; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +use super::config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, + AcpClientRequirementProbe, AcpClientStatus, +}; +use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; +use super::requirements::{ + acp_requirement_spec, apply_command_environment, install_npm_cli_package, + predownload_npm_adapter, probe_executable, probe_npm_adapter, resolve_configured_command, +}; +use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::session_persistence::AcpSessionPersistence; +pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; +use super::stream::{acp_dispatch_to_stream_events, AcpClientStreamEvent, AcpStreamRoundTracker}; +use super::tool::AcpAgentTool; + +const CONFIG_PATH: &str = "acp_clients"; +const PERMISSION_TIMEOUT: Duration = Duration::from_secs(600); +const SESSION_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); +const LOAD_REPLAY_DRAIN_QUIET_WINDOW: Duration = Duration::from_millis(250); +const LOAD_REPLAY_DRAIN_MAX_DURATION: Duration = Duration::from_secs(2); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitAcpPermissionResponseRequest { + pub permission_id: String, + pub approve: bool, + #[serde(default)] + pub option_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpClientPermissionResponse { + pub permission_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetAcpSessionModelRequest { + pub client_id: String, + pub session_id: String, + #[serde(default)] + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + pub model_id: String, +} + +pub struct AcpClientService { + config_service: Arc, + session_persistence: AcpSessionPersistence, + clients: DashMap>, + pending_permissions: DashMap, + session_permission_modes: DashMap, +} + +struct PendingPermission { + sender: oneshot::Sender, + options: Vec, +} + +struct AcpClientConnection { + id: String, + client_id: String, + config: AcpClientConfig, + status: RwLock, + connection: RwLock>>, + agent_capabilities: RwLock>, + sessions: DashMap>>, + cancel_handles: DashMap, + shutdown_tx: Mutex>>, + child: Mutex>, +} + +struct AcpRemoteSession { + active: Option>, + models: Option, + config_options: Vec, + discard_pending_updates_before_next_prompt: bool, +} + +#[derive(Clone)] +struct AcpCancelHandle { + session_id: String, + connection: ConnectionTo, +} + +impl AcpRemoteSession { + fn new() -> Self { + Self { + active: None, + models: None, + config_options: Vec::new(), + discard_pending_updates_before_next_prompt: false, + } + } +} + +impl AcpClientService { + pub fn new( + config_service: Arc, + path_manager: Arc, + ) -> BitFunResult> { + Ok(Arc::new(Self { + config_service, + session_persistence: AcpSessionPersistence::new(path_manager)?, + clients: DashMap::new(), + pending_permissions: DashMap::new(), + session_permission_modes: DashMap::new(), + })) + } + + pub async fn create_flow_session_record( + &self, + session_storage_path: &Path, + workspace_path: &str, + client_id: &str, + session_name: Option, + ) -> BitFunResult { + self.session_persistence + .create_flow_session_record( + session_storage_path, + workspace_path, + client_id, + session_name, + ) + .await + } + + pub async fn initialize_all(self: &Arc) -> BitFunResult<()> { + let configs = self.load_configs().await?; + self.register_configured_tools(&configs).await; + + let configured_ids = configs + .keys() + .cloned() + .collect::>(); + let running_connections = self + .clients + .iter() + .map(|entry| (entry.key().clone(), entry.value().client_id.clone())) + .collect::>(); + for (connection_id, client_id) in running_connections { + let should_stop = !configured_ids.contains(&client_id) + || configs + .get(&client_id) + .map(|config| !config.enabled) + .unwrap_or(true); + if should_stop { + let _ = self.stop_connection(&connection_id).await; + } + } + + Ok(()) + } + + pub async fn list_clients(self: &Arc) -> BitFunResult> { + let configs = self.load_configs().await?; + let mut infos = Vec::with_capacity(configs.len()); + for (id, config) in configs { + let clients = self + .clients + .iter() + .filter(|entry| entry.value().client_id == id) + .map(|entry| entry.value().clone()) + .collect::>(); + let mut statuses = Vec::with_capacity(clients.len()); + let mut session_count = 0usize; + for client in &clients { + statuses.push(*client.status.read().await); + session_count += client.sessions.len(); + } + let status = aggregate_client_status(&statuses); + infos.push(AcpClientInfo { + tool_name: AcpAgentTool::tool_name_for(&id), + name: config.name.clone().unwrap_or_else(|| id.clone()), + command: config.command.clone(), + args: config.args.clone(), + enabled: config.enabled, + readonly: config.readonly, + permission_mode: config.permission_mode, + id, + status, + session_count, + }); + } + infos.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(infos) + } + + pub async fn probe_client_requirements( + self: &Arc, + ) -> BitFunResult> { + let configs = self.load_configs().await?; + let mut ids = configs.keys().cloned().collect::>(); + for id in ["opencode", "claude-code", "codex"] { + if !ids.iter().any(|candidate| candidate == id) { + ids.push(id.to_string()); + } + } + ids.sort(); + + let mut probes = Vec::with_capacity(ids.len()); + for id in ids { + let spec = acp_requirement_spec(&id, configs.get(&id)); + let tool = probe_executable(spec.tool_command).await; + let adapter = match spec.adapter { + Some(adapter) => Some(probe_npm_adapter(adapter.package, adapter.bin).await), + None => None, + }; + let runnable = tool.installed + && adapter + .as_ref() + .map(|adapter| adapter.installed) + .unwrap_or(true); + let mut notes = Vec::new(); + if !tool.installed { + notes.push(format!("{} is not available on PATH", spec.tool_command)); + } + if let Some(adapter) = adapter.as_ref() { + if !adapter.installed { + notes.push(format!( + "{} is not installed in npm global or offline cache", + adapter.name + )); + } + } + + debug!( + "ACP requirement probe: id={} tool_installed={} adapter_installed={} runnable={} notes={:?}", + id, + tool.installed, + adapter.as_ref().map(|adapter| adapter.installed).unwrap_or(true), + runnable, + notes + ); + + probes.push(AcpClientRequirementProbe { + id, + tool, + adapter, + runnable, + notes, + }); + } + + Ok(probes) + } + + pub async fn predownload_client_adapter(self: &Arc, client_id: &str) -> BitFunResult<()> { + let configs = self.load_configs().await?; + let spec = acp_requirement_spec(client_id, configs.get(client_id)); + let adapter = spec.adapter.ok_or_else(|| { + BitFunError::config(format!( + "ACP client '{}' does not use a downloadable adapter", + client_id + )) + })?; + + predownload_npm_adapter(adapter.package, adapter.bin).await + } + + pub async fn install_client_cli(self: &Arc, client_id: &str) -> BitFunResult<()> { + let configs = self.load_configs().await?; + let spec = acp_requirement_spec(client_id, configs.get(client_id)); + let package = spec.install_package.ok_or_else(|| { + BitFunError::config(format!( + "ACP client '{}' does not have a known CLI installer", + client_id + )) + })?; + + install_npm_cli_package(package).await + } + + pub async fn start_client_for_session( + self: &Arc, + client_id: &str, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + let connection_id = session_client_connection_id(client_id, bitfun_session_id); + self.start_client_connection(&connection_id, client_id) + .await + } + + async fn start_client_connection( + self: &Arc, + connection_id: &str, + client_id: &str, + ) -> BitFunResult<()> { + if let Some(existing) = self.clients.get(connection_id) { + let status = *existing.status.read().await; + if matches!(status, AcpClientStatus::Running | AcpClientStatus::Starting) { + return Ok(()); + } + } + + let config = self + .load_configs() + .await? + .remove(client_id) + .ok_or_else(|| BitFunError::NotFound(format!("ACP client not found: {}", client_id)))?; + + if !config.enabled { + return Err(BitFunError::config(format!( + "ACP client is disabled: {}", + client_id + ))); + } + + let connection = Arc::new(AcpClientConnection::new( + connection_id.to_string(), + client_id.to_string(), + config, + )); + self.clients + .insert(connection_id.to_string(), connection.clone()); + *connection.status.write().await = AcpClientStatus::Starting; + + let program = + resolve_configured_command(&connection.config.command, &connection.config.env); + let mut command = bitfun_core::util::process_manager::create_tokio_command(&program); + command + .args(&connection.config.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + apply_command_environment(&mut command, Some(&connection.config.env)); + configure_process_group(&mut command); + + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => { + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "Failed to spawn ACP client '{}': {}", + client_id, error + ))); + } + }; + + let stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => { + terminate_child_process_tree(connection_id, child).await; + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "ACP client '{}' stdout is unavailable", + client_id + ))); + } + }; + let stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => { + terminate_child_process_tree(connection_id, child).await; + self.clients.remove(connection_id); + *connection.status.write().await = AcpClientStatus::Failed; + return Err(BitFunError::service(format!( + "ACP client '{}' stdin is unavailable", + client_id + ))); + } + }; + + *connection.child.lock().await = Some(child); + + let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + let service = self.clone(); + let connection_for_task = connection.clone(); + let (cx_tx, cx_rx) = oneshot::channel(); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + *connection.shutdown_tx.lock().await = Some(shutdown_tx); + + tokio::spawn(async move { + let result = Client + .builder() + .name("bitfun-acp-client") + .on_receive_request( + { + let service = service.clone(); + async move |request: RequestPermissionRequest, responder, cx| { + let service = service.clone(); + cx.spawn(async move { + responder.respond_with_result( + service.handle_permission_request(request).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_with(transport, async move |cx| { + let init = InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(ClientCapabilities::new()) + .client_info(Implementation::new( + "bitfun-desktop", + env!("CARGO_PKG_VERSION"), + )); + let initialize_response = cx.send_request(init).block_task().await?; + let _ = cx_tx.send((cx, initialize_response.agent_capabilities)); + let _ = shutdown_rx.await; + Ok(()) + }) + .await; + + if let Err(error) = result { + warn!( + "ACP client connection ended with error: id={} error={:?}", + connection_for_task.id, error + ); + *connection_for_task.status.write().await = AcpClientStatus::Failed; + } else { + *connection_for_task.status.write().await = AcpClientStatus::Stopped; + } + *connection_for_task.connection.write().await = None; + *connection_for_task.agent_capabilities.write().await = None; + connection_for_task.sessions.clear(); + }); + + let (cx, agent_capabilities) = cx_rx.await.map_err(|_| { + BitFunError::service(format!( + "ACP client '{}' exited before initialization completed", + client_id + )) + })?; + *connection.connection.write().await = Some(cx); + *connection.agent_capabilities.write().await = Some(agent_capabilities); + *connection.status.write().await = AcpClientStatus::Running; + info!("ACP client started: id={}", client_id); + Ok(()) + } + + pub async fn stop_client(self: &Arc, client_id: &str) -> BitFunResult<()> { + let connection_ids = self + .clients + .iter() + .filter(|entry| entry.value().client_id == client_id) + .map(|entry| entry.key().clone()) + .collect::>(); + for connection_id in connection_ids { + self.stop_connection(&connection_id).await?; + } + Ok(()) + } + + async fn stop_connection(self: &Arc, connection_id: &str) -> BitFunResult<()> { + let Some(client) = self.clients.get(connection_id).map(|entry| entry.clone()) else { + return Ok(()); + }; + + if let Some(tx) = client.shutdown_tx.lock().await.take() { + let _ = tx.send(()); + } + if let Some(child) = client.child.lock().await.take() { + terminate_child_process_tree(connection_id, child).await; + } + *client.connection.write().await = None; + *client.agent_capabilities.write().await = None; + client.sessions.clear(); + client.cancel_handles.clear(); + *client.status.write().await = AcpClientStatus::Stopped; + self.clients.remove(connection_id); + info!( + "ACP client stopped: id={} client_id={}", + connection_id, client.client_id + ); + Ok(()) + } + + pub async fn release_bitfun_session(self: &Arc, bitfun_session_id: &str) -> bool { + let session_key_prefix = format!("{}:", bitfun_session_id); + let clients = self + .clients + .iter() + .map(|entry| entry.value().clone()) + .collect::>(); + let mut released = false; + let mut idle_client_ids = Vec::new(); + + for client in clients { + let session_keys = client + .sessions + .iter() + .filter(|entry| entry.key().starts_with(&session_key_prefix)) + .map(|entry| entry.key().clone()) + .collect::>(); + if session_keys.is_empty() { + continue; + } + + released = true; + let supports_close = client + .agent_capabilities + .read() + .await + .as_ref() + .and_then(|capabilities| capabilities.session_capabilities.close.as_ref()) + .is_some(); + + for session_key in session_keys { + let active_session_id = + if let Some((_, session)) = client.sessions.remove(&session_key) { + let mut session = session.lock().await; + let session_id = session + .active + .as_ref() + .map(|active| active.session_id().to_string()); + session.active = None; + session_id + } else { + None + }; + let cancel_handle = client + .cancel_handles + .remove(&session_key) + .map(|(_, handle)| handle); + let remote_session_id = cancel_handle + .as_ref() + .map(|handle| handle.session_id.clone()) + .or(active_session_id); + + let Some(remote_session_id) = remote_session_id else { + continue; + }; + + self.session_permission_modes.remove(&remote_session_id); + let connection = cancel_handle + .as_ref() + .map(|handle| handle.connection.clone()); + close_or_cancel_remote_session( + &client, + connection, + &remote_session_id, + supports_close, + ) + .await; + } + + if client.id != client.client_id + && client.sessions.is_empty() + && client.cancel_handles.is_empty() + { + idle_client_ids.push(client.id.clone()); + } + } + + for connection_id in idle_client_ids { + if let Err(error) = self.stop_connection(&connection_id).await { + warn!( + "Failed to stop idle ACP client after session release: id={} error={}", + connection_id, error + ); + } + } + + released + } + + pub async fn delete_flow_session_record( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + self.session_persistence + .delete_flow_session_record(session_storage_path, bitfun_session_id) + .await + } + + pub async fn load_json_config(&self) -> BitFunResult { + let config = parse_config_value(self.load_config_value().await?)?; + serde_json::to_string_pretty(&config) + .map_err(|error| BitFunError::config(format!("Failed to render ACP config: {}", error))) + } + + pub async fn save_json_config(self: &Arc, json_config: &str) -> BitFunResult<()> { + let value: serde_json::Value = serde_json::from_str(json_config).map_err(|error| { + BitFunError::config(format!("Invalid ACP client JSON config: {}", error)) + })?; + let config = parse_config_value(value)?; + let canonical_value = serde_json::to_value(config).map_err(|error| { + BitFunError::config(format!("Failed to render ACP config: {}", error)) + })?; + self.config_service + .set_config(CONFIG_PATH, canonical_value) + .await?; + self.initialize_all().await + } + + pub async fn submit_permission_response( + &self, + request: SubmitAcpPermissionResponseRequest, + ) -> BitFunResult { + let Some((_, pending)) = self.pending_permissions.remove(&request.permission_id) else { + return Err(BitFunError::NotFound(format!( + "ACP permission request not found: {}", + request.permission_id + ))); + }; + + let option_id = request + .option_id + .unwrap_or_else(|| select_permission_option_id(&pending.options, request.approve)); + let response = RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )); + let _ = pending.sender.send(response); + Ok(AcpClientPermissionResponse { + permission_id: request.permission_id, + resolved: true, + }) + } + + pub async fn get_session_options( + self: &Arc, + client_id: &str, + workspace_path: Option, + session_storage_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )) + } + + pub async fn set_session_model( + self: &Arc, + request: SetAcpSessionModelRequest, + session_storage_path: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session( + &request.client_id, + request.workspace_path, + Some(&request.session_id), + ) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + Some(&request.session_id), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + let active = session + .active + .as_ref() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + let remote_session_id = active.session_id().to_string(); + let connection = active.connection(); + + let mut set_model_error = None; + if session.models.is_some() { + match connection + .send_request(SetSessionModelRequest::new( + remote_session_id.clone(), + request.model_id.clone(), + )) + .block_task() + .await + .map_err(protocol_error) + { + Ok(_) => { + if let Some(models) = session.models.as_mut() { + models.current_model_id = request.model_id.clone().into(); + } + if let Some(session_storage_path) = session_storage_path.as_deref() { + self.session_persistence + .update_model_id( + session_storage_path, + &request.session_id, + &request.model_id, + ) + .await?; + } + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + Err(error) => { + set_model_error = Some(error); + } + } + } + + if let Some(config_id) = model_config_id(&session.config_options) { + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + remote_session_id, + config_id, + SessionConfigOptionValue::value_id(request.model_id.clone()), + )) + .block_task() + .await + .map_err(protocol_error)?; + session.config_options = response.config_options; + if let Some(session_storage_path) = session_storage_path.as_deref() { + self.session_persistence + .update_model_id(session_storage_path, &request.session_id, &request.model_id) + .await?; + } + return Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + )); + } + + if let Some(error) = set_model_error { + return Err(error); + } + Err(BitFunError::NotFound( + "ACP session does not expose selectable models".to_string(), + )) + } + + pub async fn prompt_agent( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + session_storage_path: Option, + timeout_seconds: Option, + ) -> BitFunResult { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + + discard_pending_session_updates_if_needed(&mut session).await; + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + active.read_to_string().await.map_err(protocol_error) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn prompt_agent_stream( + self: &Arc, + client_id: &str, + prompt: String, + workspace_path: Option, + bitfun_session_id: Option, + session_storage_path: Option, + timeout_seconds: Option, + mut on_event: F, + ) -> BitFunResult<()> + where + F: FnMut(AcpClientStreamEvent) -> BitFunResult<()> + Send, + { + let (client, cwd, session_key) = self + .resolve_client_session(client_id, workspace_path, bitfun_session_id.as_deref()) + .await?; + let session = client + .sessions + .entry(session_key.clone()) + .or_insert_with(|| Arc::new(Mutex::new(AcpRemoteSession::new()))) + .clone(); + + let run = async { + let mut session = session.lock().await; + self.ensure_remote_session( + &client, + &session_key, + &cwd, + bitfun_session_id.as_deref(), + session_storage_path.as_deref(), + &mut session, + ) + .await?; + + discard_pending_session_updates_if_needed(&mut session).await; + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + let mut round_tracker = AcpStreamRoundTracker::new(); + + loop { + match active.read_update().await.map_err(protocol_error)? { + SessionMessage::SessionMessage(dispatch) => { + for event in acp_dispatch_to_stream_events(dispatch).await? { + for event in round_tracker.apply(event) { + on_event(event)?; + } + } + } + SessionMessage::StopReason(stop_reason) => { + let event = if matches!(stop_reason, StopReason::Cancelled) { + AcpClientStreamEvent::Cancelled + } else { + AcpClientStreamEvent::Completed + }; + on_event(event)?; + break; + } + _ => {} + } + } + Ok(()) + }; + + if let Some(seconds) = timeout_seconds.filter(|seconds| *seconds > 0) { + tokio::time::timeout(Duration::from_secs(seconds), run) + .await + .map_err(|_| { + BitFunError::tool(format!("ACP client timed out after {}s", seconds)) + })? + } else { + run.await + } + } + + pub async fn cancel_agent_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option, + ) -> BitFunResult<()> { + let connection_id = bitfun_session_id + .as_deref() + .map(|session_id| session_client_connection_id(client_id, session_id)) + .unwrap_or_else(|| client_id.to_string()); + let client = self + .clients + .get(&connection_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id.as_deref(), client_id, &cwd); + let handle = client.cancel_handles.get(&session_key).ok_or_else(|| { + BitFunError::NotFound(format!( + "ACP session is not active for client '{}' in workspace '{}'", + client_id, + cwd.display() + )) + })?; + + handle + .connection + .send_notification(CancelNotification::new(handle.session_id.clone())) + .map_err(protocol_error)?; + Ok(()) + } + + pub async fn cancel_bitfun_session( + self: &Arc, + bitfun_session_id: &str, + ) -> BitFunResult { + let session_key_prefix = format!("{}:", bitfun_session_id); + for client in self.clients.iter().map(|entry| entry.value().clone()) { + let handle = client + .cancel_handles + .iter() + .find(|entry| entry.key().starts_with(&session_key_prefix)) + .map(|entry| entry.value().clone()); + + if let Some(handle) = handle { + handle + .connection + .send_notification(CancelNotification::new(handle.session_id.clone())) + .map_err(protocol_error)?; + return Ok(true); + } + } + + Ok(false) + } + + async fn resolve_client_session( + self: &Arc, + client_id: &str, + workspace_path: Option, + bitfun_session_id: Option<&str>, + ) -> BitFunResult<(Arc, PathBuf, String)> { + let connection_id = bitfun_session_id + .map(|session_id| session_client_connection_id(client_id, session_id)) + .unwrap_or_else(|| client_id.to_string()); + self.start_client_connection(&connection_id, client_id) + .await?; + let client = self + .clients + .get(&connection_id) + .map(|entry| entry.clone()) + .ok_or_else(|| { + BitFunError::service(format!("ACP client is not running: {}", client_id)) + })?; + + let cwd = workspace_path + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| BitFunError::validation("Workspace path is required".to_string()))?; + let session_key = build_session_key(bitfun_session_id, client_id, &cwd); + Ok((client, cwd, session_key)) + } + + async fn ensure_remote_session( + &self, + client: &Arc, + session_key: &str, + cwd: &Path, + bitfun_session_id: Option<&str>, + session_storage_path: Option<&Path>, + session: &mut AcpRemoteSession, + ) -> BitFunResult<()> { + if session.active.is_some() { + return Ok(()); + } + + let cx = client.connection().await?; + let persisted_remote_session_id = + if let (Some(session_storage_path), Some(bitfun_session_id)) = + (session_storage_path, bitfun_session_id) + { + self.session_persistence + .load_remote_session_id(session_storage_path, bitfun_session_id) + .await? + } else { + None + }; + let capabilities = client.agent_capabilities.read().await.clone(); + let mut last_resume_error: Option = None; + + for strategy in preferred_resume_strategies( + capabilities.as_ref(), + persisted_remote_session_id.as_deref(), + ) { + let response = match strategy { + AcpRemoteSessionStrategy::Load => { + let Some(remote_session_id) = persisted_remote_session_id.as_deref() else { + continue; + }; + match cx + .send_request(LoadSessionRequest::new(remote_session_id.to_string(), cwd)) + .block_task() + .await + .map_err(protocol_error) + { + Ok(response) => new_session_response_from_load(remote_session_id, response), + Err(error) => { + warn!( + "Failed to load ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", + client.id, remote_session_id, error + ); + last_resume_error = Some(error.to_string()); + continue; + } + } + } + AcpRemoteSessionStrategy::Resume => { + let Some(remote_session_id) = persisted_remote_session_id.as_deref() else { + continue; + }; + match cx + .send_request(ResumeSessionRequest::new( + remote_session_id.to_string(), + cwd, + )) + .block_task() + .await + .map_err(protocol_error) + { + Ok(response) => { + new_session_response_from_resume(remote_session_id, response) + } + Err(error) => { + warn!( + "Failed to resume ACP remote session, falling back: client_id={}, remote_session_id={}, error={}", + client.id, remote_session_id, error + ); + last_resume_error = Some(error.to_string()); + continue; + } + } + } + AcpRemoteSessionStrategy::New => cx + .send_request(NewSessionRequest::new(cwd)) + .block_task() + .await + .map_err(protocol_error)?, + }; + + self.attach_remote_session( + client, + session_key, + bitfun_session_id, + session_storage_path, + session, + response, + strategy, + last_resume_error.clone(), + ) + .await?; + return Ok(()); + } + + Err(BitFunError::service( + "Failed to initialize ACP remote session".to_string(), + )) + } + + async fn attach_remote_session( + &self, + client: &Arc, + session_key: &str, + bitfun_session_id: Option<&str>, + session_storage_path: Option<&Path>, + session: &mut AcpRemoteSession, + response: NewSessionResponse, + strategy: AcpRemoteSessionStrategy, + last_resume_error: Option, + ) -> BitFunResult<()> { + let cx = client.connection().await?; + let models = response.models.clone(); + let config_options = response.config_options.clone().unwrap_or_default(); + let active = cx + .attach_session(response, Vec::new()) + .map_err(protocol_error)?; + let remote_session_id = active.session_id().to_string(); + client.cancel_handles.insert( + session_key.to_string(), + AcpCancelHandle { + session_id: remote_session_id.clone(), + connection: active.connection(), + }, + ); + self.session_permission_modes + .insert(remote_session_id.clone(), client.config.permission_mode); + if let (Some(session_storage_path), Some(bitfun_session_id)) = + (session_storage_path, bitfun_session_id) + { + self.session_persistence + .update_remote_session_state( + session_storage_path, + bitfun_session_id, + &remote_session_id, + strategy.as_str(), + last_resume_error, + ) + .await?; + } + session.models = models; + session.config_options = config_options; + session.discard_pending_updates_before_next_prompt = + matches!(strategy, AcpRemoteSessionStrategy::Load); + session.active = Some(active); + Ok(()) + } + + async fn load_configs(&self) -> BitFunResult> { + Ok(parse_config_value(self.load_config_value().await?)?.acp_clients) + } + + async fn load_config_value(&self) -> BitFunResult { + Ok(self + .config_service + .get_config::(Some(CONFIG_PATH)) + .await + .unwrap_or_else(|_| json!({ "acpClients": {} }))) + } + + async fn register_configured_tools( + self: &Arc, + configs: &HashMap, + ) { + let registry = get_global_tool_registry(); + let mut registry = registry.write().await; + registry.unregister_tools_by_prefix("acp__"); + + let tools = configs + .iter() + .filter(|(_, config)| config.enabled) + .map(|(id, config)| { + Arc::new(AcpAgentTool::new(id.clone(), config.clone(), self.clone())) + as Arc + }) + .collect::>(); + + for tool in tools { + debug!("Registering ACP client tool: name={}", tool.name()); + registry.register_tool(tool); + } + } + + async fn handle_permission_request( + self: Arc, + request: RequestPermissionRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let permission_mode = self.permission_mode_for_session(&session_id); + match permission_mode { + AcpClientPermissionMode::AllowOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::AllowOnce, + true, + )); + } + AcpClientPermissionMode::RejectOnce => { + return Ok(select_permission_by_kind( + &request, + PermissionOptionKind::RejectOnce, + false, + )); + } + AcpClientPermissionMode::Ask => {} + } + + let permission_id = format!("acp_permission_{}", uuid::Uuid::new_v4()); + let (tx, rx) = oneshot::channel(); + self.pending_permissions.insert( + permission_id.clone(), + PendingPermission { + sender: tx, + options: request.options.clone(), + }, + ); + + let payload = json!({ + "permissionId": permission_id, + "sessionId": session_id, + "toolCall": request.tool_call, + "options": request.options, + }); + + if let Err(error) = emit_global_event(BackendEvent::Custom { + event_name: "backend-event-acppermissionrequest".to_string(), + payload, + }) + .await + { + warn!("Failed to emit ACP permission request: {}", error); + } + + match tokio::time::timeout(PERMISSION_TIMEOUT, rx).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )), + Err(_) => { + self.pending_permissions.remove(&permission_id); + Ok(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )) + } + } + } + + fn permission_mode_for_session(&self, session_id: &str) -> AcpClientPermissionMode { + self.session_permission_modes + .get(session_id) + .map(|entry| *entry.value()) + .unwrap_or(AcpClientPermissionMode::Ask) + } +} + +impl AcpClientConnection { + fn new(id: String, client_id: String, config: AcpClientConfig) -> Self { + Self { + id, + client_id, + config, + status: RwLock::new(AcpClientStatus::Configured), + connection: RwLock::new(None), + agent_capabilities: RwLock::new(None), + sessions: DashMap::new(), + cancel_handles: DashMap::new(), + shutdown_tx: Mutex::new(None), + child: Mutex::new(None), + } + } + + async fn connection(&self) -> BitFunResult> { + self.connection.read().await.clone().ok_or_else(|| { + BitFunError::service(format!("ACP client is not connected: {}", self.id)) + }) + } +} + +fn parse_config_value(value: serde_json::Value) -> BitFunResult { + if value.get("acpClients").is_some() { + serde_json::from_value(value) + .map_err(|error| BitFunError::config(format!("Invalid ACP client config: {}", error))) + } else if value.is_object() { + serde_json::from_value(json!({ "acpClients": value })).map_err(|error| { + BitFunError::config(format!("Invalid ACP client config map: {}", error)) + }) + } else { + Err(BitFunError::config( + "ACP client config must be an object".to_string(), + )) + } +} + +fn build_session_key(bitfun_session_id: Option<&str>, client_id: &str, cwd: &Path) -> String { + format!( + "{}:{}:{}", + bitfun_session_id.unwrap_or("standalone"), + client_id, + cwd.to_string_lossy() + ) +} + +fn session_client_connection_id(client_id: &str, bitfun_session_id: &str) -> String { + format!("{}::session::{}", client_id, bitfun_session_id) +} + +fn aggregate_client_status(statuses: &[AcpClientStatus]) -> AcpClientStatus { + if statuses.is_empty() { + return AcpClientStatus::Configured; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Running)) + { + return AcpClientStatus::Running; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Starting)) + { + return AcpClientStatus::Starting; + } + if statuses + .iter() + .any(|status| matches!(status, AcpClientStatus::Failed)) + { + return AcpClientStatus::Failed; + } + AcpClientStatus::Stopped +} + +fn configure_process_group(command: &mut Command) { + #[cfg(unix)] + { + command.process_group(0); + } +} + +async fn terminate_child_process_tree(client_id: &str, mut child: Child) { + let pid = child.id(); + + #[cfg(unix)] + if let Some(pid) = pid { + let process_group = format!("-{}", pid); + match bitfun_core::util::process_manager::create_tokio_command("kill") + .arg("-TERM") + .arg(&process_group) + .status() + .await + { + Ok(status) if status.success() => {} + Ok(status) => { + warn!( + "ACP client process group terminate exited unsuccessfully: id={} pid={} status={}", + client_id, pid, status + ); + } + Err(error) => { + warn!( + "Failed to terminate ACP client process group: id={} pid={} error={}", + client_id, pid, error + ); + } + } + + match tokio::time::timeout(Duration::from_millis(750), child.wait()).await { + Ok(Ok(_)) => return, + Ok(Err(error)) => { + warn!( + "Failed to wait for ACP client process after terminate: id={} pid={} error={}", + client_id, pid, error + ); + } + Err(_) => {} + } + + if let Err(error) = bitfun_core::util::process_manager::create_tokio_command("kill") + .arg("-KILL") + .arg(&process_group) + .status() + .await + { + warn!( + "Failed to kill ACP client process group: id={} pid={} error={}", + client_id, pid, error + ); + } + let _ = child.wait().await; + return; + } + + #[cfg(windows)] + if let Some(pid) = pid { + match bitfun_core::util::process_manager::create_tokio_command("taskkill") + .arg("/PID") + .arg(pid.to_string()) + .arg("/T") + .arg("/F") + .status() + .await + { + Ok(status) if status.success() => { + let _ = child.wait().await; + return; + } + Ok(status) => { + warn!( + "ACP client process tree kill exited unsuccessfully: id={} pid={} status={}", + client_id, pid, status + ); + } + Err(error) => { + warn!( + "Failed to kill ACP client process tree: id={} pid={} error={}", + client_id, pid, error + ); + } + } + } + + if let Err(error) = child.start_kill() { + warn!( + "Failed to kill ACP client process: id={} error={}", + client_id, error + ); + } + let _ = child.wait().await; +} + +async fn close_or_cancel_remote_session( + client: &AcpClientConnection, + connection: Option>, + remote_session_id: &str, + supports_close: bool, +) { + let connection = match connection { + Some(connection) => connection, + None => match client.connection().await { + Ok(connection) => connection, + Err(error) => { + warn!( + "Failed to release ACP session because client is disconnected: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + return; + } + }, + }; + + if supports_close { + let close = connection + .send_request(CloseSessionRequest::new(remote_session_id.to_string())) + .block_task(); + match tokio::time::timeout(SESSION_CLOSE_TIMEOUT, close).await { + Ok(Ok(_)) => { + debug!( + "ACP remote session closed: client_id={} remote_session_id={}", + client.id, remote_session_id + ); + } + Ok(Err(error)) => { + warn!( + "Failed to close ACP remote session: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + } + Err(_) => { + warn!( + "Timed out closing ACP remote session: client_id={} remote_session_id={} timeout_ms={}", + client.id, + remote_session_id, + SESSION_CLOSE_TIMEOUT.as_millis() + ); + } + } + } else if let Err(error) = connection + .send_notification(CancelNotification::new(remote_session_id.to_string())) + .map_err(protocol_error) + { + warn!( + "Failed to cancel ACP remote session during release: client_id={} remote_session_id={} error={}", + client.id, remote_session_id, error + ); + } +} + +fn new_session_response_from_load( + remote_session_id: &str, + response: LoadSessionResponse, +) -> NewSessionResponse { + NewSessionResponse::new(remote_session_id.to_string()) + .modes(response.modes) + .models(response.models) + .config_options(response.config_options) + .meta(response.meta) +} + +fn new_session_response_from_resume( + remote_session_id: &str, + response: ResumeSessionResponse, +) -> NewSessionResponse { + NewSessionResponse::new(remote_session_id.to_string()) + .modes(response.modes) + .models(response.models) + .config_options(response.config_options) + .meta(response.meta) +} + +async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSession) { + if !session.discard_pending_updates_before_next_prompt { + return; + } + + session.discard_pending_updates_before_next_prompt = false; + let Some(active) = session.active.as_mut() else { + return; + }; + + let started_at = Instant::now(); + let mut discarded_count = 0usize; + while started_at.elapsed() < LOAD_REPLAY_DRAIN_MAX_DURATION { + match tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await { + Ok(Ok(_)) => { + discarded_count += 1; + } + Ok(Err(error)) => { + warn!( + "Failed to discard ACP load replay update before prompt: error={}", + error + ); + break; + } + Err(_) => break, + } + } + + if discarded_count > 0 { + info!( + "Discarded ACP load replay updates before prompt: count={}", + discarded_count + ); + } +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} + +fn select_permission_by_kind( + request: &RequestPermissionRequest, + preferred: PermissionOptionKind, + approve: bool, +) -> RequestPermissionResponse { + let fallback_kind = if approve { + PermissionOptionKind::AllowAlways + } else { + PermissionOptionKind::RejectAlways + }; + let option_id = request + .options + .iter() + .find(|option| option.kind == preferred) + .or_else(|| { + request + .options + .iter() + .find(|option| option.kind == fallback_kind) + }) + .map(|option| option.option_id.to_string()) + .unwrap_or_else(|| select_permission_option_id(&request.options, approve)); + RequestPermissionResponse::new(RequestPermissionOutcome::Selected( + SelectedPermissionOutcome::new(option_id), + )) +} + +fn select_permission_option_id(options: &[PermissionOption], approve: bool) -> String { + let preferred_kinds = if approve { + [ + PermissionOptionKind::AllowOnce, + PermissionOptionKind::AllowAlways, + ] + } else { + [ + PermissionOptionKind::RejectOnce, + PermissionOptionKind::RejectAlways, + ] + }; + + options + .iter() + .find(|option| preferred_kinds.contains(&option.kind)) + .or_else(|| options.first()) + .map(|option| option.option_id.to_string()) + .unwrap_or_else(|| { + if approve { + "allow_once".to_string() + } else { + "reject_once".to_string() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selects_actual_permission_option_id_for_approval() { + let options = vec![ + PermissionOption::new("deny", "Deny", PermissionOptionKind::RejectOnce), + PermissionOption::new("yes-once", "Allow", PermissionOptionKind::AllowOnce), + ]; + + assert_eq!(select_permission_option_id(&options, true), "yes-once"); + } + + #[test] + fn selects_actual_permission_option_id_for_rejection() { + let options = vec![ + PermissionOption::new("allow-always", "Allow", PermissionOptionKind::AllowAlways), + PermissionOption::new("no-once", "Reject", PermissionOptionKind::RejectOnce), + ]; + + assert_eq!(select_permission_option_id(&options, false), "no-once"); + } +} diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs new file mode 100644 index 000000000..7c61197d1 --- /dev/null +++ b/src/crates/acp/src/client/mod.rs @@ -0,0 +1,20 @@ +mod config; +mod manager; +mod remote_session; +mod requirements; +mod session_options; +mod session_persistence; +mod stream; +mod tool; +mod tool_card_bridge; + +pub use config::{ + AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, + AcpClientRequirementProbe, AcpClientStatus, AcpRequirementProbeItem, +}; +pub use manager::{ + AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, + SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, +}; +pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/remote_session.rs b/src/crates/acp/src/client/remote_session.rs new file mode 100644 index 000000000..ba90f4e24 --- /dev/null +++ b/src/crates/acp/src/client/remote_session.rs @@ -0,0 +1,100 @@ +use agent_client_protocol::schema::AgentCapabilities; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AcpRemoteSessionStrategy { + New, + Load, + Resume, +} + +impl AcpRemoteSessionStrategy { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::New => "new", + Self::Load => "load", + Self::Resume => "resume", + } + } +} + +pub(super) fn preferred_resume_strategies( + capabilities: Option<&AgentCapabilities>, + remote_session_id: Option<&str>, +) -> Vec { + let mut strategies = Vec::new(); + let has_remote_session_id = remote_session_id + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + + if has_remote_session_id { + // Prefer loading saved session state over resuming a live stream. Some + // ACP clients continue an unfinished prompt on resume, and ACP update + // notifications are only scoped to the remote session, not a BitFun turn. + if capabilities + .map(|capabilities| capabilities.load_session) + .unwrap_or(false) + { + strategies.push(AcpRemoteSessionStrategy::Load); + } + + if capabilities + .and_then(|capabilities| capabilities.session_capabilities.resume.as_ref()) + .is_some() + { + strategies.push(AcpRemoteSessionStrategy::Resume); + } + } + + strategies.push(AcpRemoteSessionStrategy::New); + strategies +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn falls_back_to_new_without_remote_session_id() { + assert_eq!( + preferred_resume_strategies(Some(&AgentCapabilities::new().load_session(true)), None), + vec![AcpRemoteSessionStrategy::New] + ); + } + + #[test] + fn prefers_load_when_resume_is_not_supported() { + assert_eq!( + preferred_resume_strategies( + Some(&AgentCapabilities::new().load_session(true)), + Some("s1") + ), + vec![ + AcpRemoteSessionStrategy::Load, + AcpRemoteSessionStrategy::New + ] + ); + } + + #[test] + fn prefers_load_before_resume_when_both_are_supported() { + assert_eq!( + preferred_resume_strategies( + Some( + &AgentCapabilities::new() + .load_session(true) + .session_capabilities( + agent_client_protocol::schema::SessionCapabilities::new().resume( + agent_client_protocol::schema::SessionResumeCapabilities::new(), + ), + ), + ), + Some("s1") + ), + vec![ + AcpRemoteSessionStrategy::Load, + AcpRemoteSessionStrategy::Resume, + AcpRemoteSessionStrategy::New + ] + ); + } +} diff --git a/src/crates/acp/src/client/requirements.rs b/src/crates/acp/src/client/requirements.rs new file mode 100644 index 000000000..075a177ae --- /dev/null +++ b/src/crates/acp/src/client/requirements.rs @@ -0,0 +1,479 @@ +use std::collections::{HashMap, HashSet}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use tokio::process::Command; + +use super::config::{AcpClientConfig, AcpRequirementProbeItem}; + +const REQUIREMENT_PROBE_TIMEOUT: Duration = Duration::from_secs(3); +const ADAPTER_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120); +const CLI_INSTALL_TIMEOUT: Duration = Duration::from_secs(600); + +pub(crate) struct AcpRequirementSpec<'a> { + pub(crate) tool_command: &'a str, + pub(crate) install_package: Option<&'a str>, + pub(crate) adapter: Option>, +} + +pub(crate) struct AcpAdapterSpec<'a> { + pub(crate) package: &'a str, + pub(crate) bin: &'a str, +} + +pub(crate) fn acp_requirement_spec<'a>( + client_id: &'a str, + config: Option<&'a AcpClientConfig>, +) -> AcpRequirementSpec<'a> { + match client_id { + "claude-code" => AcpRequirementSpec { + tool_command: "claude", + install_package: Some("@anthropic-ai/claude-code"), + adapter: Some(AcpAdapterSpec { + package: "@zed-industries/claude-code-acp", + bin: "claude-code-acp", + }), + }, + "codex" => AcpRequirementSpec { + tool_command: "codex", + install_package: Some("@openai/codex"), + adapter: Some(AcpAdapterSpec { + package: "@zed-industries/codex-acp", + bin: "codex-acp", + }), + }, + "opencode" => AcpRequirementSpec { + tool_command: "opencode", + install_package: Some("opencode-ai"), + adapter: None, + }, + _ => AcpRequirementSpec { + tool_command: config + .map(|config| config.command.as_str()) + .unwrap_or(client_id), + install_package: None, + adapter: None, + }, + } +} + +pub(crate) async fn probe_executable(command: &str) -> AcpRequirementProbeItem { + let path = find_executable(command); + let mut item = AcpRequirementProbeItem { + name: command.to_string(), + installed: path.is_some(), + version: None, + path: path.as_ref().map(|path| path.to_string_lossy().to_string()), + error: None, + }; + + if let Some(path) = path { + match run_command_with_timeout(path.as_os_str(), ["--version"], REQUIREMENT_PROBE_TIMEOUT) + .await + { + Ok(output) if output.status.success() => { + item.version = parse_version_text(&output.stdout) + .or_else(|| parse_version_text(&output.stderr)); + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + } + + item +} + +pub(crate) async fn probe_npm_adapter(package: &str, bin: &str) -> AcpRequirementProbeItem { + let npm_path = find_executable("npm"); + let mut item = AcpRequirementProbeItem { + name: package.to_string(), + installed: false, + version: None, + path: None, + error: None, + }; + let Some(npm_path) = npm_path else { + item.error = Some("npm is not available on PATH".to_string()); + return item; + }; + + let global_args = ["ls", "-g", "--json", "--depth=0", package]; + match run_command_with_timeout(npm_path.as_os_str(), global_args, REQUIREMENT_PROBE_TIMEOUT) + .await + { + Ok(output) if output.status.success() => { + if let Some(version) = npm_ls_package_version(&output.stdout, package) { + item.installed = true; + item.version = Some(version); + item.path = Some("npm global".to_string()); + return item; + } + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + + let offline_args = vec![ + "exec".to_string(), + "--offline".to_string(), + "--yes".to_string(), + format!("--package={package}"), + "--".to_string(), + bin.to_string(), + "--help".to_string(), + ]; + match run_command_with_timeout( + npm_path.as_os_str(), + offline_args.iter().map(String::as_str), + REQUIREMENT_PROBE_TIMEOUT, + ) + .await + { + Ok(output) if output.status.success() => { + item.installed = true; + item.path = Some("npm offline cache".to_string()); + item.error = None; + } + Ok(output) => { + item.error = Some(command_error_summary(&output.stderr, &output.stdout)); + } + Err(error) => { + item.error = Some(error); + } + } + + if find_executable("npx").is_some() { + item.installed = true; + item.path = Some("npx auto-install".to_string()); + item.error = None; + } + + item +} + +pub(crate) async fn predownload_npm_adapter(package: &str, bin: &str) -> BitFunResult<()> { + let npm_path = find_executable("npm") + .ok_or_else(|| BitFunError::service("npm is not available on PATH".to_string()))?; + let args = vec![ + "exec".to_string(), + "--yes".to_string(), + format!("--package={package}"), + "--".to_string(), + bin.to_string(), + "--help".to_string(), + ]; + + match run_command_with_timeout( + npm_path.as_os_str(), + args.iter().map(String::as_str), + ADAPTER_DOWNLOAD_TIMEOUT, + ) + .await + { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(BitFunError::service(format!( + "Failed to predownload ACP adapter '{}': {}", + package, + command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to predownload ACP adapter '{}': {}", + package, error + ))), + } +} + +pub(crate) async fn install_npm_cli_package(package: &str) -> BitFunResult<()> { + let npm_path = find_executable("npm") + .ok_or_else(|| BitFunError::service("npm is not available on PATH".to_string()))?; + let args = ["install", "-g", package]; + + match run_command_with_timeout(npm_path.as_os_str(), args, CLI_INSTALL_TIMEOUT).await { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => Err(BitFunError::service(format!( + "Failed to install ACP agent CLI '{}': {}", + package, + command_error_summary(&output.stderr, &output.stdout) + ))), + Err(error) => Err(BitFunError::service(format!( + "Failed to install ACP agent CLI '{}': {}", + package, error + ))), + } +} + +pub(crate) fn resolve_configured_command( + command: &str, + extra_env: &HashMap, +) -> PathBuf { + let configured_path = configured_path_value(extra_env); + find_executable_with_path(command, configured_path.as_deref()) + .unwrap_or_else(|| PathBuf::from(command)) +} + +pub(crate) fn apply_command_environment( + command: &mut Command, + extra_env: Option<&HashMap>, +) { + let configured_path = extra_env.and_then(configured_path_value); + let search_path = joined_command_search_path(configured_path.as_deref()); + if !search_path.is_empty() { + command.env("PATH", search_path); + } + + if let Some(extra_env) = extra_env { + for (key, value) in extra_env { + if !key.eq_ignore_ascii_case("PATH") { + command.env(key, value); + } + } + } +} + +async fn run_command_with_timeout( + program: &OsStr, + args: I, + timeout: Duration, +) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let mut command = bitfun_core::util::process_manager::create_tokio_command(program); + command.args(args); + apply_command_environment(&mut command, None); + match tokio::time::timeout(timeout, command.output()).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("Timed out while checking command".to_string()), + } +} + +fn npm_ls_package_version(stdout: &[u8], package: &str) -> Option { + let value: serde_json::Value = serde_json::from_slice(stdout).ok()?; + value + .get("dependencies")? + .get(package)? + .get("version")? + .as_str() + .map(ToString::to_string) +} + +fn parse_version_text(output: &[u8]) -> Option { + let text = String::from_utf8_lossy(output); + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(ToString::to_string) +} + +fn command_error_summary(stderr: &[u8], stdout: &[u8]) -> String { + let stderr = String::from_utf8_lossy(stderr).trim().to_string(); + if !stderr.is_empty() { + return truncate_error(stderr); + } + let stdout = String::from_utf8_lossy(stdout).trim().to_string(); + if !stdout.is_empty() { + return truncate_error(stdout); + } + "Command exited unsuccessfully".to_string() +} + +fn truncate_error(value: String) -> String { + const MAX_LEN: usize = 240; + if value.chars().count() <= MAX_LEN { + return value; + } + format!("{}...", value.chars().take(MAX_LEN).collect::()) +} + +fn find_executable(command: &str) -> Option { + find_executable_with_path(command, None) +} + +fn find_executable_with_path(command: &str, configured_path: Option<&OsStr>) -> Option { + let command_path = PathBuf::from(command); + if command_path.components().count() > 1 { + return executable_file(&command_path).then_some(command_path); + } + + for directory in command_search_paths(configured_path) { + for candidate in executable_candidates(&directory, command) { + if executable_file(&candidate) { + return Some(candidate); + } + } + } + None +} + +fn configured_path_value(extra_env: &HashMap) -> Option { + extra_env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| OsString::from(value)) +} + +fn joined_command_search_path(configured_path: Option<&OsStr>) -> OsString { + let paths = command_search_paths(configured_path); + if paths.is_empty() { + return OsString::new(); + } + env::join_paths(paths).unwrap_or_else(|_| env::var_os("PATH").unwrap_or_default()) +} + +fn command_search_paths(configured_path: Option<&OsStr>) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + + if let Some(configured_path) = configured_path { + push_split_paths(&mut paths, &mut seen, configured_path); + } + if let Some(env_path) = env::var_os("PATH") { + push_split_paths(&mut paths, &mut seen, &env_path); + } + + push_user_bin_paths(&mut paths, &mut seen); + push_system_bin_paths(&mut paths, &mut seen); + paths +} + +fn push_split_paths(paths: &mut Vec, seen: &mut HashSet, value: &OsStr) { + for directory in env::split_paths(value) { + push_search_path(paths, seen, directory); + } +} + +fn push_user_bin_paths(paths: &mut Vec, seen: &mut HashSet) { + let Some(home) = env::var_os("HOME") else { + return; + }; + let home = PathBuf::from(home); + push_existing_search_path(paths, seen, home.join(".local/bin")); + push_existing_search_path(paths, seen, home.join(".cargo/bin")); + push_existing_search_path(paths, seen, home.join(".npm-global/bin")); +} + +fn push_system_bin_paths(paths: &mut Vec, seen: &mut HashSet) { + #[cfg(target_os = "macos")] + { + for prefix in ["/opt/homebrew", "/usr/local"] { + push_existing_search_path(paths, seen, PathBuf::from(format!("{prefix}/bin"))); + push_existing_search_path(paths, seen, PathBuf::from(format!("{prefix}/sbin"))); + for node in ["node", "node@18", "node@20", "node@22", "node@24"] { + push_existing_search_path( + paths, + seen, + PathBuf::from(format!("{prefix}/opt/{node}/bin")), + ); + } + } + } +} + +fn push_existing_search_path( + paths: &mut Vec, + seen: &mut HashSet, + path: PathBuf, +) { + if path.is_dir() { + push_search_path(paths, seen, path); + } +} + +fn push_search_path(paths: &mut Vec, seen: &mut HashSet, path: PathBuf) { + if path.as_os_str().is_empty() { + return; + } + + let key = search_path_key(&path); + if seen.insert(key) { + paths.push(path); + } +} + +fn search_path_key(path: &Path) -> OsString { + #[cfg(windows)] + { + OsString::from(path.to_string_lossy().to_ascii_lowercase()) + } + #[cfg(not(windows))] + { + path.as_os_str().to_os_string() + } +} + +fn executable_candidates(directory: &Path, command: &str) -> Vec { + #[cfg(windows)] + { + let command_path = PathBuf::from(command); + if command_path.extension().is_some() { + return vec![directory.join(command)]; + } + let extensions = env::var_os("PATHEXT").unwrap_or_else(|| OsString::from(".EXE;.BAT;.CMD")); + extensions + .to_string_lossy() + .split(';') + .filter(|extension| !extension.is_empty()) + .map(|extension| directory.join(format!("{command}{extension}"))) + .collect() + } + + #[cfg(not(windows))] + { + vec![directory.join(command)] + } +} + +fn executable_file(path: &Path) -> bool { + path.is_file() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_search_paths_keep_configured_path_first() { + let configured_paths = env::join_paths([ + PathBuf::from("/tmp/bitfun-acp-first"), + PathBuf::from("/tmp/bitfun-acp-second"), + ]) + .expect("test paths should be joinable"); + + let paths = command_search_paths(Some(&configured_paths)); + + assert_eq!(paths.first(), Some(&PathBuf::from("/tmp/bitfun-acp-first"))); + assert_eq!(paths.get(1), Some(&PathBuf::from("/tmp/bitfun-acp-second"))); + } + + #[test] + fn find_executable_uses_configured_path() { + let test_dir = env::temp_dir().join(format!("bitfun-acp-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&test_dir).expect("test dir should be created"); + + #[cfg(windows)] + let file_name = "bitfun-test-tool.EXE"; + #[cfg(not(windows))] + let file_name = "bitfun-test-tool"; + + let executable = test_dir.join(file_name); + std::fs::write(&executable, b"").expect("test executable should be written"); + + let found = find_executable_with_path("bitfun-test-tool", Some(test_dir.as_os_str())); + + let _ = std::fs::remove_dir_all(&test_dir); + assert_eq!(found, Some(executable)); + } +} diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs new file mode 100644 index 000000000..3f6ea88b5 --- /dev/null +++ b/src/crates/acp/src/client/session_options.rs @@ -0,0 +1,150 @@ +use agent_client_protocol::schema::{ + ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + SessionConfigSelectOptions, SessionModelState, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionOptions { + #[serde(default)] + pub current_model_id: Option, + #[serde(default)] + pub available_models: Vec, + #[serde(default)] + pub model_config_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionModelOption { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + +pub(super) fn session_options_from_state( + models: Option<&SessionModelState>, + config_options: &[SessionConfigOption], +) -> AcpSessionOptions { + if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { + return AcpSessionOptions { + current_model_id: Some(models.current_model_id.to_string()), + available_models: models + .available_models + .iter() + .map(model_option_from_model_info) + .collect(), + model_config_id: None, + }; + } + + model_config_option(config_options) + .map(|option| { + let (current_model_id, available_models) = select_model_values(option); + AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + } + }) + .unwrap_or_default() +} + +pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { + model_config_option(config_options).map(|option| option.id.to_string()) +} + +fn model_option_from_model_info(model: &ModelInfo) -> AcpSessionModelOption { + AcpSessionModelOption { + id: model.model_id.to_string(), + name: model.name.clone(), + description: model.description.clone(), + } +} + +fn model_config_option(config_options: &[SessionConfigOption]) -> Option<&SessionConfigOption> { + config_options + .iter() + .find(|option| matches!(option.category, Some(SessionConfigOptionCategory::Model))) + .or_else(|| { + config_options.iter().find(|option| { + let id = option.id.to_string().to_ascii_lowercase(); + let name = option.name.to_ascii_lowercase(); + id == "model" || id.ends_with("_model") || name.contains("model") + }) + }) + .filter(|option| matches!(option.kind, SessionConfigKind::Select(_))) +} + +fn select_model_values( + option: &SessionConfigOption, +) -> (Option, Vec) { + let SessionConfigKind::Select(select) = &option.kind else { + return (None, Vec::new()); + }; + + let models = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group.options.iter().map(|option| AcpSessionModelOption { + id: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + }) + }) + .collect(), + _ => Vec::new(), + }; + + (Some(select.current_value.to_string()), models) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent_client_protocol::schema::{ModelInfo, SessionConfigOption}; + + #[test] + fn converts_native_model_state() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + + let options = session_options_from_state(Some(&state), &[]); + + assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); + assert_eq!(options.available_models.len(), 1); + assert_eq!(options.available_models[0].name, "GPT 5.4"); + assert!(options.model_config_id.is_none()); + } + + #[test] + fn converts_model_config_option_fallback() { + let config = SessionConfigOption::select( + "model", + "Model", + "fast", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new("fast", "Fast"), + agent_client_protocol::schema::SessionConfigSelectOption::new("smart", "Smart"), + ], + ) + .category(SessionConfigOptionCategory::Model); + + let options = session_options_from_state(None, &[config]); + + assert_eq!(options.current_model_id.as_deref(), Some("fast")); + assert_eq!(options.model_config_id.as_deref(), Some("model")); + assert_eq!(options.available_models.len(), 2); + assert_eq!(options.available_models[1].id, "smart"); + } +} diff --git a/src/crates/acp/src/client/session_persistence.rs b/src/crates/acp/src/client/session_persistence.rs new file mode 100644 index 000000000..6b53e5fc4 --- /dev/null +++ b/src/crates/acp/src/client/session_persistence.rs @@ -0,0 +1,181 @@ +use std::path::Path; +use std::sync::Arc; + +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::PathManager; +use bitfun_core::service::session::SessionMetadata; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +pub(super) const CUSTOM_METADATA_PROVIDER_KEY: &str = "provider"; +pub(super) const CUSTOM_METADATA_PROVIDER_VALUE: &str = "acp"; +pub(super) const CUSTOM_METADATA_CLIENT_ID_KEY: &str = "acpClientId"; +pub(super) const CUSTOM_METADATA_REMOTE_SESSION_ID_KEY: &str = "acpRemoteSessionId"; +pub(super) const CUSTOM_METADATA_RESUME_STRATEGY_KEY: &str = "acpResumeStrategy"; +pub(super) const CUSTOM_METADATA_LAST_RESUME_ERROR_KEY: &str = "acpLastResumeError"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateAcpFlowSessionRecordResponse { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +pub(super) struct AcpSessionPersistence { + manager: PersistenceManager, +} + +impl AcpSessionPersistence { + pub(super) fn new(path_manager: Arc) -> BitFunResult { + Ok(Self { + manager: PersistenceManager::new(path_manager)?, + }) + } + + pub(super) async fn create_flow_session_record( + &self, + session_storage_path: &Path, + workspace_path: &str, + client_id: &str, + session_name: Option, + ) -> BitFunResult { + let session_id = format!("acp_{}_{}", client_id, uuid::Uuid::new_v4()); + let agent_type = format!("acp:{}", client_id); + let session_name = session_name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("{} ACP", client_id)); + + let mut metadata = SessionMetadata::new( + session_id.clone(), + session_name.clone(), + agent_type.clone(), + "auto".to_string(), + ); + metadata.workspace_path = Some(workspace_path.to_string()); + metadata.custom_metadata = Some(json!({ + "kind": "normal", + CUSTOM_METADATA_PROVIDER_KEY: CUSTOM_METADATA_PROVIDER_VALUE, + CUSTOM_METADATA_CLIENT_ID_KEY: client_id, + CUSTOM_METADATA_REMOTE_SESSION_ID_KEY: null, + CUSTOM_METADATA_RESUME_STRATEGY_KEY: null, + CUSTOM_METADATA_LAST_RESUME_ERROR_KEY: null, + })); + + self.manager + .save_session_metadata(session_storage_path, &metadata) + .await?; + + Ok(CreateAcpFlowSessionRecordResponse { + session_id, + session_name, + agent_type, + }) + } + + pub(super) async fn delete_flow_session_record( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult<()> { + self.manager + .delete_session(session_storage_path, bitfun_session_id) + .await + } + + pub(super) async fn load_remote_session_id( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + ) -> BitFunResult> { + let Some(metadata) = self + .manager + .load_session_metadata(session_storage_path, bitfun_session_id) + .await? + else { + return Ok(None); + }; + + Ok(metadata + .custom_metadata + .as_ref() + .and_then(|custom| custom.get(CUSTOM_METADATA_REMOTE_SESSION_ID_KEY)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string)) + } + + pub(super) async fn update_remote_session_state( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + remote_session_id: &str, + resume_strategy: &str, + last_resume_error: Option, + ) -> BitFunResult<()> { + self.update_metadata(session_storage_path, bitfun_session_id, |metadata| { + let mut custom = metadata.custom_metadata.take().unwrap_or_else(|| json!({})); + ensure_object(&mut custom)?; + custom[CUSTOM_METADATA_PROVIDER_KEY] = json!(CUSTOM_METADATA_PROVIDER_VALUE); + custom[CUSTOM_METADATA_REMOTE_SESSION_ID_KEY] = json!(remote_session_id); + custom[CUSTOM_METADATA_RESUME_STRATEGY_KEY] = json!(resume_strategy); + custom[CUSTOM_METADATA_LAST_RESUME_ERROR_KEY] = + last_resume_error.map(Value::String).unwrap_or(Value::Null); + metadata.custom_metadata = Some(custom); + metadata.touch(); + Ok(()) + }) + .await + } + + pub(super) async fn update_model_id( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + self.update_metadata(session_storage_path, bitfun_session_id, |metadata| { + metadata.model_name = model_id.to_string(); + metadata.touch(); + Ok(()) + }) + .await + } + + async fn update_metadata( + &self, + session_storage_path: &Path, + bitfun_session_id: &str, + update: impl FnOnce(&mut SessionMetadata) -> BitFunResult<()>, + ) -> BitFunResult<()> { + let Some(mut metadata) = self + .manager + .load_session_metadata(session_storage_path, bitfun_session_id) + .await? + else { + return Ok(()); + }; + + update(&mut metadata)?; + self.manager + .save_session_metadata(session_storage_path, &metadata) + .await + } +} + +fn ensure_object(value: &mut Value) -> BitFunResult<()> { + if value.is_object() { + return Ok(()); + } + + *value = json!({}); + if value.is_object() { + Ok(()) + } else { + Err(BitFunError::service( + "Failed to initialize ACP session custom metadata".to_string(), + )) + } +} diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs new file mode 100644 index 000000000..d6a38261c --- /dev/null +++ b/src/crates/acp/src/client/stream.rs @@ -0,0 +1,360 @@ +use agent_client_protocol::schema::{ + ContentBlock, ContentChunk, SessionNotification, SessionUpdate, ToolCall, ToolCallContent, + ToolCallStatus, ToolCallUpdate, +}; +use agent_client_protocol::util::MatchDispatch; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use bitfun_events::ToolEventData; + +use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; + +#[derive(Debug, Clone)] +pub enum AcpClientStreamEvent { + ModelRoundStarted { + round_id: String, + round_index: usize, + disable_explore_grouping: bool, + }, + AgentText(String), + AgentThought(String), + ToolEvent(ToolEventData), + Completed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AcpStreamItemKind { + Text, + Tool, +} + +#[derive(Debug, Default)] +pub(super) struct AcpStreamRoundTracker { + next_round_index: usize, + last_item_kind: Option, +} + +impl AcpStreamRoundTracker { + pub(super) fn new() -> Self { + Self::default() + } + + pub(super) fn apply(&mut self, event: AcpClientStreamEvent) -> Vec { + match event { + AcpClientStreamEvent::AgentText(_) | AcpClientStreamEvent::AgentThought(_) => { + let mut events = Vec::new(); + if self.last_item_kind.is_none() + || self.last_item_kind == Some(AcpStreamItemKind::Tool) + { + events.push(self.next_round_started_event()); + } + self.last_item_kind = Some(AcpStreamItemKind::Text); + events.push(event); + events + } + AcpClientStreamEvent::ToolEvent(_) => { + let mut events = Vec::new(); + if self.last_item_kind.is_none() { + events.push(self.next_round_started_event()); + } + self.last_item_kind = Some(AcpStreamItemKind::Tool); + events.push(event); + events + } + AcpClientStreamEvent::ModelRoundStarted { .. } + | AcpClientStreamEvent::Completed + | AcpClientStreamEvent::Cancelled => vec![event], + } + } + + fn next_round_started_event(&mut self) -> AcpClientStreamEvent { + let round_index = self.next_round_index; + self.next_round_index += 1; + AcpClientStreamEvent::ModelRoundStarted { + round_id: format!( + "round_{}_{}", + chrono::Utc::now().timestamp_millis(), + uuid::Uuid::new_v4() + ), + round_index, + disable_explore_grouping: true, + } + } +} + +pub async fn acp_dispatch_to_stream_events( + dispatch: agent_client_protocol::Dispatch, +) -> BitFunResult> { + let mut events = Vec::new(); + MatchDispatch::new(dispatch) + .if_notification(async |notification: SessionNotification| { + match notification.update { + SessionUpdate::AgentMessageChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentText(text)); + } + } + SessionUpdate::AgentThoughtChunk(chunk) => { + if let Some(text) = content_chunk_text(chunk) { + events.push(AcpClientStreamEvent::AgentThought(text)); + } + } + SessionUpdate::ToolCall(tool_call) => { + events.extend(acp_tool_call_events(tool_call)); + } + SessionUpdate::ToolCallUpdate(tool_call_update) => { + if let Some(event) = acp_tool_call_update_event(tool_call_update) { + events.push(event); + } + } + _ => {} + } + Ok(()) + }) + .await + .otherwise_ignore() + .map_err(protocol_error)?; + Ok(events) +} + +fn content_chunk_text(chunk: ContentChunk) -> Option { + match chunk.content { + ContentBlock::Text(text) => Some(text.text), + _ => None, + } +} + +fn acp_tool_call_events(tool_call: ToolCall) -> Vec { + let tool_id = tool_call.tool_call_id.to_string(); + let tool_name = acp_tool_name( + &tool_call.title, + tool_call.raw_input.as_ref(), + Some(&tool_call.kind), + ); + let params = normalize_tool_params( + &tool_name, + tool_call.raw_input.clone().unwrap_or_else(|| { + serde_json::json!({ + "title": tool_call.title, + "kind": format!("{:?}", tool_call.kind), + }) + }), + ); + + let mut events = vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params, + timeout_seconds: None, + })]; + + match tool_call.status { + ToolCallStatus::Completed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + tool_call.raw_output, + Some(tool_call.content), + Some(tool_call.locations), + ), + result_for_assistant: None, + duration_ms: 0, + })); + } + ToolCallStatus::Failed => { + events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text(tool_call.raw_output, tool_call.content), + })); + } + ToolCallStatus::Pending | ToolCallStatus::InProgress => {} + _ => {} + } + + events +} + +fn acp_tool_call_update_event(update: ToolCallUpdate) -> Option { + let tool_id = update.tool_call_id.to_string(); + let title = update.fields.title.unwrap_or_else(|| tool_id.clone()); + let tool_name = acp_tool_name( + &title, + update.fields.raw_input.as_ref(), + update.fields.kind.as_ref(), + ); + + match update.fields.status { + Some(ToolCallStatus::Completed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { + tool_id, + tool_name, + result: acp_tool_result_value( + update.fields.raw_output, + update.fields.content, + update.fields.locations, + ), + result_for_assistant: None, + duration_ms: 0, + })) + } + Some(ToolCallStatus::Failed) => { + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { + tool_id, + tool_name, + error: acp_tool_error_text( + update.fields.raw_output, + update.fields.content.unwrap_or_default(), + ), + })) + } + Some(ToolCallStatus::InProgress) | Some(ToolCallStatus::Pending) | Some(_) => { + let params = normalize_tool_params( + &tool_name, + update.fields.raw_input.unwrap_or_else(|| { + serde_json::json!({ + "title": title, + }) + }), + ); + Some(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + })) + } + None => update.fields.raw_input.map(|params| { + let params = normalize_tool_params(&tool_name, params); + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id, + tool_name, + params, + timeout_seconds: None, + }) + }), + } +} + +fn acp_tool_result_value( + raw_output: Option, + content: Option>, + locations: Option>, +) -> serde_json::Value { + if let Some(raw_output) = raw_output { + return raw_output; + } + + let content = content.unwrap_or_default(); + let locations = locations.unwrap_or_default(); + if content.is_empty() && locations.is_empty() { + return serde_json::Value::Null; + } + + serde_json::json!({ + "content": content, + "locations": locations, + }) +} + +fn acp_tool_error_text( + raw_output: Option, + content: Vec, +) -> String { + if let Some(raw_output) = raw_output { + return value_to_display_text(&raw_output); + } + if !content.is_empty() { + return serde_json::to_string_pretty(&content).unwrap_or_else(|_| { + serde_json::to_string(&content).unwrap_or_else(|_| "ACP tool failed".to_string()) + }); + } + "ACP tool failed".to_string() +} + +fn value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +fn protocol_error(error: impl std::fmt::Display) -> BitFunError { + BitFunError::service(format!("ACP protocol error: {}", error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn tool_event(id: &str) -> AcpClientStreamEvent { + AcpClientStreamEvent::ToolEvent(ToolEventData::Started { + tool_id: id.to_string(), + tool_name: "Bash".to_string(), + params: json!({ "command": "echo ok" }), + timeout_seconds: None, + }) + } + + fn event_kinds(events: &[AcpClientStreamEvent]) -> Vec<&'static str> { + events + .iter() + .map(|event| match event { + AcpClientStreamEvent::ModelRoundStarted { .. } => "round", + AcpClientStreamEvent::AgentText(_) => "text", + AcpClientStreamEvent::AgentThought(_) => "thought", + AcpClientStreamEvent::ToolEvent(_) => "tool", + AcpClientStreamEvent::Completed => "completed", + AcpClientStreamEvent::Cancelled => "cancelled", + }) + .collect() + } + + #[test] + fn starts_new_round_for_text_after_tool() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("before".to_string()))); + events.extend(tracker.apply(tool_event("tool-1"))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("after".to_string()))); + + assert_eq!( + event_kinds(&events), + vec!["round", "text", "tool", "round", "text"] + ); + assert!(matches!( + events[0], + AcpClientStreamEvent::ModelRoundStarted { round_index: 0, .. } + )); + assert!(matches!( + events[3], + AcpClientStreamEvent::ModelRoundStarted { round_index: 1, .. } + )); + } + + #[test] + fn keeps_consecutive_tools_in_one_round_before_text() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(tool_event("tool-1"))); + events.extend(tracker.apply(tool_event("tool-2"))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("done".to_string()))); + + assert_eq!( + event_kinds(&events), + vec!["round", "tool", "tool", "round", "text"] + ); + } + + #[test] + fn keeps_consecutive_text_in_one_round() { + let mut tracker = AcpStreamRoundTracker::new(); + let mut events = Vec::new(); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("a".to_string()))); + events.extend(tracker.apply(AcpClientStreamEvent::AgentText("b".to_string()))); + + assert_eq!(event_kinds(&events), vec!["round", "text", "text"]); + } +} diff --git a/src/crates/acp/src/client/tool.rs b/src/crates/acp/src/client/tool.rs new file mode 100644 index 000000000..cff7f94c8 --- /dev/null +++ b/src/crates/acp/src/client/tool.rs @@ -0,0 +1,226 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use bitfun_core::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde_json::{json, Value}; + +use super::config::AcpClientConfig; +use super::manager::AcpClientService; + +pub struct AcpAgentTool { + client_id: String, + config: AcpClientConfig, + service: Arc, + full_name: String, +} + +impl AcpAgentTool { + pub fn new(client_id: String, config: AcpClientConfig, service: Arc) -> Self { + let full_name = Self::tool_name_for(&client_id); + Self { + client_id, + config, + service, + full_name, + } + } + + pub fn tool_name_for(client_id: &str) -> String { + format!("acp__{}__prompt", sanitize_tool_part(client_id)) + } + + fn display_name(&self) -> String { + self.config + .name + .clone() + .unwrap_or_else(|| self.client_id.clone()) + } +} + +#[async_trait] +impl Tool for AcpAgentTool { + fn name(&self) -> &str { + &self.full_name + } + + async fn description(&self) -> BitFunResult { + Ok(format!( + "Send a prompt to the external ACP agent '{}'. Use this when another local ACP-compatible agent is better suited for a delegated task.", + self.display_name() + )) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "The task or question to send to the external ACP agent." + }, + "workspace_path": { + "type": "string", + "description": "Optional absolute workspace path. Defaults to the current BitFun workspace." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 0, + "description": "Optional timeout in seconds. Use 0 or omit it to wait without a fixed timeout." + } + }, + "required": ["prompt"], + "additionalProperties": false + }) + } + + fn user_facing_name(&self) -> String { + format!("{} (ACP)", self.display_name()) + } + + fn is_readonly(&self) -> bool { + self.config.readonly + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + !self.config.readonly + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match input.get("prompt").and_then(|value| value.as_str()) { + Some(prompt) if !prompt.trim().is_empty() => ValidationResult::default(), + Some(_) => ValidationResult { + result: false, + message: Some("prompt cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }, + None => ValidationResult { + result: false, + message: Some("prompt is required".to_string()), + error_code: Some(400), + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let prompt_preview = input + .get("prompt") + .and_then(|value| value.as_str()) + .map(truncate_prompt) + .unwrap_or_else(|| "prompt".to_string()); + format!( + "Sending ACP prompt to '{}': {}", + self.display_name(), + prompt_preview + ) + } + + fn render_tool_use_rejected_message(&self) -> String { + format!("ACP prompt to '{}' was rejected", self.display_name()) + } + + fn render_tool_result_message(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .map(|response| { + format!( + "ACP agent '{}' responded:\n{}", + self.display_name(), + response + ) + }) + .unwrap_or_else(|| format!("ACP agent '{}' completed", self.display_name())) + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + output + .get("response") + .and_then(|value| value.as_str()) + .unwrap_or("ACP agent completed without text output") + .to_string() + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let prompt = input + .get("prompt") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| BitFunError::tool("prompt is required".to_string()))? + .to_string(); + + let workspace_path = input + .get("workspace_path") + .and_then(|value| value.as_str()) + .map(|value| value.to_string()) + .or_else(|| { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + }); + let timeout_seconds = input + .get("timeout_seconds") + .and_then(|value| value.as_u64()); + + let response = self + .service + .prompt_agent( + &self.client_id, + prompt, + workspace_path, + context.session_id.clone(), + None, + timeout_seconds, + ) + .await?; + + let data = json!({ + "client_id": self.client_id, + "response": response, + }); + Ok(vec![ToolResult::Result { + result_for_assistant: Some(self.render_result_for_assistant(&data)), + data, + image_attachments: None, + }]) + } +} + +fn sanitize_tool_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect::(); + sanitized.trim_matches('_').to_string() +} + +fn truncate_prompt(prompt: &str) -> String { + const LIMIT: usize = 160; + if prompt.chars().count() <= LIMIT { + prompt.to_string() + } else { + format!("{}...", prompt.chars().take(LIMIT).collect::()) + } +} diff --git a/src/crates/acp/src/client/tool_card_bridge.rs b/src/crates/acp/src/client/tool_card_bridge.rs new file mode 100644 index 000000000..b0d19e2a1 --- /dev/null +++ b/src/crates/acp/src/client/tool_card_bridge.rs @@ -0,0 +1,434 @@ +use agent_client_protocol::schema::ToolKind; + +pub(super) fn acp_tool_name( + title: &str, + raw_input: Option<&serde_json::Value>, + kind: Option<&ToolKind>, +) -> String { + if let Some(name) = raw_input.and_then(tool_name_from_raw_input) { + return normalize_tool_name(&name, title, raw_input, kind); + } + + normalize_tool_name("", title, raw_input, kind) +} + +pub(super) fn normalize_tool_params( + tool_name: &str, + params: serde_json::Value, +) -> serde_json::Value { + let Some(object) = params.as_object() else { + return params; + }; + + let mut normalized = object.clone(); + match tool_name { + "Bash" => { + if !normalized.contains_key("command") { + if let Some(value) = normalized.get("cmd").cloned() { + normalized.insert("command".to_string(), value); + } + } + if let Some(value) = normalized.get("command").cloned() { + normalized.insert( + "command".to_string(), + serde_json::Value::String(command_value_to_display_text(&value)), + ); + } + } + "Read" | "Write" | "Edit" | "Delete" => { + if !normalized.contains_key("file_path") { + if let Some(value) = normalized + .get("path") + .or_else(|| normalized.get("target_file")) + .or_else(|| normalized.get("targetFile")) + .or_else(|| normalized.get("filePath")) + .or_else(|| normalized.get("filename")) + .cloned() + { + normalized.insert("file_path".to_string(), value); + } + } + if tool_name == "Edit" { + if !normalized.contains_key("old_string") { + if let Some(value) = normalized.get("oldString").cloned() { + normalized.insert("old_string".to_string(), value); + } + } + if !normalized.contains_key("new_string") { + if let Some(value) = normalized.get("newString").cloned() { + normalized.insert("new_string".to_string(), value); + } + } + } + } + "LS" => { + if !normalized.contains_key("path") { + if let Some(value) = normalized + .get("directory") + .or_else(|| normalized.get("dir")) + .or_else(|| normalized.get("target_directory")) + .or_else(|| normalized.get("targetDirectory")) + .cloned() + { + normalized.insert("path".to_string(), value); + } + } + } + "Grep" => { + if !normalized.contains_key("pattern") { + if let Some(value) = normalized + .get("query") + .or_else(|| normalized.get("text")) + .or_else(|| normalized.get("search_pattern")) + .or_else(|| normalized.get("searchPattern")) + .cloned() + { + normalized.insert("pattern".to_string(), value); + } + } + } + "Glob" => { + if !normalized.contains_key("pattern") { + if let Some(value) = normalized + .get("glob") + .or_else(|| normalized.get("glob_pattern")) + .or_else(|| normalized.get("globPattern")) + .or_else(|| normalized.get("file_pattern")) + .or_else(|| normalized.get("filePattern")) + .cloned() + { + normalized.insert("pattern".to_string(), value); + } + } + } + _ => {} + } + + serde_json::Value::Object(normalized) +} + +fn tool_name_from_raw_input(raw_input: &serde_json::Value) -> Option { + let object = raw_input.as_object()?; + for key in [ + "tool", + "toolName", + "tool_name", + "name", + "function", + "action", + ] { + let Some(value) = object.get(key).and_then(|value| value.as_str()) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } + } + None +} + +fn normalize_tool_name( + candidate: &str, + title: &str, + raw_input: Option<&serde_json::Value>, + kind: Option<&ToolKind>, +) -> String { + let candidate = candidate.trim(); + let normalized_candidate = normalize_known_tool_alias(candidate); + if normalized_candidate != candidate || is_native_tool_name(&normalized_candidate) { + return normalized_candidate; + } + + let title_lower = title.trim().to_ascii_lowercase(); + let candidate_lower = candidate.to_ascii_lowercase(); + let haystack = format!("{} {}", candidate_lower, title_lower); + let input = raw_input.and_then(|value| value.as_object()); + if let Some(input) = input { + if has_any_key(input, &["command", "cmd"]) { + return "Bash".to_string(); + } + if has_any_key( + input, + &[ + "glob", + "glob_pattern", + "globPattern", + "file_pattern", + "filePattern", + ], + ) { + return "Glob".to_string(); + } + if has_any_key( + input, + &["pattern", "search_pattern", "searchPattern", "query"], + ) { + if contains_any(&haystack, &["web search", "search web"]) { + return "WebSearch".to_string(); + } + return "Grep".to_string(); + } + if has_any_key( + input, + &["directory", "dir", "target_directory", "targetDirectory"], + ) { + return "LS".to_string(); + } + + let has_file_path = has_any_key( + input, + &[ + "file_path", + "filePath", + "target_file", + "targetFile", + "filename", + "path", + ], + ); + if has_file_path { + if has_any_key(input, &["content", "contents"]) { + return "Write".to_string(); + } + if has_any_key( + input, + &["old_string", "oldString", "new_string", "newString"], + ) { + return "Edit".to_string(); + } + match kind { + Some(ToolKind::Delete) => return "Delete".to_string(), + Some(ToolKind::Edit) | Some(ToolKind::Move) => return "Edit".to_string(), + Some(ToolKind::Read) => return "Read".to_string(), + _ => {} + } + } + } + + if contains_any( + &haystack, + &[ + "bash", + "shell", + "terminal", + "command", + "execute", + "exec", + "run command", + ], + ) { + return "Bash".to_string(); + } + if contains_any(&haystack, &["list", "directory", "folder", "ls"]) { + return "LS".to_string(); + } + if contains_any( + &haystack, + &["glob", "find file", "file search", "search files"], + ) { + return "Glob".to_string(); + } + if contains_any(&haystack, &["grep", "search", "ripgrep", "rg"]) { + return "Grep".to_string(); + } + if contains_any(&haystack, &["write", "create file", "new file"]) { + return "Write".to_string(); + } + if contains_any(&haystack, &["edit", "patch", "replace", "modify"]) { + return "Edit".to_string(); + } + if contains_any(&haystack, &["delete", "remove", "unlink"]) { + return "Delete".to_string(); + } + if contains_any(&haystack, &["read", "open file", "view file"]) { + return "Read".to_string(); + } + if contains_any(&haystack, &["web search", "search web"]) { + return "WebSearch".to_string(); + } + + match kind { + Some(ToolKind::Read) => "Read".to_string(), + Some(ToolKind::Edit) => "Edit".to_string(), + Some(ToolKind::Delete) => "Delete".to_string(), + Some(ToolKind::Move) => "Edit".to_string(), + Some(ToolKind::Search) => "Grep".to_string(), + Some(ToolKind::Execute) => "Bash".to_string(), + Some(ToolKind::Fetch) => "WebSearch".to_string(), + Some(ToolKind::Think) | Some(ToolKind::SwitchMode) | Some(ToolKind::Other) | Some(_) => { + fallback_tool_name(candidate, title) + } + None => fallback_tool_name(candidate, title), + } +} + +fn fallback_tool_name(candidate: &str, title: &str) -> String { + if !candidate.is_empty() { + candidate.to_string() + } else { + let title = title.trim(); + if title.is_empty() { + "ACP Tool".to_string() + } else { + title.to_string() + } + } +} + +fn normalize_known_tool_alias(name: &str) -> String { + match name.trim().to_ascii_lowercase().as_str() { + "read" | "read_file" | "readfile" | "view" | "open" => "Read".to_string(), + "ls" | "list" | "list_dir" | "list_directory" | "readdir" => "LS".to_string(), + "grep" | "rg" | "search" | "text_search" => "Grep".to_string(), + "glob" | "find" | "file_search" => "Glob".to_string(), + "bash" | "sh" | "shell" | "terminal" | "command" | "cmd" | "execute" => "Bash".to_string(), + "write" | "write_file" | "create" => "Write".to_string(), + "edit" | "patch" | "replace" | "update" => "Edit".to_string(), + "delete" | "remove" | "rm" => "Delete".to_string(), + "todowrite" | "todo_write" | "todo" => "TodoWrite".to_string(), + "websearch" | "web_search" | "search_web" => "WebSearch".to_string(), + _ => name.to_string(), + } +} + +fn is_native_tool_name(name: &str) -> bool { + matches!( + name, + "Read" + | "Write" + | "Edit" + | "Delete" + | "LS" + | "Grep" + | "Glob" + | "Bash" + | "TodoWrite" + | "WebSearch" + ) +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + +fn has_any_key(object: &serde_json::Map, keys: &[&str]) -> bool { + keys.iter().any(|key| object.contains_key(*key)) +} + +fn command_value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(items) => items + .iter() + .map(command_value_to_display_text) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" "), + serde_json::Value::Number(number) => number.to_string(), + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Null => String::new(), + serde_json::Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn normalizes_execute_tools_to_bash_card() { + let input = json!({ "command": "pnpm test" }); + assert_eq!( + acp_tool_name("Run shell command", Some(&input), Some(&ToolKind::Execute)), + "Bash" + ); + + let params = normalize_tool_params("Bash", json!({ "cmd": "ls -la" })); + assert_eq!(params["command"], "ls -la"); + } + + #[test] + fn normalizes_bash_command_arrays_to_display_string() { + let params = normalize_tool_params( + "Bash", + json!({ + "command": ["/bin/zsh", "-lc", "sed -n '1,120p' src/lib.rs"], + "cwd": "/tmp/project" + }), + ); + + assert_eq!(params["command"], "/bin/zsh -lc sed -n '1,120p' src/lib.rs"); + assert_eq!(params["cwd"], "/tmp/project"); + } + + #[test] + fn normalizes_file_tools_to_native_cards() { + let read_input = json!({ "path": "src/main.rs" }); + assert_eq!( + acp_tool_name("Read file", Some(&read_input), Some(&ToolKind::Read)), + "Read" + ); + assert_eq!( + normalize_tool_params("Read", read_input)["file_path"], + "src/main.rs" + ); + + let write_input = json!({ "path": "README.md", "content": "hello" }); + assert_eq!( + acp_tool_name("Create file", Some(&write_input), Some(&ToolKind::Edit)), + "Write" + ); + } + + #[test] + fn normalizes_search_tools_to_grep_or_glob_cards() { + let grep_input = json!({ "query": "AcpClientService" }); + assert_eq!( + acp_tool_name("Search text", Some(&grep_input), Some(&ToolKind::Search)), + "Grep" + ); + assert_eq!( + normalize_tool_params("Grep", grep_input)["pattern"], + "AcpClientService" + ); + + let glob_input = json!({ "glob_pattern": "**/*.rs" }); + assert_eq!( + acp_tool_name("Find files", Some(&glob_input), Some(&ToolKind::Search)), + "Glob" + ); + assert_eq!( + normalize_tool_params("Glob", glob_input)["pattern"], + "**/*.rs" + ); + } + + #[test] + fn search_with_path_stays_search_card() { + let input = json!({ "pattern": "ToolEventData", "path": "src" }); + assert_eq!( + acp_tool_name("Search text", Some(&input), Some(&ToolKind::Search)), + "Grep" + ); + } + + #[test] + fn normalizes_camel_case_file_params() { + let input = json!({ + "filePath": "src/lib.rs", + "oldString": "before", + "newString": "after" + }); + assert_eq!( + acp_tool_name("Edit file", Some(&input), Some(&ToolKind::Edit)), + "Edit" + ); + + let params = normalize_tool_params("Edit", input); + assert_eq!(params["file_path"], "src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + } +} diff --git a/src/crates/acp/src/lib.rs b/src/crates/acp/src/lib.rs new file mode 100644 index 000000000..5930b0a52 --- /dev/null +++ b/src/crates/acp/src/lib.rs @@ -0,0 +1,13 @@ +//! BitFun Agent Client Protocol integration. +//! +//! This crate owns the external ACP server surface and maps it onto BitFun's +//! core agentic runtime. CLI and other hosts should only start this crate. + +pub mod client; +mod runtime; +mod server; + +pub use agent_client_protocol as protocol; +pub use client::AcpClientService; +pub use runtime::BitfunAcpRuntime; +pub use server::AcpServer; diff --git a/src/crates/acp/src/runtime.rs b/src/crates/acp/src/runtime.rs new file mode 100644 index 000000000..0ec32fd1a --- /dev/null +++ b/src/crates/acp/src/runtime.rs @@ -0,0 +1,131 @@ +use std::sync::Arc; + +use agent_client_protocol::schema::{ + AgentCapabilities, CancelNotification, Implementation, InitializeRequest, InitializeResponse, + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, + McpCapabilities, NewSessionRequest, NewSessionResponse, PromptCapabilities, PromptRequest, + PromptResponse, ProtocolVersion, SessionCapabilities, SessionListCapabilities, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use async_trait::async_trait; +use bitfun_core::agentic::system::AgenticSystem; +use dashmap::DashMap; + +use crate::server::{AcpRuntime, AcpServer}; + +mod content; +mod events; +mod mcp; +mod model; +mod prompt; +mod session; +mod thinking; + +pub struct BitfunAcpRuntime { + pub(crate) agentic_system: AgenticSystem, + pub(crate) sessions: DashMap, + pub(crate) connections: DashMap>, +} + +#[derive(Clone)] +pub(crate) struct AcpSessionState { + pub(crate) acp_session_id: String, + pub(crate) bitfun_session_id: String, + pub(crate) cwd: String, + pub(crate) mode_id: String, + pub(crate) model_id: String, + #[allow(dead_code)] + pub(crate) mcp_server_ids: Vec, +} + +impl BitfunAcpRuntime { + pub fn new(agentic_system: AgenticSystem) -> Self { + Self { + agentic_system, + sessions: DashMap::new(), + connections: DashMap::new(), + } + } + + pub async fn serve_stdio(agentic_system: AgenticSystem) -> Result<()> { + AcpServer::new(Arc::new(Self::new(agentic_system))) + .serve_stdio() + .await + } + + pub(crate) fn internal_error(error: impl std::fmt::Display) -> Error { + Error::internal_error().data(serde_json::json!(error.to_string())) + } +} + +#[async_trait] +impl AcpRuntime for BitfunAcpRuntime { + async fn initialize(&self, _request: InitializeRequest) -> Result { + Ok(InitializeResponse::new(ProtocolVersion::V1) + .agent_capabilities( + AgentCapabilities::new() + .load_session(true) + .prompt_capabilities( + PromptCapabilities::new().image(true).embedded_context(true), + ) + .mcp_capabilities(McpCapabilities::new().http(true)) + .session_capabilities( + SessionCapabilities::new().list(SessionListCapabilities::new()), + ), + ) + .agent_info( + Implementation::new("bitfun-acp", env!("CARGO_PKG_VERSION")).title("BitFun"), + )) + } + + async fn new_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result { + self.create_session(request, connection).await + } + + async fn load_session( + &self, + request: LoadSessionRequest, + connection: ConnectionTo, + ) -> Result { + self.restore_session(request, connection).await + } + + async fn list_sessions(&self, request: ListSessionsRequest) -> Result { + self.list_sessions_for_cwd(request).await + } + + async fn prompt(&self, request: PromptRequest) -> Result { + self.run_prompt(request).await + } + + async fn cancel(&self, notification: CancelNotification) -> Result<()> { + self.cancel_prompt(notification).await + } + + async fn set_session_mode( + &self, + request: SetSessionModeRequest, + ) -> Result { + self.update_session_mode(request).await + } + + async fn set_session_config_option( + &self, + request: SetSessionConfigOptionRequest, + ) -> Result { + self.update_session_config_option(request).await + } + + async fn set_session_model( + &self, + request: SetSessionModelRequest, + ) -> Result { + self.update_session_model(request).await + } +} diff --git a/src/crates/acp/src/runtime/content.rs b/src/crates/acp/src/runtime/content.rs new file mode 100644 index 000000000..96b98ff38 --- /dev/null +++ b/src/crates/acp/src/runtime/content.rs @@ -0,0 +1,235 @@ +use agent_client_protocol::schema::{ + Annotations, BlobResourceContents, ContentBlock, EmbeddedResourceResource, ImageContent, + ResourceLink, Role, TextResourceContents, +}; +use bitfun_core::agentic::image_analysis::ImageContextData; + +pub(super) struct ParsedPrompt { + pub(super) user_message: String, + pub(super) original_user_message: Option, + pub(super) image_contexts: Vec, +} + +pub(super) fn parse_prompt_blocks(session_id: &str, blocks: Vec) -> ParsedPrompt { + let mut text_parts = Vec::new(); + let mut original_text_parts = Vec::new(); + let mut image_contexts = Vec::new(); + + for (index, block) in blocks.into_iter().enumerate() { + match block { + ContentBlock::Text(text) => { + if is_user_only(text.annotations.as_ref()) { + continue; + } + original_text_parts.push(text.text.clone()); + text_parts.push(text.text); + } + ContentBlock::Image(image) => { + if is_user_only(image.annotations.as_ref()) { + continue; + } + if let Some(context) = image_to_context(session_id, index, image) { + text_parts.push(format!("[Attached image: {}]", context.id)); + image_contexts.push(context); + } + } + ContentBlock::ResourceLink(link) => { + if is_user_only(link.annotations.as_ref()) { + continue; + } + text_parts.push(resource_link_text(&link)); + } + ContentBlock::Resource(resource) => { + if is_user_only(resource.annotations.as_ref()) { + continue; + } + match resource.resource { + EmbeddedResourceResource::TextResourceContents(text) => { + text_parts.push(text_resource_text(&text)); + } + EmbeddedResourceResource::BlobResourceContents(blob) => { + if let Some(context) = + blob_resource_to_image_context(session_id, index, &blob) + { + text_parts.push(format!("[Attached image resource: {}]", context.id)); + image_contexts.push(context); + } else { + text_parts.push(blob_resource_text(&blob)); + } + } + _ => { + text_parts.push( + "[Embedded resource omitted: unsupported resource type]".to_string(), + ); + } + } + } + ContentBlock::Audio(audio) => { + if is_user_only(audio.annotations.as_ref()) { + continue; + } + text_parts.push(format!( + "[Audio attachment omitted: mime_type={}, bytes={}]", + audio.mime_type, + audio.data.len() + )); + } + _ => {} + } + } + + let user_message = join_prompt_parts(text_parts); + let original_user_message = if original_text_parts.is_empty() { + None + } else { + Some(join_prompt_parts(original_text_parts)) + }; + + ParsedPrompt { + user_message, + original_user_message, + image_contexts, + } +} + +fn is_user_only(annotations: Option<&Annotations>) -> bool { + matches!( + annotations.and_then(|a| a.audience.as_ref()), + Some(audience) if audience.len() == 1 && matches!(audience.first(), Some(Role::User)) + ) +} + +fn image_to_context( + session_id: &str, + index: usize, + image: ImageContent, +) -> Option { + if image.data.trim().is_empty() { + return image.uri.clone().map(|uri| ImageContextData { + id: prompt_context_id(session_id, "image", index), + image_path: file_uri_to_path(&uri).or(Some(uri)), + data_url: None, + mime_type: image.mime_type, + metadata: Some(serde_json::json!({ + "source": "acp", + "uri": image.uri, + })), + }); + } + + Some(ImageContextData { + id: prompt_context_id(session_id, "image", index), + image_path: None, + data_url: Some(format!("data:{};base64,{}", image.mime_type, image.data)), + mime_type: image.mime_type, + metadata: Some(serde_json::json!({ + "source": "acp", + "uri": image.uri, + })), + }) +} + +fn blob_resource_to_image_context( + session_id: &str, + index: usize, + blob: &BlobResourceContents, +) -> Option { + let mime_type = blob + .mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()); + if !mime_type.to_ascii_lowercase().starts_with("image/") { + return None; + } + + Some(ImageContextData { + id: prompt_context_id(session_id, "resource_image", index), + image_path: None, + data_url: Some(format!("data:{};base64,{}", mime_type, blob.blob)), + mime_type, + metadata: Some(serde_json::json!({ + "source": "acp_resource", + "uri": blob.uri, + })), + }) +} + +fn resource_link_text(link: &ResourceLink) -> String { + let mut lines = vec![ + "[Attached resource link]".to_string(), + format!("name: {}", link.name), + format!("uri: {}", link.uri), + ]; + if let Some(title) = &link.title { + lines.push(format!("title: {}", title)); + } + if let Some(description) = &link.description { + lines.push(format!("description: {}", description)); + } + if let Some(mime_type) = &link.mime_type { + lines.push(format!("mime_type: {}", mime_type)); + } + lines.join("\n") +} + +fn text_resource_text(resource: &TextResourceContents) -> String { + let language = resource + .mime_type + .as_deref() + .and_then(markdown_language_for_mime) + .unwrap_or(""); + format!( + "[Embedded resource]\nuri: {}\nmime_type: {}\n```{}\n{}\n```", + resource.uri, + resource.mime_type.as_deref().unwrap_or("text/plain"), + language, + resource.text + ) +} + +fn blob_resource_text(resource: &BlobResourceContents) -> String { + format!( + "[Embedded binary resource]\nuri: {}\nmime_type: {}\nbase64_bytes: {}", + resource.uri, + resource + .mime_type + .as_deref() + .unwrap_or("application/octet-stream"), + resource.blob.len() + ) +} + +fn markdown_language_for_mime(mime_type: &str) -> Option<&'static str> { + match mime_type.split(';').next()?.trim() { + "application/json" => Some("json"), + "application/javascript" | "text/javascript" => Some("javascript"), + "text/css" => Some("css"), + "text/html" => Some("html"), + "text/markdown" => Some("markdown"), + "text/x-python" => Some("python"), + "text/x-rust" => Some("rust"), + "text/x-typescript" => Some("typescript"), + _ => None, + } +} + +fn join_prompt_parts(parts: Vec) -> String { + parts + .into_iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n\n") +} + +fn prompt_context_id(session_id: &str, kind: &str, index: usize) -> String { + let sanitized = session_id + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::(); + format!("acp_{}_{}_{}", kind, sanitized, index) +} + +fn file_uri_to_path(uri: &str) -> Option { + uri.strip_prefix("file://").map(|path| path.to_string()) +} diff --git a/src/crates/acp/src/runtime/events.rs b/src/crates/acp/src/runtime/events.rs new file mode 100644 index 000000000..7e42c0ed2 --- /dev/null +++ b/src/crates/acp/src/runtime/events.rs @@ -0,0 +1,372 @@ +use std::collections::HashSet; + +use agent_client_protocol::schema::{ + PermissionOption, PermissionOptionKind, RequestPermissionRequest, SessionId, + SessionNotification, SessionUpdate, ToolCall, ToolCallContent, ToolCallStatus, ToolCallUpdate, + ToolCallUpdateFields, ToolKind, +}; +use agent_client_protocol::{Client, ConnectionTo, Result}; +use bitfun_events::ToolEventData; + +pub(super) const PERMISSION_ALLOW_ONCE: &str = "allow_once"; +pub(super) const PERMISSION_REJECT_ONCE: &str = "reject_once"; + +pub(super) fn send_update( + connection: &ConnectionTo, + session_id: &str, + update: SessionUpdate, +) -> Result<()> { + connection.send_notification(SessionNotification::new( + SessionId::new(session_id.to_string()), + update, + )) +} + +pub(super) fn tool_event_updates( + tool_event: &ToolEventData, + seen_tool_calls: &mut HashSet, +) -> Vec { + let tool_id = tool_event.tool_id(); + let mut updates = Vec::new(); + + if !seen_tool_calls.contains(tool_id) { + seen_tool_calls.insert(tool_id.to_string()); + updates.push(SessionUpdate::ToolCall(initial_tool_call(tool_event))); + } + + if let Some(update) = tool_call_update(tool_event) { + updates.push(SessionUpdate::ToolCallUpdate(update)); + } + + updates +} + +pub(super) fn permission_request( + session_id: &str, + tool_id: &str, + tool_name: &str, + params: &serde_json::Value, +) -> RequestPermissionRequest { + RequestPermissionRequest::new( + SessionId::new(session_id.to_string()), + ToolCallUpdate::new( + tool_id.to_string(), + ToolCallUpdateFields::new() + .title(format!("Allow {}?", tool_name)) + .status(ToolCallStatus::Pending) + .kind(tool_kind(tool_name)) + .raw_input(params.clone()) + .content(vec![text_content(format!( + "Permission required to run {}.", + tool_name + ))]), + ), + vec![ + PermissionOption::new( + PERMISSION_ALLOW_ONCE, + "Allow once", + PermissionOptionKind::AllowOnce, + ), + PermissionOption::new( + PERMISSION_REJECT_ONCE, + "Reject once", + PermissionOptionKind::RejectOnce, + ), + ], + ) +} + +fn initial_tool_call(tool_event: &ToolEventData) -> ToolCall { + let tool_id = tool_event.tool_id().to_string(); + let tool_name = tool_event.tool_name(); + let mut tool_call = ToolCall::new(tool_id, tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(tool_status(tool_event)); + + if let Some(raw_input) = tool_event.raw_input() { + tool_call = tool_call.raw_input(raw_input); + } + + tool_call +} + +fn tool_call_update(tool_event: &ToolEventData) -> Option { + let tool_id = tool_event.tool_id().to_string(); + let fields = match tool_event { + ToolEventData::EarlyDetected { tool_name, .. } => ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::Pending), + ToolEventData::ParamsPartial { params, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!("Input: {}", params))]), + ToolEventData::Queued { position, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!( + "Queued at position {}.", + position + ))]), + ToolEventData::Waiting { dependencies, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Pending) + .content(vec![text_content(format!( + "Waiting for dependencies: {}.", + dependencies.join(", ") + ))]), + ToolEventData::Started { + tool_name, params, .. + } => ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::InProgress) + .raw_input(params.clone()), + ToolEventData::Progress { + message, + percentage, + .. + } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(format!( + "{} ({:.0}%)", + message, percentage + ))]), + ToolEventData::Streaming { + chunks_received, .. + } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(format!( + "Received {} streaming chunks.", + chunks_received + ))]), + ToolEventData::StreamChunk { data, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content(value_to_display_text(data))]), + ToolEventData::ConfirmationNeeded { + tool_name, params, .. + } => ToolCallUpdateFields::new() + .title(format!("Allow {}?", tool_name)) + .status(ToolCallStatus::Pending) + .raw_input(params.clone()) + .content(vec![text_content("Waiting for permission.")]), + ToolEventData::Confirmed { .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::InProgress) + .content(vec![text_content("Permission granted.")]), + ToolEventData::Rejected { .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .content(vec![text_content("Permission rejected.")]), + ToolEventData::Completed { + result, + result_for_assistant, + duration_ms, + .. + } => { + let display = result_for_assistant + .clone() + .unwrap_or_else(|| value_to_display_text(result)); + ToolCallUpdateFields::new() + .status(ToolCallStatus::Completed) + .raw_output(result.clone()) + .content(vec![text_content(format!( + "{}\nCompleted in {} ms.", + display, duration_ms + ))]) + } + ToolEventData::Failed { error, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .raw_output(serde_json::json!({ "error": error })) + .content(vec![text_content(format!("Error: {}", error))]), + ToolEventData::Cancelled { reason, .. } => ToolCallUpdateFields::new() + .status(ToolCallStatus::Failed) + .raw_output(serde_json::json!({ "reason": reason })) + .content(vec![text_content(format!("Cancelled: {}", reason))]), + }; + + Some(ToolCallUpdate::new(tool_id, fields)) +} + +fn tool_title(tool_name: &str) -> String { + format!("Run {}", tool_name) +} + +fn tool_status(tool_event: &ToolEventData) -> ToolCallStatus { + match tool_event { + ToolEventData::Started { .. } + | ToolEventData::Progress { .. } + | ToolEventData::Streaming { .. } + | ToolEventData::StreamChunk { .. } + | ToolEventData::Confirmed { .. } => ToolCallStatus::InProgress, + ToolEventData::Completed { .. } => ToolCallStatus::Completed, + ToolEventData::Failed { .. } + | ToolEventData::Cancelled { .. } + | ToolEventData::Rejected { .. } => ToolCallStatus::Failed, + _ => ToolCallStatus::Pending, + } +} + +fn tool_kind(tool_name: &str) -> ToolKind { + let name = tool_name.to_ascii_lowercase(); + if name.contains("delete") || name.contains("remove") { + ToolKind::Delete + } else if name.contains("write") + || name.contains("edit") + || name.contains("patch") + || name.contains("replace") + { + ToolKind::Edit + } else if name.contains("move") || name.contains("rename") { + ToolKind::Move + } else if name.contains("grep") + || name.contains("glob") + || name.contains("search") + || name.contains("find") + { + ToolKind::Search + } else if name.contains("bash") + || name.contains("terminal") + || name.contains("command") + || name.contains("execute") + { + ToolKind::Execute + } else if name.contains("web") || name.contains("fetch") || name.contains("http") { + ToolKind::Fetch + } else if name.contains("think") || name.contains("plan") { + ToolKind::Think + } else if name.contains("read") || name == "ls" { + ToolKind::Read + } else { + ToolKind::Other + } +} + +fn text_content(text: impl Into) -> ToolCallContent { + ToolCallContent::from(text.into()) +} + +fn value_to_display_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(text) => text.clone(), + _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()), + } +} + +trait ToolEventExt { + fn tool_id(&self) -> &str; + fn tool_name(&self) -> &str; + fn raw_input(&self) -> Option; +} + +impl ToolEventExt for ToolEventData { + fn tool_id(&self) -> &str { + match self { + Self::EarlyDetected { tool_id, .. } + | Self::ParamsPartial { tool_id, .. } + | Self::Queued { tool_id, .. } + | Self::Waiting { tool_id, .. } + | Self::Started { tool_id, .. } + | Self::Progress { tool_id, .. } + | Self::Streaming { tool_id, .. } + | Self::StreamChunk { tool_id, .. } + | Self::ConfirmationNeeded { tool_id, .. } + | Self::Confirmed { tool_id, .. } + | Self::Rejected { tool_id, .. } + | Self::Completed { tool_id, .. } + | Self::Failed { tool_id, .. } + | Self::Cancelled { tool_id, .. } => tool_id, + } + } + + fn tool_name(&self) -> &str { + match self { + Self::EarlyDetected { tool_name, .. } + | Self::ParamsPartial { tool_name, .. } + | Self::Queued { tool_name, .. } + | Self::Waiting { tool_name, .. } + | Self::Started { tool_name, .. } + | Self::Progress { tool_name, .. } + | Self::Streaming { tool_name, .. } + | Self::StreamChunk { tool_name, .. } + | Self::ConfirmationNeeded { tool_name, .. } + | Self::Confirmed { tool_name, .. } + | Self::Rejected { tool_name, .. } + | Self::Completed { tool_name, .. } + | Self::Failed { tool_name, .. } + | Self::Cancelled { tool_name, .. } => tool_name, + } + } + + fn raw_input(&self) -> Option { + match self { + Self::Started { params, .. } | Self::ConfirmationNeeded { params, .. } => { + Some(params.clone()) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn early_detected_creates_tool_call_once() { + let mut seen = HashSet::new(); + let event = ToolEventData::EarlyDetected { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + }; + + let first = tool_event_updates(&event, &mut seen); + assert_eq!(first.len(), 2); + assert!(matches!(first[0], SessionUpdate::ToolCall(_))); + assert!(matches!(first[1], SessionUpdate::ToolCallUpdate(_))); + + let second = tool_event_updates(&event, &mut seen); + assert_eq!(second.len(), 1); + assert!(matches!(second[0], SessionUpdate::ToolCallUpdate(_))); + } + + #[test] + fn completed_event_maps_to_completed_update_with_output() { + let mut seen = HashSet::new(); + let event = ToolEventData::Completed { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + result: serde_json::json!({ "stdout": "ok" }), + result_for_assistant: Some("done".to_string()), + duration_ms: 42, + }; + + let updates = tool_event_updates(&event, &mut seen); + let SessionUpdate::ToolCallUpdate(update) = &updates[1] else { + panic!("expected tool call update"); + }; + + assert_eq!(update.fields.status, Some(ToolCallStatus::Completed)); + assert_eq!( + update.fields.raw_output, + Some(serde_json::json!({ "stdout": "ok" })) + ); + } + + #[test] + fn permission_request_exposes_allow_and_reject_once() { + let request = permission_request( + "session-1", + "tool-1", + "FileWrite", + &serde_json::json!({ "path": "a.txt" }), + ); + + assert_eq!(request.options.len(), 2); + assert_eq!( + request.options[0].option_id.to_string(), + PERMISSION_ALLOW_ONCE + ); + assert_eq!(request.options[0].kind, PermissionOptionKind::AllowOnce); + assert_eq!( + request.options[1].option_id.to_string(), + PERMISSION_REJECT_ONCE + ); + assert_eq!(request.options[1].kind, PermissionOptionKind::RejectOnce); + } +} diff --git a/src/crates/acp/src/runtime/mcp.rs b/src/crates/acp/src/runtime/mcp.rs new file mode 100644 index 000000000..a69810b63 --- /dev/null +++ b/src/crates/acp/src/runtime/mcp.rs @@ -0,0 +1,173 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use agent_client_protocol::schema::{McpServer, McpServerSse, McpServerStdio}; +use agent_client_protocol::{Error, Result}; +use bitfun_core::service::config::get_global_config_service; +use bitfun_core::service::mcp::{ + get_global_mcp_service, set_global_mcp_service, ConfigLocation, MCPServerConfig, + MCPServerManager, MCPServerTransport, MCPServerType, MCPService, +}; + +use super::BitfunAcpRuntime; + +impl BitfunAcpRuntime { + pub(super) async fn provision_mcp_servers( + &self, + acp_session_id: &str, + servers: Vec, + ) -> Result> { + if servers.is_empty() { + return Ok(Vec::new()); + } + + let manager = mcp_server_manager().await?; + let mut server_ids: Vec = Vec::with_capacity(servers.len()); + + for server in servers { + let config = acp_mcp_server_config(acp_session_id, server)?; + let server_id = config.id.clone(); + + if let Err(error) = manager.add_ephemeral_server(config).await { + for provisioned_id in &server_ids { + let _ = manager.remove_ephemeral_server(provisioned_id).await; + } + return Err(Self::internal_error(error)); + } + + server_ids.push(server_id); + } + + Ok(server_ids) + } +} + +async fn mcp_server_manager() -> Result> { + if let Some(service) = get_global_mcp_service() { + return Ok(service.server_manager()); + } + + let config_service = get_global_config_service() + .await + .map_err(BitfunAcpRuntime::internal_error)?; + let service = + Arc::new(MCPService::new(config_service).map_err(BitfunAcpRuntime::internal_error)?); + set_global_mcp_service(service.clone()); + Ok(service.server_manager()) +} + +fn acp_mcp_server_config(acp_session_id: &str, server: McpServer) -> Result { + match server { + McpServer::Stdio(server) => stdio_server_config(acp_session_id, server), + McpServer::Http(server) => remote_server_config( + acp_session_id, + server.name, + server.url, + header_map(server.headers), + MCPServerTransport::StreamableHttp, + ), + McpServer::Sse(server) => sse_server_config(acp_session_id, server), + _ => Err(Error::invalid_params().data("unsupported MCP server transport")), + } +} + +fn stdio_server_config(acp_session_id: &str, server: McpServerStdio) -> Result { + let name = clean_server_name(&server.name)?; + Ok(MCPServerConfig { + id: ephemeral_server_id(acp_session_id, &name), + name, + server_type: MCPServerType::Local, + transport: Some(MCPServerTransport::Stdio), + command: Some(server.command.to_string_lossy().to_string()), + args: server.args, + env: server + .env + .into_iter() + .map(|env| (env.name, env.value)) + .collect(), + headers: HashMap::new(), + url: None, + auto_start: true, + enabled: true, + location: ConfigLocation::Project, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth: None, + xaa: None, + }) +} + +fn sse_server_config(acp_session_id: &str, server: McpServerSse) -> Result { + remote_server_config( + acp_session_id, + server.name, + server.url, + header_map(server.headers), + MCPServerTransport::Sse, + ) +} + +fn remote_server_config( + acp_session_id: &str, + name: String, + url: String, + headers: HashMap, + transport: MCPServerTransport, +) -> Result { + let name = clean_server_name(&name)?; + Ok(MCPServerConfig { + id: ephemeral_server_id(acp_session_id, &name), + name, + server_type: MCPServerType::Remote, + transport: Some(transport), + command: None, + args: Vec::new(), + env: HashMap::new(), + headers, + url: Some(url), + auto_start: true, + enabled: true, + location: ConfigLocation::Project, + capabilities: Vec::new(), + settings: HashMap::new(), + oauth: None, + xaa: None, + }) +} + +fn header_map(headers: Vec) -> HashMap { + headers + .into_iter() + .map(|header| (header.name, header.value)) + .collect() +} + +fn clean_server_name(name: &str) -> Result { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err(Error::invalid_params().data("MCP server name cannot be empty")); + } + Ok(trimmed.to_string()) +} + +fn ephemeral_server_id(acp_session_id: &str, server_name: &str) -> String { + format!( + "acp-{}-{}", + sanitize_id_part(acp_session_id), + sanitize_id_part(server_name) + ) +} + +fn sanitize_id_part(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect::(); + sanitized.trim_matches('-').to_string() +} diff --git a/src/crates/acp/src/runtime/model.rs b/src/crates/acp/src/runtime/model.rs new file mode 100644 index 000000000..8bfa3d272 --- /dev/null +++ b/src/crates/acp/src/runtime/model.rs @@ -0,0 +1,267 @@ +use agent_client_protocol::schema::{ + ModelInfo, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption, + SessionModelState, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, + SetSessionModelRequest, SetSessionModelResponse, +}; +use agent_client_protocol::{Error, Result}; +use bitfun_core::agentic::agents::get_agent_registry; +use bitfun_core::service::config::types::AIConfig; +use bitfun_core::service::config::{GlobalConfig, GlobalConfigManager}; + +use super::BitfunAcpRuntime; + +const AUTO_MODEL_ID: &str = "auto"; +const MODEL_CONFIG_ID: &str = "model"; +const MODE_CONFIG_ID: &str = "mode"; + +impl BitfunAcpRuntime { + pub(super) async fn update_session_model( + &self, + request: SetSessionModelRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let model_id = request.model_id.to_string(); + self.set_session_model_id(&session_id, &model_id).await?; + Ok(SetSessionModelResponse::new()) + } + + pub(super) async fn update_session_config_option( + &self, + request: SetSessionConfigOptionRequest, + ) -> Result { + let session_id = request.session_id.to_string(); + let config_id = request.config_id.to_string(); + let value = request + .value + .as_value_id() + .ok_or_else(|| Error::invalid_params().data("config option value must be a string"))? + .to_string(); + + match config_id.as_str() { + MODEL_CONFIG_ID => { + self.set_session_model_id(&session_id, &value).await?; + } + MODE_CONFIG_ID => { + self.update_session_mode_inner(&session_id, &value).await?; + } + _ => { + return Err(Error::invalid_params() + .data(format!("unknown session config option: {}", config_id))); + } + } + + let state = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let model_id = state.model_id.clone(); + let mode_id = state.mode_id.clone(); + drop(state); + + Ok(SetSessionConfigOptionResponse::new( + build_session_config_options(Some(&model_id), Some(&mode_id)).await?, + )) + } + + async fn set_session_model_id(&self, session_id: &str, model_id: &str) -> Result<()> { + let acp_session = self + .sessions + .get(session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.to_string())))?; + let bitfun_session_id = acp_session.bitfun_session_id.clone(); + drop(acp_session); + + let normalized_model_id = normalize_model_selection(model_id).await?; + + self.agentic_system + .coordinator + .update_session_model(&bitfun_session_id, &normalized_model_id) + .await + .map_err(Self::internal_error)?; + + if let Some(mut state) = self.sessions.get_mut(session_id) { + state.model_id = normalized_model_id; + } + + Ok(()) + } +} + +pub(super) fn normalize_session_model_id(model_id: Option<&str>) -> String { + let model_id = model_id.unwrap_or(AUTO_MODEL_ID).trim(); + if model_id.is_empty() { + AUTO_MODEL_ID.to_string() + } else { + model_id.to_string() + } +} + +pub(super) async fn build_session_model_state( + preferred_model_id: Option<&str>, +) -> Result { + let ai_config = load_ai_config().await?; + let current_model_id = current_model_id(&ai_config, preferred_model_id); + let available_models = available_model_infos(&ai_config); + Ok(SessionModelState::new(current_model_id, available_models)) +} + +pub(super) async fn build_session_config_options( + preferred_model_id: Option<&str>, + preferred_mode_id: Option<&str>, +) -> Result> { + let ai_config = load_ai_config().await?; + let current_model_id = current_model_id(&ai_config, preferred_model_id); + let model_options = available_model_select_options(&ai_config); + + let mode_infos = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .filter(|info| info.enabled) + .collect::>(); + let current_mode_id = preferred_mode_id + .and_then(|preferred| { + mode_infos + .iter() + .find(|mode| mode.id == preferred) + .map(|mode| mode.id.clone()) + }) + .or_else(|| { + mode_infos + .iter() + .find(|mode| mode.id == "agentic") + .or_else(|| mode_infos.first()) + .map(|mode| mode.id.clone()) + }) + .unwrap_or_else(|| "agentic".to_string()); + let mode_options = mode_infos + .into_iter() + .map(|mode| { + SessionConfigSelectOption::new(mode.id, mode.name).description(mode.description) + }) + .collect::>(); + + Ok(vec![ + SessionConfigOption::select(MODEL_CONFIG_ID, "Model", current_model_id, model_options) + .description("AI model used for this session") + .category(SessionConfigOptionCategory::Model), + SessionConfigOption::select(MODE_CONFIG_ID, "Mode", current_mode_id, mode_options) + .description("Agent mode used for this session") + .category(SessionConfigOptionCategory::Mode), + ]) +} + +async fn normalize_model_selection(model_id: &str) -> Result { + let model_id = normalize_session_model_id(Some(model_id)); + if model_id == AUTO_MODEL_ID { + return Ok(model_id); + } + + let ai_config = load_ai_config().await?; + ai_config.resolve_model_reference(&model_id).ok_or_else(|| { + Error::invalid_params().data(format!("unknown or disabled session model: {}", model_id)) + }) +} + +fn current_model_id(ai_config: &AIConfig, preferred_model_id: Option<&str>) -> String { + let preferred_model_id = normalize_session_model_id(preferred_model_id); + if preferred_model_id == AUTO_MODEL_ID { + return preferred_model_id; + } + + ai_config + .resolve_model_reference(&preferred_model_id) + .unwrap_or_else(|| AUTO_MODEL_ID.to_string()) +} + +fn available_model_infos(ai_config: &AIConfig) -> Vec { + let mut models = Vec::with_capacity(ai_config.models.len() + 1); + models.push(ModelInfo::new(AUTO_MODEL_ID, "Auto").description("Use the mode default model")); + models.extend( + ai_config + .models + .iter() + .filter(|model| model.enabled) + .map(|model| ModelInfo::new(model.id.clone(), model_display_name(model))), + ); + models +} + +fn available_model_select_options(ai_config: &AIConfig) -> Vec { + let mut options = Vec::with_capacity(ai_config.models.len() + 1); + options.push( + SessionConfigSelectOption::new(AUTO_MODEL_ID, "Auto") + .description("Use the mode default model"), + ); + options.extend( + ai_config + .models + .iter() + .filter(|model| model.enabled) + .map(|model| { + SessionConfigSelectOption::new(model.id.clone(), model_display_name(model)) + .description(format!("{} / {}", model.provider, model.model_name)) + }), + ); + options +} + +fn model_display_name(model: &bitfun_core::service::config::types::AIModelConfig) -> String { + if model.name.trim().is_empty() { + format!("{} / {}", model.provider, model.model_name) + } else { + model.name.clone() + } +} + +async fn load_ai_config() -> Result { + let config_service = GlobalConfigManager::get_service() + .await + .map_err(BitfunAcpRuntime::internal_error)?; + let global_config = config_service + .get_config::(None) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + Ok(global_config.ai) +} + +#[cfg(test)] +mod tests { + use super::{current_model_id, normalize_session_model_id, AUTO_MODEL_ID}; + use bitfun_core::service::config::types::{AIConfig, AIModelConfig}; + + #[test] + fn normalize_session_model_defaults_to_auto() { + assert_eq!(normalize_session_model_id(None), AUTO_MODEL_ID); + assert_eq!(normalize_session_model_id(Some("")), AUTO_MODEL_ID); + assert_eq!(normalize_session_model_id(Some(" model-a ")), "model-a"); + } + + #[test] + fn current_model_falls_back_to_auto_for_disabled_model() { + let mut ai_config = AIConfig::default(); + ai_config.models.push(AIModelConfig { + id: "model-a".to_string(), + enabled: false, + ..Default::default() + }); + + assert_eq!(current_model_id(&ai_config, Some("model-a")), AUTO_MODEL_ID); + } + + #[test] + fn current_model_resolves_name_to_model_id() { + let mut ai_config = AIConfig::default(); + ai_config.models.push(AIModelConfig { + id: "model-a".to_string(), + name: "Readable Model".to_string(), + enabled: true, + ..Default::default() + }); + + assert_eq!( + current_model_id(&ai_config, Some("Readable Model")), + "model-a" + ); + } +} diff --git a/src/crates/acp/src/runtime/prompt.rs b/src/crates/acp/src/runtime/prompt.rs new file mode 100644 index 000000000..cdea66fa9 --- /dev/null +++ b/src/crates/acp/src/runtime/prompt.rs @@ -0,0 +1,288 @@ +use std::collections::HashSet; + +use agent_client_protocol::schema::{ + CancelNotification, ContentChunk, PromptRequest, PromptResponse, RequestPermissionOutcome, + SessionUpdate, StopReason, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use bitfun_core::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; +use bitfun_core::agentic::events::EventEnvelope; +use bitfun_events::AgenticEvent as CoreEvent; +use log::warn; +use tokio::sync::broadcast; + +use super::content::parse_prompt_blocks; +use super::events::{ + permission_request, send_update, tool_event_updates, PERMISSION_ALLOW_ONCE, + PERMISSION_REJECT_ONCE, +}; +use super::thinking::{InlineThinkRouter, InlineThinkSegment}; +use super::BitfunAcpRuntime; + +impl BitfunAcpRuntime { + pub(super) async fn run_prompt(&self, request: PromptRequest) -> Result { + let session_id = request.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + let connection = self + .connections + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))? + .clone(); + + let parsed_prompt = parse_prompt_blocks(&session_id, request.prompt); + + if parsed_prompt.user_message.trim().is_empty() && parsed_prompt.image_contexts.is_empty() { + return Err(Error::invalid_params().data("empty prompt")); + } + + let mut event_rx = self.agentic_system.event_queue.subscribe(); + if parsed_prompt.image_contexts.is_empty() { + self.agentic_system + .coordinator + .start_dialog_turn( + acp_session.bitfun_session_id.clone(), + parsed_prompt.user_message, + parsed_prompt.original_user_message, + None, + acp_session.mode_id.clone(), + Some(acp_session.cwd.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + ) + .await + .map_err(Self::internal_error)?; + } else { + self.agentic_system + .coordinator + .start_dialog_turn_with_image_contexts( + acp_session.bitfun_session_id.clone(), + parsed_prompt.user_message, + parsed_prompt.original_user_message, + parsed_prompt.image_contexts, + None, + acp_session.mode_id.clone(), + Some(acp_session.cwd.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + ) + .await + .map_err(Self::internal_error)?; + } + + let stop_reason = wait_for_prompt_completion( + self, + &mut event_rx, + &connection, + &acp_session.acp_session_id, + &acp_session.bitfun_session_id, + ) + .await?; + + Ok(PromptResponse::new(stop_reason)) + } + + pub(super) async fn cancel_prompt(&self, notification: CancelNotification) -> Result<()> { + let session_id = notification.session_id.to_string(); + let acp_session = self + .sessions + .get(&session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.clone())))?; + let acp_session = acp_session.clone(); + + self.agentic_system + .coordinator + .cancel_active_turn_for_session( + &acp_session.bitfun_session_id, + std::time::Duration::from_secs(5), + ) + .await + .map_err(Self::internal_error)?; + + Ok(()) + } +} + +async fn wait_for_prompt_completion( + runtime: &BitfunAcpRuntime, + event_rx: &mut broadcast::Receiver, + connection: &ConnectionTo, + acp_session_id: &str, + bitfun_session_id: &str, +) -> Result { + let mut seen_tool_calls = HashSet::new(); + let mut inline_think = InlineThinkRouter::new(); + + loop { + let event = match event_rx.recv().await { + Ok(envelope) => envelope.event, + Err(broadcast::error::RecvError::Lagged(count)) => { + warn!("ACP event receiver lagged: skipped {} events", count); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + return Err(Error::internal_error().data("event stream closed")); + } + }; + + if event.session_id() != Some(bitfun_session_id) { + continue; + } + + match event { + CoreEvent::TextChunk { text, .. } => { + send_inline_think_segments( + connection, + acp_session_id, + inline_think.route_text(text), + )?; + } + CoreEvent::ThinkingChunk { content, .. } => { + send_update( + connection, + acp_session_id, + SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())), + )?; + } + CoreEvent::ToolEvent { tool_event, .. } => { + for update in tool_event_updates(&tool_event, &mut seen_tool_calls) { + send_update(connection, acp_session_id, update)?; + } + + if let bitfun_events::ToolEventData::ConfirmationNeeded { + tool_id, + tool_name, + params, + } = tool_event + { + handle_permission_request( + runtime, + connection, + acp_session_id, + &tool_id, + &tool_name, + ¶ms, + ) + .await?; + } + } + CoreEvent::DialogTurnCompleted { .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; + return Ok(StopReason::EndTurn); + } + CoreEvent::DialogTurnCancelled { .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; + return Ok(StopReason::Cancelled); + } + CoreEvent::DialogTurnFailed { error, .. } | CoreEvent::SystemError { error, .. } => { + send_inline_think_segments(connection, acp_session_id, inline_think.flush())?; + send_update( + connection, + acp_session_id, + SessionUpdate::AgentMessageChunk(ContentChunk::new( + format!("Error: {}", error).into(), + )), + )?; + return Err(Error::internal_error().data(serde_json::json!(error))); + } + _ => {} + } + } +} + +fn send_inline_think_segments( + connection: &ConnectionTo, + acp_session_id: &str, + segments: Vec, +) -> Result<()> { + for segment in segments { + let update = match segment { + InlineThinkSegment::Text(text) => { + SessionUpdate::AgentMessageChunk(ContentChunk::new(text.into())) + } + InlineThinkSegment::Thinking(content) => { + SessionUpdate::AgentThoughtChunk(ContentChunk::new(content.into())) + } + }; + send_update(connection, acp_session_id, update)?; + } + + Ok(()) +} + +async fn handle_permission_request( + runtime: &BitfunAcpRuntime, + connection: &ConnectionTo, + acp_session_id: &str, + tool_id: &str, + tool_name: &str, + params: &serde_json::Value, +) -> Result<()> { + let request = permission_request(acp_session_id, tool_id, tool_name, params); + let response = match connection.send_request(request).block_task().await { + Ok(response) => response, + Err(error) => { + let reason = format!("ACP permission request failed: {}", error); + let _ = runtime + .agentic_system + .coordinator + .reject_tool(tool_id, reason.clone()) + .await; + return Err(error); + } + }; + + match response.outcome { + RequestPermissionOutcome::Selected(selected) + if selected.option_id.to_string() == PERMISSION_ALLOW_ONCE => + { + runtime + .agentic_system + .coordinator + .confirm_tool(tool_id, None) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Selected(selected) + if selected.option_id.to_string() == PERMISSION_REJECT_ONCE => + { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "Rejected by ACP client".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Cancelled => { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "ACP permission request cancelled".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + RequestPermissionOutcome::Selected(selected) => { + let reason = format!( + "Unknown ACP permission option selected: {}", + selected.option_id + ); + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, reason) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + _ => { + runtime + .agentic_system + .coordinator + .reject_tool(tool_id, "Unsupported ACP permission outcome".to_string()) + .await + .map_err(BitfunAcpRuntime::internal_error)?; + } + } + + Ok(()) +} diff --git a/src/crates/acp/src/runtime/session.rs b/src/crates/acp/src/runtime/session.rs new file mode 100644 index 000000000..3a74b9d85 --- /dev/null +++ b/src/crates/acp/src/runtime/session.rs @@ -0,0 +1,266 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use agent_client_protocol::schema::{ + CurrentModeUpdate, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, SessionId, SessionInfo, + SessionMode, SessionModeState, SessionUpdate, SetSessionModeRequest, SetSessionModeResponse, +}; +use agent_client_protocol::{Client, ConnectionTo, Error, Result}; +use bitfun_core::agentic::agents::get_agent_registry; +use bitfun_core::agentic::core::SessionConfig; +use chrono::{DateTime, Utc}; + +use super::events::send_update; +use super::model::{ + build_session_config_options, build_session_model_state, normalize_session_model_id, +}; +use super::{AcpSessionState, BitfunAcpRuntime}; + +impl BitfunAcpRuntime { + pub(super) async fn create_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let mcp_servers = request.mcp_servers; + let session = self + .agentic_system + .coordinator + .create_session( + format!( + "ACP Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(cwd.clone()), + ..Default::default() + }, + ) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + model_id: normalize_session_model_id(session.config.model_id.as_deref()), + mcp_server_ids: self + .provision_mcp_servers(&session.session_id, mcp_servers) + .await?, + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + let models = build_session_model_state(Some(&acp_session.model_id)).await?; + let config_options = + build_session_config_options(Some(&acp_session.model_id), Some(&acp_session.mode_id)) + .await?; + Ok( + NewSessionResponse::new(SessionId::new(acp_session.acp_session_id)) + .modes(modes) + .models(models) + .config_options(config_options), + ) + } + + pub(super) async fn restore_session( + &self, + request: LoadSessionRequest, + connection: ConnectionTo, + ) -> Result { + let cwd = request.cwd.to_string_lossy().to_string(); + let session_id = request.session_id.to_string(); + let mcp_servers = request.mcp_servers; + let session = self + .agentic_system + .coordinator + .restore_session(Path::new(&cwd), &session_id) + .await + .map_err(Self::internal_error)?; + + let acp_session = AcpSessionState { + acp_session_id: session.session_id.clone(), + bitfun_session_id: session.session_id.clone(), + cwd, + mode_id: session.agent_type.clone(), + model_id: normalize_session_model_id(session.config.model_id.as_deref()), + mcp_server_ids: self + .provision_mcp_servers(&session.session_id, mcp_servers) + .await?, + }; + self.sessions + .insert(acp_session.acp_session_id.clone(), acp_session.clone()); + self.connections + .insert(acp_session.acp_session_id.clone(), connection); + + let modes = build_session_modes(Some(session.agent_type.as_str())).await; + let models = build_session_model_state(Some(&acp_session.model_id)).await?; + let config_options = + build_session_config_options(Some(&acp_session.model_id), Some(&acp_session.mode_id)) + .await?; + Ok(LoadSessionResponse::new() + .modes(modes) + .models(models) + .config_options(config_options)) + } + + pub(super) async fn list_sessions_for_cwd( + &self, + request: ListSessionsRequest, + ) -> Result { + let cwd = request + .cwd + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| Error::invalid_params().data("cwd is required"))?; + let cursor = request + .cursor + .as_deref() + .and_then(|value| value.parse::().ok()); + + let mut summaries = self + .agentic_system + .coordinator + .list_sessions(&cwd) + .await + .map_err(Self::internal_error)?; + summaries.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + let limit = 100usize; + let filtered = summaries + .into_iter() + .filter(|summary| { + cursor + .map(|cursor| system_time_to_unix_ms(summary.last_activity_at) < cursor) + .unwrap_or(true) + }) + .collect::>(); + + let sessions = filtered + .iter() + .take(limit) + .map(|summary| { + SessionInfo::new( + SessionId::new(summary.session_id.clone()), + Path::new(&cwd).to_path_buf(), + ) + .title(summary.session_name.clone()) + .updated_at(system_time_to_rfc3339(summary.last_activity_at)) + }) + .collect::>(); + + let next_cursor = if filtered.len() > limit { + filtered + .get(limit - 1) + .map(|summary| system_time_to_unix_ms(summary.last_activity_at).to_string()) + } else { + None + }; + + Ok(ListSessionsResponse::new(sessions).next_cursor(next_cursor)) + } + + pub(super) async fn update_session_mode( + &self, + request: SetSessionModeRequest, + ) -> Result { + let mode_id = request.mode_id.to_string(); + self.update_session_mode_inner(&request.session_id.to_string(), &mode_id) + .await?; + + Ok(SetSessionModeResponse::new()) + } + + pub(super) async fn update_session_mode_inner( + &self, + session_id: &str, + mode_id: &str, + ) -> Result<()> { + let acp_session = self + .sessions + .get(session_id) + .ok_or_else(|| Error::resource_not_found(Some(session_id.to_string())))?; + let bitfun_session_id = acp_session.bitfun_session_id.clone(); + drop(acp_session); + + validate_mode_id(mode_id).await?; + + self.agentic_system + .coordinator + .update_session_agent_type(&bitfun_session_id, mode_id) + .await + .map_err(Self::internal_error)?; + + if let Some(mut state) = self.sessions.get_mut(session_id) { + state.mode_id = mode_id.to_string(); + } + + if let Some(connection) = self.connections.get(session_id) { + send_update( + &connection, + session_id, + SessionUpdate::CurrentModeUpdate(CurrentModeUpdate::new(mode_id.to_string())), + )?; + } + + Ok(()) + } +} + +async fn build_session_modes(preferred_mode_id: Option<&str>) -> SessionModeState { + let available_modes = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .filter(|info| info.enabled) + .map(|info| SessionMode::new(info.id, info.name).description(info.description)) + .collect::>(); + + let current_mode_id = preferred_mode_id + .and_then(|preferred| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == preferred) + .map(|mode| mode.id.clone()) + }) + .or_else(|| { + available_modes + .iter() + .find(|mode| mode.id.to_string() == "agentic") + .or_else(|| available_modes.first()) + .map(|mode| mode.id.clone()) + }) + .unwrap_or_else(|| "agentic".into()); + + SessionModeState::new(current_mode_id, available_modes) +} + +async fn validate_mode_id(mode_id: &str) -> Result<()> { + let mode_exists = get_agent_registry() + .get_modes_info() + .await + .into_iter() + .any(|info| info.enabled && info.id == mode_id); + + if mode_exists { + Ok(()) + } else { + Err(Error::invalid_params().data(format!("unknown session mode: {}", mode_id))) + } +} + +fn system_time_to_unix_ms(time: SystemTime) -> u128 { + time.duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or_default() +} + +fn system_time_to_rfc3339(time: SystemTime) -> String { + DateTime::::from(time).to_rfc3339() +} diff --git a/src/crates/acp/src/runtime/thinking.rs b/src/crates/acp/src/runtime/thinking.rs new file mode 100644 index 000000000..dd1d61821 --- /dev/null +++ b/src/crates/acp/src/runtime/thinking.rs @@ -0,0 +1,222 @@ +use std::mem; + +const INLINE_THINK_OPEN_TAG: &str = ""; +const INLINE_THINK_CLOSE_TAG: &str = ""; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Activation { + Unknown, + Enabled, + Disabled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Text, + Thinking, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum InlineThinkSegment { + Text(String), + Thinking(String), +} + +#[derive(Debug)] +pub(crate) struct InlineThinkRouter { + activation: Activation, + mode: Mode, + pending_tail: String, + initial_probe: String, +} + +impl InlineThinkRouter { + pub(crate) fn new() -> Self { + Self { + activation: Activation::Unknown, + mode: Mode::Text, + pending_tail: String::new(), + initial_probe: String::new(), + } + } + + pub(crate) fn route_text(&mut self, text: String) -> Vec { + match self.activation { + Activation::Unknown => self.consume_unknown_text(text), + Activation::Enabled => self.parse_enabled_text(text), + Activation::Disabled => vec![InlineThinkSegment::Text(text)], + } + } + + pub(crate) fn flush(&mut self) -> Vec { + match self.activation { + Activation::Unknown => { + let pending = mem::take(&mut self.initial_probe); + if pending.is_empty() { + Vec::new() + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + Activation::Enabled => { + let pending = mem::take(&mut self.pending_tail); + if pending.is_empty() { + Vec::new() + } else if self.mode == Mode::Thinking { + vec![InlineThinkSegment::Thinking(pending)] + } else { + vec![InlineThinkSegment::Text(pending)] + } + } + Activation::Disabled => Vec::new(), + } + } + + fn consume_unknown_text(&mut self, text: String) -> Vec { + self.initial_probe.push_str(&text); + + let trimmed = self.initial_probe.trim_start_matches(char::is_whitespace); + if trimmed.is_empty() { + return Vec::new(); + } + + if trimmed.starts_with(INLINE_THINK_OPEN_TAG) { + self.activation = Activation::Enabled; + let buffered = mem::take(&mut self.initial_probe); + return self.parse_enabled_text(buffered); + } + + if INLINE_THINK_OPEN_TAG.starts_with(trimmed) { + return Vec::new(); + } + + self.activation = Activation::Disabled; + vec![InlineThinkSegment::Text(mem::take(&mut self.initial_probe))] + } + + fn parse_enabled_text(&mut self, text: String) -> Vec { + let mut data = mem::take(&mut self.pending_tail); + data.push_str(&text); + + let mut segments = Vec::new(); + + loop { + let marker = match self.mode { + Mode::Text => INLINE_THINK_OPEN_TAG, + Mode::Thinking => INLINE_THINK_CLOSE_TAG, + }; + + if let Some(marker_idx) = data.find(marker) { + let before_marker = data[..marker_idx].to_string(); + self.push_segment(&mut segments, before_marker); + + data = data[marker_idx + marker.len()..].to_string(); + self.mode = match self.mode { + Mode::Text => Mode::Thinking, + Mode::Thinking => Mode::Text, + }; + continue; + } + + let tail_len = longest_suffix_prefix_len(&data, marker); + let flush_len = data.len() - tail_len; + let ready = data[..flush_len].to_string(); + self.push_segment(&mut segments, ready); + self.pending_tail = data[flush_len..].to_string(); + break; + } + + segments + } + + fn push_segment(&self, segments: &mut Vec, content: String) { + if content.is_empty() { + return; + } + + match self.mode { + Mode::Text => segments.push(InlineThinkSegment::Text(content)), + Mode::Thinking => segments.push(InlineThinkSegment::Thinking(content)), + } + } +} + +fn longest_suffix_prefix_len(value: &str, marker: &str) -> usize { + let max_len = value.len().min(marker.len().saturating_sub(1)); + (1..=max_len) + .rev() + .find(|&len| value.ends_with(&marker[..len])) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::{InlineThinkRouter, InlineThinkSegment}; + + #[test] + fn routes_initial_inline_thinking_to_thought_segments() { + let mut router = InlineThinkRouter::new(); + + let first = router.route_text("abc".to_string()); + let second = router.route_text("defghi".to_string()); + + assert_eq!(first, vec![InlineThinkSegment::Thinking("abc".to_string())]); + assert_eq!( + second, + vec![ + InlineThinkSegment::Thinking("def".to_string()), + InlineThinkSegment::Text("ghi".to_string()) + ] + ); + } + + #[test] + fn handles_split_opening_tag() { + let mut router = InlineThinkRouter::new(); + + assert!(router.route_text("hiddenvisible".to_string()), + vec![ + InlineThinkSegment::Thinking("hidden".to_string()), + InlineThinkSegment::Text("visible".to_string()) + ] + ); + } + + #[test] + fn leaves_non_initial_tags_as_message_text() { + let mut router = InlineThinkRouter::new(); + + assert_eq!( + router.route_text("hello literal".to_string()), + vec![InlineThinkSegment::Text("hello literal".to_string())] + ); + assert_eq!( + router.route_text(" world".to_string()), + vec![InlineThinkSegment::Text(" world".to_string())] + ); + } + + #[test] + fn flushes_unclosed_thinking_without_tags() { + let mut router = InlineThinkRouter::new(); + + assert_eq!( + router.route_text("abc".to_string()), + vec![InlineThinkSegment::Thinking("abc".to_string())] + ); + assert!(router.flush().is_empty()); + } + + #[test] + fn flushes_unknown_probe_as_text() { + let mut router = InlineThinkRouter::new(); + + assert!(router.route_text(" ".to_string()).is_empty()); + assert_eq!( + router.flush(), + vec![InlineThinkSegment::Text(" ".to_string())] + ); + } +} diff --git a/src/crates/acp/src/server.rs b/src/crates/acp/src/server.rs new file mode 100644 index 000000000..23a16a28b --- /dev/null +++ b/src/crates/acp/src/server.rs @@ -0,0 +1,247 @@ +use std::sync::Arc; + +use agent_client_protocol::schema::{ + AuthenticateRequest, AuthenticateResponse, CancelNotification, InitializeRequest, + InitializeResponse, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, + LoadSessionResponse, NewSessionRequest, NewSessionResponse, PromptRequest, PromptResponse, + SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest, + SetSessionModeResponse, SetSessionModelRequest, SetSessionModelResponse, +}; +use agent_client_protocol::{ + Agent, ByteStreams, Client, ConnectTo, ConnectionTo, Dispatch, Error, Result, +}; +use async_trait::async_trait; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +/// Runtime operations needed by the ACP protocol layer. +#[async_trait] +pub trait AcpRuntime: Send + Sync + 'static { + async fn initialize(&self, request: InitializeRequest) -> Result; + + async fn authenticate(&self, _request: AuthenticateRequest) -> Result { + Ok(AuthenticateResponse::new()) + } + + async fn new_session( + &self, + request: NewSessionRequest, + connection: ConnectionTo, + ) -> Result; + + async fn load_session( + &self, + _request: LoadSessionRequest, + _connection: ConnectionTo, + ) -> Result { + Err(Error::method_not_found().data("session/load is not implemented")) + } + + async fn list_sessions(&self, request: ListSessionsRequest) -> Result; + + async fn prompt(&self, request: PromptRequest) -> Result; + + async fn cancel(&self, notification: CancelNotification) -> Result<()>; + + async fn set_session_mode( + &self, + _request: SetSessionModeRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_mode is not implemented")) + } + + async fn set_session_config_option( + &self, + _request: SetSessionConfigOptionRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_config_option is not implemented")) + } + + async fn set_session_model( + &self, + _request: SetSessionModelRequest, + ) -> Result { + Err(Error::method_not_found().data("session/set_model is not implemented")) + } +} + +/// Typed ACP server backed by an injected BitFun runtime. +pub struct AcpServer { + runtime: Arc, +} + +impl AcpServer +where + R: AcpRuntime, +{ + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } + + pub async fn serve_stdio(self) -> Result<()> { + let stdin = tokio::io::stdin().compat(); + let stdout = tokio::io::stdout().compat_write(); + self.serve(ByteStreams::new(stdout, stdin)).await + } + + pub async fn serve(self, transport: impl ConnectTo + 'static) -> Result<()> { + let runtime = self.runtime; + + Agent + .builder() + .name("bitfun-acp") + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: InitializeRequest, responder, _cx| { + responder.respond_with_result(runtime.initialize(request).await) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: AuthenticateRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.authenticate(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: NewSessionRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + let session_cx = cx.clone(); + cx.spawn(async move { + responder + .respond_with_result(runtime.new_session(request, session_cx).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: LoadSessionRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + let session_cx = cx.clone(); + cx.spawn(async move { + responder.respond_with_result( + runtime.load_session(request, session_cx).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: ListSessionsRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.list_sessions(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: PromptRequest, responder, cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.prompt(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_notification( + { + let runtime = runtime.clone(); + async move |notification: CancelNotification, cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + if let Err(error) = runtime.cancel(notification).await { + log::error!("Error handling ACP cancel notification: {:?}", error); + } + Ok(()) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionModeRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.set_session_mode(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionConfigOptionRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result( + runtime.set_session_config_option(request).await, + ) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_request( + { + let runtime = runtime.clone(); + async move |request: SetSessionModelRequest, + responder, + cx: ConnectionTo| { + let runtime = runtime.clone(); + cx.spawn(async move { + responder.respond_with_result(runtime.set_session_model(request).await) + })?; + Ok(()) + } + }, + agent_client_protocol::on_receive_request!(), + ) + .on_receive_dispatch( + async move |message: Dispatch, cx: ConnectionTo| { + message.respond_with_error(Error::method_not_found(), cx) + }, + agent_client_protocol::on_receive_dispatch!(), + ) + .connect_to(transport) + .await + } +} diff --git a/src/crates/ai-adapters/src/client/sse.rs b/src/crates/ai-adapters/src/client/sse.rs index 01e638bb2..d2445f415 100644 --- a/src/crates/ai-adapters/src/client/sse.rs +++ b/src/crates/ai-adapters/src/client/sse.rs @@ -2,9 +2,53 @@ use crate::client::utils::elapsed_ms_u64; use crate::client::StreamResponse; use crate::stream::UnifiedResponse; use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; use log::{debug, error, warn}; +use reqwest::{ + header::{HeaderMap, RETRY_AFTER}, + StatusCode, +}; use tokio::sync::mpsc; +const BASE_RETRY_DELAY_MS: u64 = 500; +const MAX_RETRY_AFTER_DELAY_MS: u64 = 30_000; + +fn is_retryable_http_status(status: StatusCode) -> bool { + status.is_server_error() || matches!(status.as_u16(), 408 | 409 | 425 | 429) +} + +fn exponential_retry_delay_ms(attempt: usize) -> u64 { + BASE_RETRY_DELAY_MS * (1 << attempt.min(3)) +} + +fn retry_after_delay_ms(headers: &HeaderMap) -> Option { + let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim(); + + if let Ok(seconds) = value.parse::() { + return Some(seconds.saturating_mul(1000).min(MAX_RETRY_AFTER_DELAY_MS)); + } + + let retry_at = DateTime::parse_from_rfc2822(value) + .ok()? + .with_timezone(&Utc); + let now = Utc::now(); + if retry_at <= now { + return Some(0); + } + + Some( + retry_at + .signed_duration_since(now) + .num_milliseconds() + .max(0) as u64, + ) + .map(|delay| delay.min(MAX_RETRY_AFTER_DELAY_MS)) +} + +fn retry_delay_ms(attempt: usize, headers: &HeaderMap) -> u64 { + retry_after_delay_ms(headers).unwrap_or_else(|| exponential_retry_delay_ms(attempt)) +} + pub(crate) async fn execute_sse_request( label: &str, _url: &str, @@ -22,8 +66,6 @@ where ), { let mut last_error = None; - let base_wait_time_ms = 500; - for attempt in 0..max_tries { let request_start_time = std::time::Instant::now(); let response_result = build_request().json(request_body).send().await; @@ -32,8 +74,9 @@ where Ok(resp) => { let connect_time = elapsed_ms_u64(request_start_time); let status = resp.status(); + let headers = resp.headers().clone(); - if status.is_client_error() { + if status.is_client_error() && !is_retryable_http_status(status) { let error_text = resp .text() .await @@ -69,12 +112,13 @@ where last_error = Some(error); if attempt < max_tries - 1 { - let delay_ms = base_wait_time_ms * (1 << attempt.min(3)); + let delay_ms = retry_delay_ms(attempt, &headers); debug!( - "Retrying {} after {}ms (attempt {})", + "Retrying {} after {}ms (attempt {}, status {})", label, delay_ms, - attempt + 2 + attempt + 2, + status ); tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; } @@ -95,7 +139,7 @@ where last_error = Some(error); if attempt < max_tries - 1 { - let delay_ms = base_wait_time_ms * (1 << attempt.min(3)); + let delay_ms = exponential_retry_delay_ms(attempt); debug!( "Retrying {} after {}ms (attempt {})", label, @@ -127,3 +171,41 @@ where error!("{}", error_msg); Err(anyhow!(error_msg)) } + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn retryable_http_statuses_include_rate_limit_and_server_errors() { + assert!(is_retryable_http_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(is_retryable_http_status(StatusCode::REQUEST_TIMEOUT)); + assert!(is_retryable_http_status(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(is_retryable_http_status(StatusCode::BAD_GATEWAY)); + + assert!(!is_retryable_http_status(StatusCode::UNAUTHORIZED)); + assert!(!is_retryable_http_status(StatusCode::BAD_REQUEST)); + assert!(!is_retryable_http_status(StatusCode::NOT_FOUND)); + } + + #[test] + fn retry_after_seconds_is_capped() { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_static("120")); + + assert_eq!( + retry_after_delay_ms(&headers), + Some(MAX_RETRY_AFTER_DELAY_MS) + ); + } + + #[test] + fn retry_delay_falls_back_to_exponential_backoff() { + let headers = HeaderMap::new(); + + assert_eq!(retry_delay_ms(0, &headers), 500); + assert_eq!(retry_delay_ms(1, &headers), 1000); + assert_eq!(retry_delay_ms(4, &headers), 4000); + } +} diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index c4d95190b..3034219bb 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -47,8 +47,3 @@ cargo test -p bitfun-core -- --nocapture ```bash cargo check --workspace && cargo test --workspace ``` - -额外规则: - -- 如果修改 `src/crates/ai-adapters`,需要运行 `src/crates/core/tests` 中的 stream integration tests -- 如果修改 `src/agentic/execution/stream_processor.rs`,结束前需要运行 stream integration tests diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 685d0d504..040f515a5 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -47,8 +47,3 @@ cargo test -p bitfun-core -- --nocapture ```bash cargo check --workspace && cargo test --workspace ``` - -Extra rules: - -- if you modify `src/crates/ai-adapters`, run the stream integration tests in `src/crates/core/tests` -- if you modify `src/agentic/execution/stream_processor.rs`, run the stream integration tests before finishing diff --git a/src/crates/core/builtin_skills/docx/SKILL.md b/src/crates/core/builtin_skills/docx/SKILL.md index ad2e17500..196bc0850 100644 --- a/src/crates/core/builtin_skills/docx/SKILL.md +++ b/src/crates/core/builtin_skills/docx/SKILL.md @@ -300,7 +300,7 @@ Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to Edit files in `unpacked/word/`. See XML Reference below for patterns. -**Use "Claude" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name. +**Use "BitFun" as the author** for tracked changes and comments, unless the user explicitly requests use of a different name. **Use the Edit tool directly for string replacement. Do not write Python scripts.** Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced. @@ -356,14 +356,14 @@ Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate fal **Insertion:** ```xml - + inserted text ``` **Deletion:** ```xml - + deleted text ``` @@ -374,10 +374,10 @@ Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate fal ```xml The term is - + 30 - + 60 days. @@ -389,10 +389,10 @@ Validates with auto-repair, condenses XML, and creates DOCX. Use `--validate fal ... - + - + Entire paragraph content being deleted... @@ -402,7 +402,7 @@ Without the `` in ``, accepting changes leaves an empty pa **Rejecting another author's insertion** - nest deletion inside their insertion: ```xml - + their inserted text @@ -413,7 +413,7 @@ Without the `` in ``, accepting changes leaves an empty pa deleted text - + deleted text ``` @@ -427,7 +427,7 @@ After running `comment.py` (see Step 2), add markers to document.xml. For replie ```xml - + deleted more text diff --git a/src/crates/core/builtin_skills/gstack-autoplan/SKILL.md b/src/crates/core/builtin_skills/gstack-autoplan/SKILL.md index 85c90d0e1..49fa6f4ca 100644 --- a/src/crates/core/builtin_skills/gstack-autoplan/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-autoplan/SKILL.md @@ -52,10 +52,10 @@ Examples: run codex (always yes), run evals (always yes), reduce scope on a comp **Taste** — reasonable people could disagree. Auto-decide with recommendation, but surface at the final gate. Three natural sources: 1. **Close approaches** — top two are both viable with different tradeoffs. 2. **Borderline scope** — in blast radius but 3-5 files, or ambiguous radius. -3. **Codex disagreements** — codex recommends differently and has a valid point. +3. **outside-voice sub-agent disagreements** — codex recommends differently and has a valid point. **User Challenge** — both models agree the user's stated direction should change. -This is qualitatively different from taste decisions. When Claude and Codex both +This is qualitatively different from taste decisions. When BitFun and outside-voice sub-agent both recommend merging, splitting, adding, or removing features/skills/workflows that the user specified, this is a User Challenge. It is NEVER auto-decided. @@ -123,14 +123,14 @@ State what you examined and why nothing was flagged (1-2 sentences minimum). --- -## Filesystem Boundary — Codex Prompts +## Filesystem Boundary — outside-voice sub-agent Prompts -All prompts sent to Codex (via `codex exec` or `codex review`) MUST be prefixed with +All prompts sent to outside-voice sub-agent (via `BitFun Task outside-voice dispatch` or `BitFun Task outside-voice review`) MUST be prefixed with this boundary instruction: > IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Stay focused on the repository code only. -This prevents Codex from discovering gstack skill files on disk and following their +This prevents outside-voice sub-agent from discovering gstack skill files on disk and following their instructions instead of reviewing the plan. --- @@ -142,10 +142,10 @@ instructions instead of reviewing the plan. Before doing anything, save the plan file's current state to an external file: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-') DATETIME=$(date +%Y%m%d-%H%M%S) -echo "RESTORE_PATH=$HOME/.gstack/projects/$SLUG/${BRANCH}-autoplan-restore-${DATETIME}.md" +echo "RESTORE_PATH=$HOME/.bitfun/team/projects/$SLUG/${BRANCH}-autoplan-restore-${DATETIME}.md" ``` Write the plan file's full contents to the restore path with this header: @@ -166,27 +166,27 @@ Then prepend a one-line HTML comment to the plan file: ### Step 2: Read context -- Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Read AGENTS.md, TODOS.md, git log -30, git diff against the base branch --stat +- Discover design docs: `ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). - Detect DX scope: grep the plan for developer-facing terms (API, endpoint, REST, GraphQL, gRPC, webhook, CLI, command, flag, argument, terminal, shell, SDK, library, - package, npm, pip, import, require, SKILL.md, skill template, Claude Code, MCP, agent, + package, npm, pip, import, require, SKILL.md, skill template, BitFun, MCP, agent, OpenClaw, action, developer docs, getting started, onboarding, integration, debug, implement, error message). Require 2+ matches. Also trigger DX scope if the product IS a developer tool (the plan describes something developers install, integrate, or build - on top of) or if an AI agent is the primary user (OpenClaw actions, Claude Code skills, + on top of) or if an AI agent is the primary user (OpenClaw actions, BitFun skills, MCP servers). ### Step 3: Load skill files from disk Read each file using the Read tool: -- `~/.claude/skills/gstack/plan-ceo-review/SKILL.md` -- `~/.claude/skills/gstack/plan-design-review/SKILL.md` (only if UI scope detected) -- `~/.claude/skills/gstack/plan-eng-review/SKILL.md` -- `~/.claude/skills/gstack/plan-devex-review/SKILL.md` (only if DX scope detected) +- `the bundled plan-ceo-review skill via the Skill tool` +- `the bundled plan-design-review skill via the Skill tool` (only if UI scope detected) +- `the bundled plan-eng-review skill via the Skill tool` +- `the relevant built-in developer-experience review methodology, if present` (only if DX scope detected) **Section skip list — when following a loaded skill file, SKIP these sections (they are already handled by /autoplan):** @@ -225,15 +225,15 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - Scope expansion: in blast radius + <1d CC → approve (P2). Outside → defer to TODOS.md (P3). Duplicates → reject (P4). Borderline (3-5 files) → mark TASTE DECISION. - All 10 review sections: run fully, auto-decide each issue, log every decision. -- Dual voices: always run BOTH Claude subagent AND Codex if available (P6). - Run them sequentially in foreground. First the Claude subagent (Agent tool, - foreground — do NOT use run_in_background), then Codex (Bash). Both must +- Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). + Run them sequentially in foreground. First the independent subagent (Task tool, + foreground — do NOT use run_in_background), then outside-voice sub-agent (Bash). Both must complete before building the consensus table. - **Codex CEO voice** (via Bash): + **outside-voice sub-agent CEO voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. You are a CEO/founder advisor reviewing a development plan. Challenge the strategic foundations: Are the premises valid or assumed? Is this the @@ -245,7 +245,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. ``` Timeout: 10 minutes - **Claude CEO subagent** (via Agent tool): + **Independent CEO subagent** (via Task tool): "Read the plan file at . You are an independent CEO/strategist reviewing this plan. You have NOT seen any prior review. Evaluate: 1. Is this the right problem to solve? Could a reframing yield 10x impact? @@ -255,11 +255,11 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. 5. What's the competitive risk — could someone else solve this first/better? For each finding: what's wrong, severity (critical/high/medium), and the fix." - **Error handling:** Both calls block in foreground. Codex auth/timeout/empty → proceed with - Claude subagent only, tagged `[single-model]`. If Claude subagent also fails → + **Error handling:** Both calls block in foreground. outside-voice sub-agent auth/timeout/empty → proceed with + independent subagent only, tagged `[single-model]`. If independent subagent also fails → "Outside voices unavailable — continuing with primary review." - **Degradation matrix:** Both fail → "single-reviewer mode". Codex only → + **Degradation matrix:** Both fail → "single-reviewer mode". outside-voice sub-agent only → tag `[codex-only]`. Subagent only → tag `[subagent-only]`. - Strategy choices: if codex disagrees with a premise or scope decision with valid @@ -277,15 +277,15 @@ Step 0 (0A-0F) — run each sub-step and produce: - 0E: Temporal interrogation (HOUR 1 → HOUR 6+) - 0F: Mode selection confirmation -Step 0.5 (Dual Voices): Run Claude subagent (foreground Agent tool) first, then -Codex (Bash). Present Codex output under CODEX SAYS (CEO — strategy challenge) -header. Present subagent output under CLAUDE SUBAGENT (CEO — strategic independence) +Step 0.5 (Dual Voices): Run independent subagent (foreground Task tool) first, then +outside-voice sub-agent (Bash). Present outside-voice sub-agent output under CODEX SAYS (CEO — strategy challenge) +header. Present subagent output under INDEPENDENT SUBAGENT (CEO — strategic independence) header. Produce CEO consensus table: ``` CEO DUAL VOICES — CONSENSUS TABLE: ═══════════════════════════════════════════════════════════════ - Dimension Claude Codex Consensus + Dimension Task outside-voice sub-agent Consensus ──────────────────────────────────── ─────── ─────── ───────── 1. Premises valid? — — — 2. Right problem to solve? — — — @@ -313,7 +313,7 @@ Sections 1-10 — for EACH section, run the evaluation criteria from the loaded - Completion Summary (the full summary table from the CEO skill) **PHASE 1 COMPLETE.** Emit phase-transition summary: -> **Phase 1 complete.** Codex: [N concerns]. Claude subagent: [N issues]. +> **Phase 1 complete.** outside-voice sub-agent: [N concerns]. independent subagent: [N issues]. > Consensus: [X/6 confirmed, Y disagreements → surfaced at gate]. > Passing to Phase 2. @@ -324,7 +324,7 @@ and the premise gate has been passed. **Pre-Phase 2 checklist (verify before starting):** - [ ] CEO completion summary written to plan file -- [ ] CEO dual voices ran (Codex + Claude subagent, or noted unavailable) +- [ ] CEO dual voices ran (outside-voice sub-agent + independent subagent, or noted unavailable) - [ ] CEO consensus table produced - [ ] Premise gate passed (user confirmed) - [ ] Phase-transition summary emitted @@ -339,12 +339,12 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - Structural issues (missing states, broken hierarchy): auto-fix (P5) - Aesthetic/taste issues: mark TASTE DECISION - Design system alignment: auto-fix if DESIGN.md exists and fix is obvious -- Dual voices: always run BOTH Claude subagent AND Codex if available (P6). +- Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **Codex design voice** (via Bash): + **outside-voice sub-agent design voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. Read the plan file at . Evaluate this plan's UI/UX design decisions. @@ -362,7 +362,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. ``` Timeout: 10 minutes - **Claude design subagent** (via Agent tool): + **Independent design subagent** (via Task tool): "Read the plan file at . You are an independent senior product designer reviewing this plan. You have NOT seen any prior review. Evaluate: 1. Information hierarchy: what does the user see first, second, third? Is it right? @@ -382,17 +382,17 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. 1. Step 0 (Design Scope): Rate completeness 0-10. Check DESIGN.md. Map existing patterns. -2. Step 0.5 (Dual Voices): Run Claude subagent (foreground) first, then Codex. Present under - CODEX SAYS (design — UX challenge) and CLAUDE SUBAGENT (design — independent review) +2. Step 0.5 (Dual Voices): Run independent subagent (foreground) first, then outside-voice sub-agent. Present under + CODEX SAYS (design — UX challenge) and INDEPENDENT SUBAGENT (design — independent review) headers. Produce design litmus scorecard (consensus table). Use the litmus scorecard - format from plan-design-review. Include CEO phase findings in Codex prompt ONLY - (not Claude subagent — stays independent). + format from plan-design-review. Include CEO phase findings in outside-voice sub-agent prompt ONLY + (not independent subagent — stays independent). 3. Passes 1-7: Run each from loaded skill. Rate 0-10. Auto-decide each issue. DISAGREE items from scorecard → raised in the relevant pass with both perspectives. **PHASE 2 COMPLETE.** Emit phase-transition summary: -> **Phase 2 complete.** Codex: [N concerns]. Claude subagent: [N issues]. +> **Phase 2 complete.** outside-voice sub-agent: [N concerns]. independent subagent: [N issues]. > Consensus: [X/Y confirmed, Z disagreements → surfaced at gate]. > Passing to Phase 3. @@ -414,12 +414,12 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. **Override rules:** - Scope challenge: never reduce (P2) -- Dual voices: always run BOTH Claude subagent AND Codex if available (P6). +- Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **Codex eng voice** (via Bash): + **outside-voice sub-agent eng voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. Review this plan for architectural issues, missing edge cases, and hidden complexity. Be adversarial. @@ -432,7 +432,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. ``` Timeout: 10 minutes - **Claude eng subagent** (via Agent tool): + **Independent eng subagent** (via Task tool): "Read the plan file at . You are an independent senior engineer reviewing this plan. You have NOT seen any prior review. Evaluate: 1. Architecture: Is the component structure sound? Coupling concerns? @@ -447,7 +447,7 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. - Architecture choices: explicit over clever (P5). If codex disagrees with valid reason → TASTE DECISION. Scope changes both models agree on → USER CHALLENGE. - Evals: always include all relevant suites (P1) -- Test plan: generate artifact at `~/.gstack/projects/$SLUG/{user}-{branch}-test-plan-{datetime}.md` +- Test plan: generate artifact at `$HOME/.bitfun/team/projects/$SLUG/{user}-{branch}-test-plan-{datetime}.md` - TODOS.md: collect all deferred scope expansions from Phase 1, auto-write **Required execution checklist (Eng):** @@ -455,15 +455,15 @@ Override: every AskUserQuestion → auto-decide using the 6 principles. 1. Step 0 (Scope Challenge): Read actual code referenced by the plan. Map each sub-problem to existing code. Run the complexity check. Produce concrete findings. -2. Step 0.5 (Dual Voices): Run Claude subagent (foreground) first, then Codex. Present - Codex output under CODEX SAYS (eng — architecture challenge) header. Present subagent - output under CLAUDE SUBAGENT (eng — independent review) header. Produce eng consensus +2. Step 0.5 (Dual Voices): Run independent subagent (foreground) first, then outside-voice sub-agent. Present + outside-voice sub-agent output under CODEX SAYS (eng — architecture challenge) header. Present subagent + output under INDEPENDENT SUBAGENT (eng — independent review) header. Produce eng consensus table: ``` ENG DUAL VOICES — CONSENSUS TABLE: ═══════════════════════════════════════════════════════════════ - Dimension Claude Codex Consensus + Dimension Task outside-voice sub-agent Consensus ──────────────────────────────────── ─────── ─────── ───────── 1. Architecture sound? — — — 2. Test coverage sufficient? — — — @@ -506,7 +506,7 @@ Missing voice = N/A (not CONFIRMED). Single critical finding from one voice = fl - TODOS.md updates (collected from all phases) **PHASE 3 COMPLETE.** Emit phase-transition summary: -> **Phase 3 complete.** Codex: [N concerns]. Claude subagent: [N issues]. +> **Phase 3 complete.** outside-voice sub-agent: [N concerns]. independent subagent: [N issues]. > Consensus: [X/6 confirmed, Y disagreements → surfaced at gate]. > Passing to Phase 3.5 (DX Review) or Phase 4 (Final Gate). @@ -529,12 +529,12 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." - Error message quality: always require problem + cause + fix (P1, completeness) - API/CLI naming: consistency wins over cleverness (P5) - DX taste decisions (e.g., opinionated defaults vs flexibility): mark TASTE DECISION -- Dual voices: always run BOTH Claude subagent AND Codex if available (P6). +- Dual voices: always run BOTH independent subagent AND outside-voice sub-agent if available (P6). - **Codex DX voice** (via Bash): + **outside-voice sub-agent DX voice** (via Bash): ```bash _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } - codex exec "IMPORTANT: Do NOT read or execute any SKILL.md files or files in skill definition directories (paths containing skills/gstack). These are AI assistant skill definitions meant for a different system. Stay focused on repository code only. +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. Read the plan file at . Evaluate this plan's developer experience. @@ -552,7 +552,7 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." ``` Timeout: 10 minutes - **Claude DX subagent** (via Agent tool): + **Independent DX subagent** (via Task tool): "Read the plan file at . You are an independent DX engineer reviewing this plan. You have NOT seen any prior review. Evaluate: 1. Getting started: how many steps from zero to hello world? What's the TTHW? @@ -573,14 +573,14 @@ Log: "Phase 3.5 skipped — no developer-facing scope detected." 1. Step 0 (DX Scope Assessment): Auto-detect product type. Map the developer journey. Rate initial DX completeness 0-10. Assess TTHW. -2. Step 0.5 (Dual Voices): Run Claude subagent (foreground) first, then Codex. Present - under CODEX SAYS (DX — developer experience challenge) and CLAUDE SUBAGENT +2. Step 0.5 (Dual Voices): Run independent subagent (foreground) first, then outside-voice sub-agent. Present + under CODEX SAYS (DX — developer experience challenge) and INDEPENDENT SUBAGENT (DX — independent review) headers. Produce DX consensus table: ``` DX DUAL VOICES — CONSENSUS TABLE: ═══════════════════════════════════════════════════════════════ - Dimension Claude Codex Consensus + Dimension Task outside-voice sub-agent Consensus ──────────────────────────────────── ─────── ─────── ───────── 1. Getting started < 5 min? — — — 2. API/CLI naming guessable? — — — @@ -607,7 +607,7 @@ Missing voice = N/A (not CONFIRMED). Single critical finding from one voice = fl **PHASE 3.5 COMPLETE.** Emit phase-transition summary: > **Phase 3.5 complete.** DX overall: [N]/10. TTHW: [N] min → [target] min. -> Codex: [N concerns]. Claude subagent: [N issues]. +> outside-voice sub-agent: [N concerns]. independent subagent: [N issues]. > Consensus: [X/6 confirmed, Y disagreements → surfaced at gate]. > Passing to Phase 4 (Final Gate). @@ -644,7 +644,7 @@ produced. Check the plan file and conversation for each item. - [ ] "What already exists" section written - [ ] Dream state delta written - [ ] Completion Summary produced -- [ ] Dual voices ran (Codex + Claude subagent, or noted unavailable) +- [ ] Dual voices ran (outside-voice sub-agent + independent subagent, or noted unavailable) - [ ] CEO consensus table produced **Phase 2 (Design) outputs — only if UI scope detected:** @@ -657,12 +657,12 @@ produced. Check the plan file and conversation for each item. - [ ] Scope challenge with actual code analysis (not just "scope is fine") - [ ] Architecture ASCII diagram produced - [ ] Test diagram mapping codepaths to test coverage -- [ ] Test plan artifact written to disk at ~/.gstack/projects/$SLUG/ +- [ ] Test plan artifact written to disk at $HOME/.bitfun/team/projects/$SLUG/ - [ ] "NOT in scope" section written - [ ] "What already exists" section written - [ ] Failure modes registry with critical gap assessment - [ ] Completion Summary produced -- [ ] Dual voices ran (Codex + Claude subagent, or noted unavailable) +- [ ] Dual voices ran (outside-voice sub-agent + independent subagent, or noted unavailable) - [ ] Eng consensus table produced **Phase 3.5 (DX) outputs — only if DX scope detected:** @@ -723,13 +723,13 @@ I recommend [X] — [principle]. But [Y] is also viable: ### Review Scores - CEO: [summary] -- CEO Voices: Codex [summary], Claude subagent [summary], Consensus [X/6 confirmed] +- CEO Voices: outside-voice sub-agent [summary], independent subagent [summary], Consensus [X/6 confirmed] - Design: [summary or "skipped, no UI scope"] -- Design Voices: Codex [summary], Claude subagent [summary], Consensus [X/7 confirmed] (or "skipped") +- Design Voices: outside-voice sub-agent [summary], independent subagent [summary], Consensus [X/7 confirmed] (or "skipped") - Eng: [summary] -- Eng Voices: Codex [summary], Claude subagent [summary], Consensus [X/6 confirmed] +- Eng Voices: outside-voice sub-agent [summary], independent subagent [summary], Consensus [X/6 confirmed] - DX: [summary or "skipped, no developer-facing scope"] -- DX Voices: Codex [summary], Claude subagent [summary], Consensus [X/6 confirmed] (or "skipped") +- DX Voices: outside-voice sub-agent [summary], independent subagent [summary], Consensus [X/6 confirmed] (or "skipped") ### Cross-Phase Themes [For any concern that appeared in 2+ phases' dual voices independently:] @@ -773,36 +773,36 @@ STATUS is "clean" if no unresolved issues, "issues_open" otherwise. COMMIT=$(git rev-parse --short HEAD 2>/dev/null) TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-ceo-review","timestamp":"'"$TIMESTAMP"'","status":"STATUS","unresolved":N,"critical_gaps":N,"mode":"SELECTIVE_EXPANSION","via":"autoplan","commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-eng-review","timestamp":"'"$TIMESTAMP"'","status":"STATUS","unresolved":N,"critical_gaps":N,"issues_found":N,"mode":"FULL_REVIEW","via":"autoplan","commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` If Phase 2 ran (UI scope): ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-design-review","timestamp":"'"$TIMESTAMP"'","status":"STATUS","unresolved":N,"via":"autoplan","commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` If Phase 3.5 ran (DX scope): ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-devex-review","timestamp":"'"$TIMESTAMP"'","status":"STATUS","initial_score":N,"overall_score":N,"product_type":"TYPE","tthw_current":"TTHW","tthw_target":"TARGET","unresolved":N,"via":"autoplan","commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` Dual voice logs (one per phase that ran): ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"autoplan-voices","timestamp":"'"$TIMESTAMP"'","status":"STATUS","source":"SOURCE","phase":"ceo","via":"autoplan","consensus_confirmed":N,"consensus_disagree":N,"commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"autoplan-voices","timestamp":"'"$TIMESTAMP"'","status":"STATUS","source":"SOURCE","phase":"eng","via":"autoplan","consensus_confirmed":N,"consensus_disagree":N,"commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` If Phase 2 ran (UI scope), also log: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"autoplan-voices","timestamp":"'"$TIMESTAMP"'","status":"STATUS","source":"SOURCE","phase":"design","via":"autoplan","consensus_confirmed":N,"consensus_disagree":N,"commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` If Phase 3.5 ran (DX scope), also log: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"autoplan-voices","timestamp":"'"$TIMESTAMP"'","status":"STATUS","source":"SOURCE","phase":"dx","via":"autoplan","consensus_confirmed":N,"consensus_disagree":N,"commit":"'"$COMMIT"'"}' +true # BitFun Team Mode has no external review-log helper ``` SOURCE = "codex+subagent", "codex-only", "subagent-only", or "unavailable". diff --git a/src/crates/core/builtin_skills/gstack-cso/SKILL.md b/src/crates/core/builtin_skills/gstack-cso/SKILL.md index bff7360fd..6f025bbbe 100644 --- a/src/crates/core/builtin_skills/gstack-cso/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-cso/SKILL.md @@ -18,6 +18,16 @@ The real attack surface isn't your code — it's your dependencies. Most teams a You do NOT make code changes. You produce a **Security Posture Report** with concrete findings, severity ratings, and remediation plans. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the security-review lens. Use existing Task sub-agents for independent security evidence gathering, then make final severity and remediation calls in the main Team session. + +- Do not assume a CSO sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom security sub-agent if available; otherwise use `ReviewSecurity` for diff-focused review when available, `Explore` for broader code/config mapping, and `FileFinder` for security-sensitive files. +- Keep Task work read-only. Ask for concrete evidence: file paths, trust boundaries, inputs, auth/data flows, exploit preconditions, and confidence. +- In parallel batches, return a compact Security brief: `critical/high findings`, `trust-boundary risks`, `false-positive notes`, `required fixes`, `verification`. +- The main Team orchestrator decides what blocks Build/Ship and asks the user for risk acceptance when needed. + ## User-invocable When the user types `/cso`, run this skill. @@ -44,7 +54,7 @@ When the user types `/cso`, run this skill. ## Important: Use the Grep tool for all code searches -The bash blocks throughout this skill show WHAT patterns to search for, not HOW to run them. Use Claude Code's Grep tool (which handles permissions and access correctly) rather than raw bash grep. The bash blocks are illustrative examples — do NOT copy-paste them into a terminal. Do NOT use `| head` to truncate results. +The bash blocks throughout this skill show WHAT patterns to search for, not HOW to run them. Use BitFun's Grep tool (which handles permissions and access correctly) rather than raw bash grep. The bash blocks are illustrative examples — do NOT copy-paste them into a terminal. Do NOT use `| head` to truncate results. ## Instructions @@ -82,7 +92,7 @@ grep -q "laravel" composer.json 2>/dev/null && echo "FRAMEWORK: Laravel" **Soft gate, not hard gate:** Stack detection determines scan PRIORITY, not scan SCOPE. In subsequent phases, PRIORITIZE scanning for detected languages/frameworks first and most thoroughly. However, do NOT skip undetected languages entirely — after the targeted scan, run a brief catch-all pass with high-signal patterns (SQL injection, command injection, hardcoded secrets, SSRF) across ALL file types. A Python service nested in `ml/` that wasn't detected at root still gets basic coverage. **Mental model:** -- Read CLAUDE.md, README, key config files +- Read AGENTS.md, README, key config files - Map the application architecture: what components exist, how they connect, where trust boundaries are - Identify the data flow: where does user input enter? Where does it exit? What transformations happen? - Document invariants and assumptions the code relies on @@ -92,41 +102,7 @@ This is NOT a checklist — it's a reasoning phase. The output is understanding, ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ### Phase 1: Attack Surface Census @@ -290,12 +266,12 @@ Use Grep to search for these patterns: ### Phase 8: Skill Supply Chain -Scan installed Claude Code skills for malicious patterns. 36% of published skills have security flaws, 13.4% are outright malicious (Snyk ToxicSkills research). +Scan installed BitFun skills for malicious patterns. 36% of published skills have security flaws, 13.4% are outright malicious (Snyk ToxicSkills research). **Tier 1 — repo-local (automatic):** Scan the repo's local skills directory for suspicious patterns: ```bash -ls -la .claude/skills/ 2>/dev/null +Use Skill/FileFinder context to inspect bundled skill definitions when relevant ``` Use Grep to search all local skill SKILL.md files for suspicious patterns: @@ -486,7 +462,7 @@ When a finding is VERIFIED, search the entire codebase for the same vulnerabilit **Parallel Finding Verification:** -For each candidate finding, launch an independent verification sub-task using the Agent tool. The verifier has fresh context and cannot see the initial scan's reasoning — only the finding itself and the FP filtering rules. +For each candidate finding, launch an independent verification sub-task using the Task tool. The verifier has fresh context and cannot see the initial scan's reasoning — only the finding itself and the FP filtering rules. Prompt each verifier with: - The file path and line number ONLY (avoid anchoring) @@ -495,7 +471,7 @@ Prompt each verifier with: Launch all verifiers in parallel. Discard findings where the verifier scores below 8 (daily mode) or below 2 (comprehensive mode). -If the Agent tool is unavailable, self-verify by re-reading code with a skeptic's eye. Note: "Self-verified — independent sub-task unavailable." +If the Task tool is unavailable, self-verify by re-reading code with a skeptic's eye. Note: "Self-verified — independent sub-task unavailable." ### Phase 13: Findings Report + Trend Tracking + Remediation @@ -561,7 +537,7 @@ For each finding: 5. **Audit exposure window** — when committed? When removed? Was repo public? 6. **Check for abuse** — review provider's audit logs -**Trend Tracking:** If prior reports exist in `.gstack/security-reports/`: +**Trend Tracking:** If prior reports exist in `.bitfun/team/security-reports/`: ``` SECURITY POSTURE TREND ══════════════════════ @@ -589,10 +565,10 @@ Match findings across reports using the `fingerprint` field (sha256 of category ### Phase 14: Save Report ```bash -mkdir -p .gstack/security-reports +mkdir -p .bitfun/team/security-reports ``` -Write findings to `.gstack/security-reports/{date}-{HHMMSS}.json` using this schema: +Write findings to `.bitfun/team/security-reports/{date}-{HHMMSS}.json` using this schema: ```json { @@ -645,7 +621,7 @@ Write findings to `.gstack/security-reports/{date}-{HHMMSS}.json` using this sch } ``` -If `.gstack/` is not in `.gitignore`, note it in findings — security reports should stay local. +If `.bitfun/team/` is not in `.gitignore`, note it in findings — security reports should stay local. ## Capture Learnings @@ -653,7 +629,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"cso","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -661,7 +637,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md b/src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md index 2fcdb909c..983b79634 100644 --- a/src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-design-consultation/SKILL.md @@ -16,6 +16,15 @@ You are a senior product designer with strong opinions about typography, color, **Your posture:** Design consultant, not form wizard. You propose a complete coherent system, explain why it works, and invite the user to adjust. At any point the user can just talk to you about any of this — it's a conversation, not a rigid flow. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the design-system methodology. Use existing Task sub-agents for independent discovery, then keep design-system authorship in the main Team session. + +- Do not assume a Design Partner sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom design/research/frontend sub-agents if available; otherwise use `Explore` for product/UI surface mapping and `FileFinder` for design docs, themes, screenshots, and component libraries. +- Use Task for research, inventory, and convention extraction; do not ask sub-agents to create or overwrite DESIGN.md. +- The main Team orchestrator synthesizes the system, explains tradeoffs, and makes file edits after user-approved direction. + --- ## Phase 0: Pre-checks @@ -41,8 +50,8 @@ Look for office-hours output: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -ls ~/.gstack/projects/$SLUG/*office-hours* 2>/dev/null | head -5 +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +ls $HOME/.bitfun/team/projects/$SLUG/*office-hours* 2>/dev/null | head -5 ls .context/*office-hours* .context/attachments/*office-hours* 2>/dev/null | head -5 ``` @@ -50,134 +59,30 @@ If office-hours output exists, read it — the product context is pre-filled. If the codebase is empty and purpose is unclear, say: *"I don't have a clear picture of what you're building yet. Want to explore first with `/office-hours`? Once we know the product direction, we can set up the design system."* -**Find the browse binary (optional — enables visual competitive research):** - -## SETUP (run this check BEFORE any browse command) - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "READY: $B" -else - echo "NEEDS_SETUP" -fi -``` - -If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. -2. Run: `cd && ./setup` -3. If `bun` is not installed: - ```bash - if ! command -v bun >/dev/null 2>&1; then - BUN_VERSION="1.3.10" - BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" - tmpfile=$(mktemp) - curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') - if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then - echo "ERROR: bun install script checksum mismatch" >&2 - echo " expected: $BUN_INSTALL_SHA" >&2 - echo " got: $actual_sha" >&2 - rm "$tmpfile"; exit 1 - fi - BUN_VERSION="$BUN_VERSION" bash "$tmpfile" - rm "$tmpfile" - fi - ``` +**Visual research tooling:** Use BitFun built-in browser/computer-use capability for screenshots and live-page inspection. Do not install, build, or call any external browse binary. If browser tooling is unavailable, continue with code inspection, WebSearch when allowed, and static visual analysis. If browse is not available, that's fine — visual research is optional. The skill works without it using WebSearch and your built-in design knowledge. -**Find the gstack designer (optional — enables AI mockup generation):** - -## DESIGN SETUP (run this check BEFORE any design mockup command) +**Find the BitFun image/design capability (optional — enables AI mockup generation):** -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" -[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design -if [ -x "$D" ]; then - echo "DESIGN_READY: $D" -else - echo "DESIGN_NOT_AVAILABLE" -fi -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "BROWSE_READY: $B" -else - echo "BROWSE_NOT_AVAILABLE (will use 'open' to view comparison boards)" -fi -``` +## DESIGN SETUP -If `DESIGN_NOT_AVAILABLE`: skip visual mockup generation and fall back to the -existing HTML wireframe approach (`DESIGN_SKETCH`). Design mockups are a -progressive enhancement, not a hard requirement. - -If `BROWSE_NOT_AVAILABLE`: use `open file://...` instead of `$B goto` to open -comparison boards. The user just needs to see the HTML file in any browser. - -If `DESIGN_READY`: the design binary is available for visual mockup generation. -Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server -- `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP -- `$D check --image /path.png --brief "..."` — vision quality gate -- `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Use BitFun built-in image/design and browser/computer-use capabilities. Do not install, build, or call external `design` or `browse` binaries. Generate mockups, comparison boards, screenshots, and visual QA artifacts through BitFun tools; if a visual generation capability is not available in the current session, fall back to HTML wireframes and code-level design review. **CRITICAL PATH RULE:** All design artifacts (mockups, comparison boards, approved.json) -MUST be saved to `~/.gstack/projects/$SLUG/designs/`, NEVER to `.context/`, +MUST be saved to `$HOME/.bitfun/team/projects/$SLUG/designs/`, NEVER to `.context/`, `docs/designs/`, `/tmp/`, or any project-local directory. Design artifacts are USER data, not project files. They persist across branches, conversations, and workspaces. -If `DESIGN_READY`: Phase 5 will generate AI mockups of your proposed design system applied to real screens, instead of just an HTML preview page. Much more powerful — the user sees what their product could actually look like. +If `BitFun image/design capability is available`: Phase 5 will generate AI mockups of your proposed design system applied to real screens, instead of just an HTML preview page. Much more powerful — the user sees what their product could actually look like. -If `DESIGN_NOT_AVAILABLE`: Phase 5 falls back to the HTML preview page (still good). +If `BitFun image/design capability is unavailable`: Phase 5 falls back to the HTML preview page (still good). --- ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Phase 1: Product Context @@ -206,12 +111,12 @@ Use WebSearch to find 5-10 products in their space. Search for: **Step 2: Visual research via browse (if available)** -If the browse binary is available (`$B` is set), visit the top 3-5 sites in the space and capture visual evidence: +If the BitFun browser/computer-use tooling is available (`BitFun browser/computer-use` is set), visit the top 3-5 sites in the space and capture visual evidence: ```bash -$B goto "https://example-site.com" -$B screenshot "/tmp/design-research-site-name.png" -$B snapshot +BitFun browser/computer-use goto "https://example-site.com" +BitFun browser/computer-use screenshot "/tmp/design-research-site-name.png" +BitFun browser/computer-use snapshot ``` For each site, analyze: fonts actually used, color palette, layout approach, spacing density, aesthetic direction. The screenshot gives you the feel; the snapshot gives you structural data. @@ -244,25 +149,25 @@ If the user said no research, skip entirely and proceed to Phase 3 using your bu ## Design Outside Voices (parallel) Use AskUserQuestion: -> "Want outside design voices? Codex evaluates against OpenAI's design hard rules + litmus checks; Claude subagent does an independent design direction proposal." +> "Want outside design voices? outside-voice sub-agent evaluates against OpenAI's design hard rules + litmus checks; independent subagent does an independent design direction proposal." > > A) Yes — run outside design voices > B) No — proceed without If user chooses B, skip this step and continue. -**Check Codex availability:** +**Check outside-voice sub-agent availability:** ```bash which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" ``` -**If Codex is available**, launch both voices simultaneously: +**If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **Codex design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via Bash): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "Given this product context, propose a complete design direction: +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. - Visual thesis: one sentence describing mood, material, and energy - Typography: specific font names (not defaults — no Inter/Roboto/Arial/system) + hex colors - Color system: CSS variables for background, surface, primary text, muted text, accent @@ -277,7 +182,7 @@ Use a 5-minute timeout (`timeout: 300000`). After the command completes, read st cat "$TMPERR_DESIGN" && rm -f "$TMPERR_DESIGN" ``` -2. **Claude design subagent** (via Agent tool): +2. **Independent design subagent** (via BitFun Task tool): Dispatch a subagent with this prompt: "Given this product context, propose a design direction that would SURPRISE. What would the cool indie studio do that the enterprise UI team wouldn't? - Propose an aesthetic direction, typography stack (specific font names), color palette (hex values) @@ -287,23 +192,23 @@ Dispatch a subagent with this prompt: Be bold. Be specific. No hedging." **Error handling (all non-blocking):** -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run `codex login` to authenticate." -- **Timeout:** "Codex timed out after 5 minutes." -- **Empty response:** "Codex returned no response." -- On any Codex error: proceed with Claude subagent output only, tagged `[single-model]`. -- If Claude subagent also fails: "Outside voices unavailable — continuing with primary review." -Present Codex output under a `CODEX SAYS (design direction):` header. -Present subagent output under a `CLAUDE SUBAGENT (design direction):` header. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." +- **Empty response:** "outside-voice sub-agent returned no response." +- On any outside-voice sub-agent error: proceed with independent subagent output only, tagged `[single-model]`. +- If independent subagent also fails: "Outside voices unavailable — continuing with primary review." + +Present outside-voice sub-agent output under a `CODEX SAYS (design direction):` header. +Present subagent output under a `INDEPENDENT SUBAGENT (design direction):` header. -**Synthesis:** Claude main references both Codex and subagent proposals in the Phase 3 proposal. Present: -- Areas of agreement between all three voices (Claude main + Codex + subagent) +**Synthesis:** BitFun main references both outside-voice sub-agent and subagent proposals in the Phase 3 proposal. Present: +- Areas of agreement between all three voices (BitFun main + outside-voice sub-agent + subagent) - Genuine divergences as creative alternatives for the user to choose from -- "Codex and I agree on X. Codex suggested Y where I'm proposing Z — here's why..." +- "outside-voice sub-agent and I agree on X. outside-voice sub-agent suggested Y where I'm proposing Z — here's why..." **Log the result:** ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"design-outside-voices","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` Replace STATUS with "clean" or "issues_found", SOURCE with "codex+subagent", "codex-only", "subagent-only", or "unavailable". @@ -411,15 +316,15 @@ Each drill-down is one focused AskUserQuestion. After the user decides, re-check ## Phase 5: Design System Preview (default ON) -This phase generates visual previews of the proposed design system. Two paths depending on whether the gstack designer is available. +This phase generates visual previews of the proposed design system. Two paths depending on whether the BitFun image/design capability is available. -### Path A: AI Mockups (if DESIGN_READY) +### Path A: AI Mockups (if BitFun image/design capability is available) Generate AI-rendered mockups showing the proposed design system applied to realistic screens for this product. This is far more powerful than an HTML preview — the user sees what their product could actually look like. ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/design-system-$(date +%Y%m%d) +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +_DESIGN_DIR=$HOME/.bitfun/team/projects/$SLUG/designs/design-system-$(date +%Y%m%d) mkdir -p "$_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR" ``` @@ -427,13 +332,13 @@ echo "DESIGN_DIR: $_DESIGN_DIR" Construct a design brief from the Phase 3 proposal (aesthetic, colors, typography, spacing, layout) and the product context from Phase 1: ```bash -$D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" +BitFun image/design capability variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" ``` Run quality check on each variant: ```bash -$D check --image "$_DESIGN_DIR/variant-A.png" --brief "" +BitFun image/design capability check --image "$_DESIGN_DIR/variant-A.png" --brief "" ``` Show each variant inline (Read tool on each PNG) for instant preview. @@ -445,7 +350,7 @@ Tell the user: "I've generated 3 visual directions applying your design system t Create the comparison board and serve it over HTTP: ```bash -$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve +BitFun image/design capability compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve ``` This command generates the board HTML, starts an HTTP server on a random port, @@ -507,8 +412,8 @@ the approved variant. 1. Read `regenerateAction` from the JSON (`"different"`, `"match"`, `"more_like_B"`, `"remix"`, or custom text) 2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`) -3. Generate new variants with `$D iterate` or `$D variants` using updated brief -4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"` +3. Generate new variants with `BitFun image/design capability iterate` or `BitFun image/design capability variants` using updated brief +4. Create new board: `BitFun image/design capability compare --images "..." --output "$_DESIGN_DIR/design-board.html"` 5. Reload the board in the user's browser (same tab): `curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'` 6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to @@ -518,7 +423,7 @@ the approved variant. AskUserQuestion response instead of using the board. Use their text response as the feedback. -**POLLING FALLBACK:** Only use polling if `$D serve` fails (no port available). +**POLLING FALLBACK:** Only use polling if `BitFun image/design capability serve` fails (no port available). In that case, show each variant inline using the Read tool (so the user can see them), then use AskUserQuestion: "The comparison board server failed to start. I've shown the variants above. @@ -544,14 +449,14 @@ echo '{"approved_variant":"","feedback":"","date":"'$(date -u +%Y-%m-%dT% After the user picks a direction: -- Use `$D extract --image "$_DESIGN_DIR/variant-.png"` to analyze the approved mockup and extract design tokens (colors, typography, spacing) that will populate DESIGN.md in Phase 6. This grounds the design system in what was actually approved visually, not just what was described in text. -- If the user wants to iterate further: `$D iterate --feedback "" --output "$_DESIGN_DIR/refined.png"` +- Use `BitFun image/design capability extract --image "$_DESIGN_DIR/variant-.png"` to analyze the approved mockup and extract design tokens (colors, typography, spacing) that will populate DESIGN.md in Phase 6. This grounds the design system in what was actually approved visually, not just what was described in text. +- If the user wants to iterate further: `BitFun image/design capability iterate --feedback "" --output "$_DESIGN_DIR/refined.png"` **Plan mode vs. implementation mode:** - **If in plan mode:** Add the approved mockup path (the full `$_DESIGN_DIR` path) and extracted tokens to the plan file under an "## Approved Design Direction" section. The design system gets written to DESIGN.md when the plan is implemented. - **If NOT in plan mode:** Proceed directly to Phase 6 and write DESIGN.md with the extracted tokens. -### Path B: HTML Preview Page (fallback if DESIGN_NOT_AVAILABLE) +### Path B: HTML Preview Page (fallback if BitFun image/design capability is unavailable) Generate a polished HTML preview page and open it in the user's browser. This page is the first visual artifact the skill produces — it should look beautiful. @@ -600,7 +505,7 @@ If the user says skip the preview, go directly to Phase 6. ## Phase 6: Write DESIGN.md & Confirm -If `$D extract` was used in Phase 5 (Path A), use the extracted tokens as the primary source for DESIGN.md values — colors, typography, and spacing grounded in the approved mockup rather than text descriptions alone. Merge extracted tokens with the Phase 3 proposal (the proposal provides rationale and context; the extraction provides exact values). +If `BitFun image/design capability extract` was used in Phase 5 (Path A), use the extracted tokens as the primary source for DESIGN.md values — colors, typography, and spacing grounded in the approved mockup rather than text descriptions alone. Merge extracted tokens with the Phase 3 proposal (the proposal provides rationale and context; the extraction provides exact values). **If in plan mode:** Write the DESIGN.md content into the plan file as a "## Proposed DESIGN.md" section. Do NOT write the actual file — that happens at implementation time. @@ -660,7 +565,7 @@ If `$D extract` was used in Phase 5 (Path A), use the extracted tokens as the pr | [today] | Initial design system created | Created by /design-consultation based on [product context / research] | ``` -**Update CLAUDE.md** (or create it if it doesn't exist) — append this section: +**Update AGENTS.md** (or create it if it doesn't exist) — append this section: ```markdown ## Design System @@ -673,7 +578,7 @@ In QA mode, flag any code that doesn't match DESIGN.md. **AskUserQuestion Q-final — show summary and confirm:** List all decisions. Flag any that used agent defaults without explicit user confirmation (the user should know what they're shipping). Options: -- A) Ship it — write DESIGN.md and CLAUDE.md +- A) Ship it — write DESIGN.md and AGENTS.md - B) I want to change something (specify what) - C) Start over @@ -689,7 +594,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"design-consultation","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -697,7 +602,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-design-review/SKILL.md b/src/crates/core/builtin_skills/gstack-design-review/SKILL.md index 5bc91c305..4b7328c1d 100644 --- a/src/crates/core/builtin_skills/gstack-design-review/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-design-review/SKILL.md @@ -14,6 +14,16 @@ description: | You are a senior product designer AND a frontend engineer. Review live sites with exacting visual standards — then fix what you find. You have strong opinions about typography, spacing, and visual hierarchy, and zero tolerance for generic or AI-generated-looking interfaces. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the live design-audit methodology. Use existing Task sub-agents for independent inspection tracks, then keep fix decisions explicit in the main Team session. + +- Do not assume a Designer sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom design/frontend/accessibility sub-agents if available; otherwise use `ComputerUse` for browser inspection when available, `Explore` for component/style-system mapping, and `FileFinder` for UI files. +- Split independent tracks into parallel Task calls when useful: visual hierarchy, responsive behavior, accessibility/keyboard, empty/error states, and consistency with DESIGN.md. +- Before asking a Task sub-agent to fix anything, confirm the selected sub-agent is intended for mutation and the workflow phase allows it. Otherwise request report-only output. +- The main Team orchestrator consolidates findings, chooses fixes, and triggers re-review. + ## Setup **Parse the user's request for these parameters:** @@ -29,10 +39,7 @@ You are a senior product designer AND a frontend engineer. Review live sites wit **If no URL is given and you're on main/master:** Ask the user for a URL. -**CDP mode detection:** Check if browse is connected to the user's real browser: -```bash -$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false" -``` +**Browser session detection:** Use BitFun browser/computer-use state to detect whether an existing user browser session is available. If `CDP_MODE=true`: skip cookie import steps — the real browser already has cookies and auth sessions. Skip headless detection workarounds. **Check for DESIGN.md:** @@ -57,43 +64,7 @@ RECOMMENDATION: Choose A because uncommitted work should be preserved as a commi After the user chooses, execute their choice (commit or stash), then continue with setup. -**Find the browse binary:** - -## SETUP (run this check BEFORE any browse command) - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "READY: $B" -else - echo "NEEDS_SETUP" -fi -``` - -If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. -2. Run: `cd && ./setup` -3. If `bun` is not installed: - ```bash - if ! command -v bun >/dev/null 2>&1; then - BUN_VERSION="1.3.10" - BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" - tmpfile=$(mktemp) - curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') - if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then - echo "ERROR: bun install script checksum mismatch" >&2 - echo " expected: $BUN_INSTALL_SHA" >&2 - echo " got: $actual_sha" >&2 - rm "$tmpfile"; exit 1 - fi - BUN_VERSION="$BUN_VERSION" bash "$tmpfile" - rm "$tmpfile" - fi - ``` +**Browser/desktop QA tooling:** Use BitFun built-in browser/computer-use capability. Do not install, build, or call any external browse binary. Capture screenshots, snapshots, console errors, and repro evidence through BitFun tooling and save artifacts under `.bitfun/team/qa-reports/`. **Check test framework (bootstrap if needed):** @@ -118,7 +89,7 @@ setopt +o nomatch 2>/dev/null || true # zsh compat ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null # Check opt-out marker -[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" +[ -f .bitfun/team/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" ``` **If test framework detected** (config files or test directories found): @@ -131,7 +102,7 @@ Store conventions as prose context for use in Phase 8e.5 or Step 3.4. **Skip the **If NO runtime detected** (no config files found): Use AskUserQuestion: "I couldn't detect your project's language. What runtime are you using?" Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests. -If user picks H → write `.gstack/no-test-bootstrap` and continue without tests. +If user picks H → write `.bitfun/team/no-test-bootstrap` and continue without tests. **If runtime detected but no test framework — bootstrap:** @@ -163,7 +134,7 @@ B) [Alternative] — [rationale]. Includes: [packages] C) Skip — don't set up testing right now RECOMMENDATION: Choose A because [reason based on project context]" -If user picks C → write `.gstack/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.gstack/no-test-bootstrap` and re-run." Continue without tests. +If user picks C → write `.bitfun/team/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.bitfun/team/no-test-bootstrap` and re-run." Continue without tests. If multiple runtimes detected (monorepo) → ask which runtime to set up first, with option to do both sequentially. @@ -225,9 +196,9 @@ Write TESTING.md with: - Test layers: Unit tests (what, where, when), Integration tests, Smoke tests, E2E tests - Conventions: file naming, assertion style, setup/teardown patterns -### B7. Update CLAUDE.md +### B7. Update AGENTS.md -First check: If CLAUDE.md already has a `## Testing` section → skip. Don't duplicate. +First check: If AGENTS.md already has a `## Testing` section → skip. Don't duplicate. Append a `## Testing` section: - Run command and test directory @@ -246,65 +217,31 @@ Append a `## Testing` section: git status --porcelain ``` -Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, CLAUDE.md, .github/workflows/test.yml if created): +Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, AGENTS.md, .github/workflows/test.yml if created): `git commit -m "chore: bootstrap test framework ({framework name})"` --- -**Find the gstack designer (optional — enables target mockup generation):** - -## DESIGN SETUP (run this check BEFORE any design mockup command) - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" -[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design -if [ -x "$D" ]; then - echo "DESIGN_READY: $D" -else - echo "DESIGN_NOT_AVAILABLE" -fi -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "BROWSE_READY: $B" -else - echo "BROWSE_NOT_AVAILABLE (will use 'open' to view comparison boards)" -fi -``` +**Find the BitFun image/design capability (optional — enables target mockup generation):** -If `DESIGN_NOT_AVAILABLE`: skip visual mockup generation and fall back to the -existing HTML wireframe approach (`DESIGN_SKETCH`). Design mockups are a -progressive enhancement, not a hard requirement. +## DESIGN SETUP -If `BROWSE_NOT_AVAILABLE`: use `open file://...` instead of `$B goto` to open -comparison boards. The user just needs to see the HTML file in any browser. - -If `DESIGN_READY`: the design binary is available for visual mockup generation. -Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server -- `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP -- `$D check --image /path.png --brief "..."` — vision quality gate -- `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Use BitFun built-in image/design and browser/computer-use capabilities. Do not install, build, or call external `design` or `browse` binaries. Generate mockups, comparison boards, screenshots, and visual QA artifacts through BitFun tools; if a visual generation capability is not available in the current session, fall back to HTML wireframes and code-level design review. **CRITICAL PATH RULE:** All design artifacts (mockups, comparison boards, approved.json) -MUST be saved to `~/.gstack/projects/$SLUG/designs/`, NEVER to `.context/`, +MUST be saved to `$HOME/.bitfun/team/projects/$SLUG/designs/`, NEVER to `.context/`, `docs/designs/`, `/tmp/`, or any project-local directory. Design artifacts are USER data, not project files. They persist across branches, conversations, and workspaces. -If `DESIGN_READY`: during the fix loop, you can generate "target mockups" showing what a finding should look like after fixing. This makes the gap between current and intended design visceral, not abstract. +If `BitFun image/design capability is available`: during the fix loop, you can generate "target mockups" showing what a finding should look like after fixing. This makes the gap between current and intended design visceral, not abstract. -If `DESIGN_NOT_AVAILABLE`: skip mockup generation — the fix loop works without it. +If `BitFun image/design capability is unavailable`: skip mockup generation — the fix loop works without it. **Create output directories:** ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -REPORT_DIR=~/.gstack/projects/$SLUG/designs/design-audit-$(date +%Y%m%d) +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +REPORT_DIR=$HOME/.bitfun/team/projects/$SLUG/designs/design-audit-$(date +%Y%m%d) mkdir -p "$REPORT_DIR/screenshots" echo "REPORT_DIR: $REPORT_DIR" ``` @@ -313,41 +250,7 @@ echo "REPORT_DIR: $REPORT_DIR" ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Phases 1-6: Design Audit Baseline @@ -379,7 +282,7 @@ Run full audit, then load previous `design-baseline.json`. Compare: per-category The most uniquely designer-like output. Form a gut reaction before analyzing anything. 1. Navigate to the target URL -2. Take a full-page desktop screenshot: `$B screenshot "$REPORT_DIR/screenshots/first-impression.png"` +2. Take a full-page desktop screenshot: `BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/first-impression.png"` 3. Write the **First Impression** using this structured critique format: - "The site communicates **[what]**." (what it says at a glance — competence? playfulness? confusion?) - "I notice **[observation]**." (what stands out, positive or negative — be specific) @@ -396,19 +299,19 @@ Extract the actual design system the site uses (not what a DESIGN.md says, but w ```bash # Fonts in use (capped at 500 elements to avoid timeout) -$B js "JSON.stringify([...new Set([...document.querySelectorAll('*')].slice(0,500).map(e => getComputedStyle(e).fontFamily))])" +BitFun browser/computer-use js "JSON.stringify([...new Set([...document.querySelectorAll('*')].slice(0,500).map(e => getComputedStyle(e).fontFamily))])" # Color palette in use -$B js "JSON.stringify([...new Set([...document.querySelectorAll('*')].slice(0,500).flatMap(e => [getComputedStyle(e).color, getComputedStyle(e).backgroundColor]).filter(c => c !== 'rgba(0, 0, 0, 0)'))])" +BitFun browser/computer-use js "JSON.stringify([...new Set([...document.querySelectorAll('*')].slice(0,500).flatMap(e => [getComputedStyle(e).color, getComputedStyle(e).backgroundColor]).filter(c => c !== 'rgba(0, 0, 0, 0)'))])" # Heading hierarchy -$B js "JSON.stringify([...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => ({tag:h.tagName, text:h.textContent.trim().slice(0,50), size:getComputedStyle(h).fontSize, weight:getComputedStyle(h).fontWeight})))" +BitFun browser/computer-use js "JSON.stringify([...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => ({tag:h.tagName, text:h.textContent.trim().slice(0,50), size:getComputedStyle(h).fontSize, weight:getComputedStyle(h).fontWeight})))" # Touch target audit (find undersized interactive elements) -$B js "JSON.stringify([...document.querySelectorAll('a,button,input,[role=button]')].filter(e => {const r=e.getBoundingClientRect(); return r.width>0 && (r.width<44||r.height<44)}).map(e => ({tag:e.tagName, text:(e.textContent||'').trim().slice(0,30), w:Math.round(e.getBoundingClientRect().width), h:Math.round(e.getBoundingClientRect().height)})).slice(0,20))" +BitFun browser/computer-use js "JSON.stringify([...document.querySelectorAll('a,button,input,[role=button]')].filter(e => {const r=e.getBoundingClientRect(); return r.width>0 && (r.width<44||r.height<44)}).map(e => ({tag:e.tagName, text:(e.textContent||'').trim().slice(0,30), w:Math.round(e.getBoundingClientRect().width), h:Math.round(e.getBoundingClientRect().height)})).slice(0,20))" # Performance baseline -$B perf +BitFun browser/computer-use perf ``` Structure findings as an **Inferred Design System**: @@ -426,18 +329,18 @@ After extraction, offer: *"Want me to save this as your DESIGN.md? I can lock in For each page in scope: ```bash -$B goto -$B snapshot -i -a -o "$REPORT_DIR/screenshots/{page}-annotated.png" -$B responsive "$REPORT_DIR/screenshots/{page}" -$B console --errors -$B perf +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/{page}-annotated.png" +BitFun browser/computer-use responsive "$REPORT_DIR/screenshots/{page}" +BitFun browser/computer-use console --errors +BitFun browser/computer-use perf ``` ### Auth Detection After the first navigation, check if the URL changed to a login-like path: ```bash -$B url +BitFun browser/computer-use url ``` If URL contains `/login`, `/signin`, `/auth`, or `/sso`: the site requires authentication. AskUserQuestion: "This site requires authentication. Want to import cookies from your browser? Run `/setup-browser-cookies` first if needed." @@ -464,7 +367,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish - Weight contrast: >=2 weights used for hierarchy - No blacklisted fonts (Papyrus, Comic Sans, Lobster, Impact, Jokerman) - If primary font is Inter/Roboto/Open Sans/Poppins → flag as potentially generic -- `text-wrap: balance` or `text-pretty` on headings (check via `$B css text-wrap`) +- `text-wrap: balance` or `text-pretty` on headings (check via `BitFun browser/computer-use css text-wrap`) - Curly quotes used, not straight quotes - Ellipsis character (`…`) not three dots (`...`) - `font-variant-numeric: tabular-nums` on number columns @@ -524,7 +427,7 @@ Apply these at each page. Each finding gets an impact rating (high/medium/polish - Easing: ease-out for entering, ease-in for exiting, ease-in-out for moving - Duration: 50-700ms range (nothing slower unless page transition) - Purpose: every animation communicates something (state change, attention, spatial relationship) -- `prefers-reduced-motion` respected (check: `$B js "matchMedia('(prefers-reduced-motion: reduce)').matches"`) +- `prefers-reduced-motion` respected (check: `BitFun browser/computer-use js "matchMedia('(prefers-reduced-motion: reduce)').matches"`) - No `transition: all` — properties listed explicitly - Only `transform` and `opacity` animated (not layout properties like width, height, top, left) @@ -568,9 +471,9 @@ The test: would a human designer at a respected studio ever ship this? Walk 2-3 key user flows and evaluate the *feel*, not just the function: ```bash -$B snapshot -i -$B click @e3 # perform action -$B snapshot -D # diff to see what changed +BitFun browser/computer-use snapshot -i +BitFun browser/computer-use click @e3 # perform action +BitFun browser/computer-use snapshot -D # diff to see what changed ``` Evaluate: @@ -596,13 +499,13 @@ Compare screenshots and observations across pages for: ### Output Locations -**Local:** `.gstack/design-reports/design-audit-{domain}-{YYYY-MM-DD}.md` +**Local:** `.bitfun/team/design-reports/design-audit-{domain}-{YYYY-MM-DD}.md` **Project-scoped:** ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG ``` -Write to: `~/.gstack/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md` +Write to: `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md` **Baseline:** Write `design-baseline.json` for regression mode: ```json @@ -680,7 +583,7 @@ Tie everything to user goals and product objectives. Always suggest specific imp 8. **Responsive is design, not just "not broken."** A stacked desktop layout on mobile is not responsive design — it's lazy. Evaluate whether the mobile layout makes *design* sense. 9. **Document incrementally.** Write each finding to the report as you find it. Don't batch. 10. **Depth over breadth.** 5-10 well-documented findings with screenshots and specific suggestions > 20 vague observations. -11. **Show screenshots to the user.** After every `$B screenshot`, `$B snapshot -a -o`, or `$B responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. +11. **Show screenshots to the user.** After every `BitFun browser/computer-use screenshot`, `BitFun browser/computer-use snapshot -a -o`, or `BitFun browser/computer-use responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. ### Design Hard Rules @@ -758,7 +661,7 @@ Record baseline design score and AI slop score at end of Phase 6. ## Output Structure ``` -~/.gstack/projects/$SLUG/designs/design-audit-{YYYYMMDD}/ +$HOME/.bitfun/team/projects/$SLUG/designs/design-audit-{YYYYMMDD}/ ├── design-audit-{domain}.md # Structured report ├── screenshots/ │ ├── first-impression.png # Phase 1 @@ -777,20 +680,20 @@ Record baseline design score and AI slop score at end of Phase 6. ## Design Outside Voices (parallel) -**Automatic:** Outside voices run automatically when Codex is available. No opt-in needed. +**Automatic:** Outside voices run automatically when outside-voice sub-agent is available. No opt-in needed. -**Check Codex availability:** +**Check outside-voice sub-agent availability:** ```bash which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" ``` -**If Codex is available**, launch both voices simultaneously: +**If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **Codex design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via Bash): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "Review the frontend source code in this repo. Evaluate against these design hard rules: +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. - Spacing: systematic (design tokens / CSS variables) or magic numbers? - Typography: expressive purposeful fonts or default stacks? - Color: CSS variables with defined system, or hardcoded hex scattered? @@ -826,7 +729,7 @@ Use a 5-minute timeout (`timeout: 300000`). After the command completes, read st cat "$TMPERR_DESIGN" && rm -f "$TMPERR_DESIGN" ``` -2. **Claude design subagent** (via Agent tool): +2. **Independent design subagent** (via BitFun Task tool): Dispatch a subagent with this prompt: "Review the frontend source code in this repo. You are an independent senior product designer doing a source-code design audit. Focus on CONSISTENCY PATTERNS across files rather than individual violations: - Are spacing values systematic across the codebase? @@ -837,14 +740,14 @@ Dispatch a subagent with this prompt: For each finding: what's wrong, severity (critical/high/medium), and the file:line." **Error handling (all non-blocking):** -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run `codex login` to authenticate." -- **Timeout:** "Codex timed out after 5 minutes." -- **Empty response:** "Codex returned no response." -- On any Codex error: proceed with Claude subagent output only, tagged `[single-model]`. -- If Claude subagent also fails: "Outside voices unavailable — continuing with primary review." -Present Codex output under a `CODEX SAYS (design source audit):` header. -Present subagent output under a `CLAUDE SUBAGENT (design consistency):` header. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." +- **Empty response:** "outside-voice sub-agent returned no response." +- On any outside-voice sub-agent error: proceed with independent subagent output only, tagged `[single-model]`. +- If independent subagent also fails: "Outside voices unavailable — continuing with primary review." + +Present outside-voice sub-agent output under a `CODEX SAYS (design source audit):` header. +Present subagent output under a `INDEPENDENT SUBAGENT (design consistency):` header. **Synthesis — Litmus scorecard:** @@ -853,7 +756,7 @@ Merge findings into the triage with `[codex]` / `[subagent]` / `[cross-model]` t **Log the result:** ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"design-outside-voices","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` Replace STATUS with "clean" or "issues_found", SOURCE with "codex+subagent", "codex-only", "subagent-only", or "unavailable". @@ -884,12 +787,12 @@ For each fixable finding, in impact order: - ONLY modify files directly related to the finding - Prefer CSS/styling changes over structural component changes -### 8a.5. Target Mockup (if DESIGN_READY) +### 8a.5. Target Mockup (if BitFun image/design capability is available) -If the gstack designer is available and the finding involves visual layout, hierarchy, or spacing (not just a CSS value fix like wrong color or font-size), generate a target mockup showing what the corrected version should look like: +If the BitFun image/design capability is available and the finding involves visual layout, hierarchy, or spacing (not just a CSS value fix like wrong color or font-size), generate a target mockup showing what the corrected version should look like: ```bash -$D generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png" +BitFun image/design capability generate --brief "" --output "$REPORT_DIR/screenshots/finding-NNN-target.png" ``` Show the user: "Here's the current state (screenshot) and here's what it should look like (mockup). Now I'll fix the source to match." @@ -919,10 +822,10 @@ git commit -m "style(design): FINDING-NNN — short description" Navigate back to the affected page and verify the fix: ```bash -$B goto -$B screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png" -$B console --errors -$B snapshot -D +BitFun browser/computer-use goto +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png" +BitFun browser/computer-use console --errors +BitFun browser/computer-use snapshot -D ``` Take **before/after screenshot pair** for every fix. @@ -970,7 +873,7 @@ DESIGN-FIX RISK: After all fixes are applied: 1. Re-run the design audit on all affected pages -2. If target mockups were generated during the fix loop AND `DESIGN_READY`: run `$D verify --mockup "$REPORT_DIR/screenshots/finding-NNN-target.png" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. +2. If target mockups were generated during the fix loop AND `BitFun image/design capability is available`: run `BitFun image/design capability verify --mockup "$REPORT_DIR/screenshots/finding-NNN-target.png" --screenshot "$REPORT_DIR/screenshots/finding-NNN-after.png"` to compare the fix result against the target. Include pass/fail in the report. 3. Compute final design score and AI slop score 4. **If final scores are WORSE than baseline:** WARN prominently — something regressed @@ -984,9 +887,9 @@ Write the report to `$REPORT_DIR` (already set up in the setup phase): **Also write a summary to the project index:** ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG ``` -Write a one-line summary to `~/.gstack/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md` with a pointer to the full report in `$REPORT_DIR`. +Write a one-line summary to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-design-audit-{datetime}.md` with a pointer to the full report in `$REPORT_DIR`. **Per-finding additions** (beyond standard design audit report): - Fix Status: verified / best-effort / reverted / deferred @@ -1021,7 +924,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"design-review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -1029,7 +932,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-document-release/SKILL.md b/src/crates/core/builtin_skills/gstack-document-release/SKILL.md index 98273e8a7..8548940d4 100644 --- a/src/crates/core/builtin_skills/gstack-document-release/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-document-release/SKILL.md @@ -2,7 +2,7 @@ name: document-release description: | Post-ship documentation update. Reads all project docs, cross-references the - diff, updates README/ARCHITECTURE/CONTRIBUTING/CLAUDE.md to match what shipped, + diff, updates README/ARCHITECTURE/CONTRIBUTING/AGENTS.md to match what shipped, polishes CHANGELOG voice, cleans up TODOS, and optionally bumps VERSION. Use when asked to "update the docs", "sync documentation", or "post-ship docs". Proactively suggest after a PR is merged or code is shipped. (gstack) @@ -37,6 +37,16 @@ subjective decisions. - Bump VERSION without asking — always use AskUserQuestion for version changes - Use `Write` tool on CHANGELOG.md — always use `Edit` with exact `old_string` matches +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the documentation-release methodology. Use existing Task sub-agents for read-only doc drift discovery, then keep edits in the main Team session. + +- Do not assume a Technical Writer sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom docs/writing sub-agents if available; otherwise use `Explore` for diff-to-doc mapping and `FileFinder` for locating impacted docs. +- Good parallel Task tracks: README/API drift, architecture docs drift, changelog/release-note gaps, and TODO cleanup candidates. +- Do not ask Task sub-agents to edit docs. Require evidence: changed behavior, affected docs, stale statements, and suggested wording. +- The main Team orchestrator owns all doc edits and risky narrative questions. + --- ## Step 1: Pre-flight & Diff Analysis @@ -60,7 +70,7 @@ git diff ...HEAD --name-only 3. Discover all documentation files in the repo: ```bash -find . -maxdepth 2 -name "*.md" -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.gstack/*" -not -path "./.context/*" | sort +find . -maxdepth 2 -name "*.md" -not -path "./.git/*" -not -path "./node_modules/*" -not -path "./.bitfun/team/*" -not -path "./.context/*" | sort ``` 4. Classify the changes into categories relevant to documentation: @@ -97,7 +107,7 @@ Read each documentation file and cross-reference it against the diff. Use these - Are workflow descriptions (dev setup, operational learnings, etc.) current? - Flag anything that would fail or confuse a first-time contributor. -**CLAUDE.md / project instructions:** +**AGENTS.md / project instructions:** - Does the project structure section match the actual file tree? - Are listed commands and scripts accurate? - Do build/test instructions match what's in package.json (or equivalent)? @@ -179,11 +189,11 @@ preserved them. This skill must NEVER do that. After auditing each file individually, do a cross-doc consistency pass: -1. Does the README's feature/capability list match what CLAUDE.md (or project instructions) describes? +1. Does the README's feature/capability list match what AGENTS.md (or project instructions) describes? 2. Does ARCHITECTURE's component list match CONTRIBUTING's project structure description? 3. Does CHANGELOG's latest version match the VERSION file? -4. **Discoverability:** Is every documentation file reachable from README.md or CLAUDE.md? If - ARCHITECTURE.md exists but neither README nor CLAUDE.md links to it, flag it. Every doc +4. **Discoverability:** Is every documentation file reachable from README.md or AGENTS.md? If + ARCHITECTURE.md exists but neither README nor AGENTS.md links to it, flag it. Every doc should be discoverable from one of the two entry-point files. 5. Flag any contradictions between documents. Auto-fix clear factual inconsistencies (e.g., a version mismatch). Use AskUserQuestion for narrative contradictions. @@ -265,8 +275,6 @@ committing. ```bash git commit -m "$(cat <<'EOF' docs: update project documentation for vX.Y.Z.W - -Co-Authored-By: Claude Opus 4.6 EOF )" ``` @@ -355,6 +363,6 @@ Where status is one of: - **Never bump VERSION silently.** Always ask. Even if already bumped, check whether it covers the full scope of changes. - **Be explicit about what changed.** Every edit gets a one-line summary. - **Generic heuristics, not project-specific.** The audit checks work on any repo. -- **Discoverability matters.** Every doc file should be reachable from README or CLAUDE.md. +- **Discoverability matters.** Every doc file should be reachable from README or AGENTS.md. - **Voice: friendly, user-forward, not obscure.** Write like you're explaining to a smart person who hasn't seen the code. diff --git a/src/crates/core/builtin_skills/gstack-investigate/SKILL.md b/src/crates/core/builtin_skills/gstack-investigate/SKILL.md index afc415dbc..8ff61a805 100644 --- a/src/crates/core/builtin_skills/gstack-investigate/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-investigate/SKILL.md @@ -18,6 +18,16 @@ description: | Fixing symptoms creates whack-a-mole debugging. Every fix that doesn't address root cause makes the next bug harder to find. Find the root cause, then fix it. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the debugging methodology. Use existing Task sub-agents to gather independent evidence, then keep hypothesis selection and fixes in the main Team session. + +- Do not assume a Debugger sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom debugging/domain sub-agents if available; otherwise use `Explore` for code-path tracing and `FileFinder` for locating logs, configs, tests, and affected files. +- Split independent evidence tracks into parallel Task calls when useful: reproduction path, recent-change audit, config/environment audit, and suspected subsystem trace. +- Keep Task work read-only until root cause is proven. Ask for facts, file paths, commands tried, observations, and confidence. +- The main Team orchestrator owns the root-cause statement, fix plan, implementation, and regression test. + --- ## Phase 1: Root Cause Investigation @@ -38,41 +48,7 @@ Gather context before forming any hypothesis. ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. Output: **"Root cause hypothesis: ..."** — a specific, testable claim about what is wrong and why. @@ -83,13 +59,13 @@ Output: **"Root cause hypothesis: ..."** — a specific, testable claim about wh After forming your root cause hypothesis, lock edits to the affected module to prevent scope creep. ```bash -[ -x "${CLAUDE_SKILL_DIR}/../freeze/bin/check-freeze.sh" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE" +[ -x "BitFun built-in freeze check" ] && echo "FREEZE_AVAILABLE" || echo "FREEZE_UNAVAILABLE" ``` **If FREEZE_AVAILABLE:** Identify the narrowest directory containing the affected files. Write it to the freeze state file: ```bash -STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}" +STATE_DIR="${BITFUN_TEAM_HOME:-$HOME/.bitfun/team}" mkdir -p "$STATE_DIR" echo "/" > "$STATE_DIR/freeze-dir.txt" echo "Debug scope locked to: /" @@ -203,7 +179,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"investigate","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -211,7 +187,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-office-hours/SKILL.md b/src/crates/core/builtin_skills/gstack-office-hours/SKILL.md index 19b91629d..621b34966 100644 --- a/src/crates/core/builtin_skills/gstack-office-hours/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-office-hours/SKILL.md @@ -20,6 +20,16 @@ You are a **YC office hours partner**. Your job is to ensure the problem is unde **HARD GATE:** Do NOT invoke any implementation skill, write any code, scaffold any project, or take any implementation action. Your only output is a design document. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, treat this skill as the product-thinking methodology and use existing Task sub-agents only for independent discovery that improves the design doc. + +- Do not assume role-named sub-agents exist. Choose only from the Task tool's available agents. +- Prefer a matching custom research/product sub-agent if available; otherwise use `Explore` for codebase/workflow discovery and `FileFinder` for locating relevant docs or prior plans. +- Keep all final problem framing, tradeoff decisions, and design-doc writing in the main Team session. +- Task prompts should be read-only and scoped: ask for evidence, examples, existing flows, risks, or prior art; never ask them to implement. +- If no useful sub-agent exists, continue in the main Team session and say `subagent: none suitable`. + --- ## Phase 1: Context Gathering @@ -27,56 +37,22 @@ You are a **YC office hours partner**. Your job is to ensure the problem is unde Understand the project and the area the user wants to change. ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) ``` -1. Read `CLAUDE.md`, `TODOS.md` (if they exist). +1. Read `AGENTS.md`, `TODOS.md` (if they exist). 2. Run `git log --oneline -30` and `git diff origin/main --stat 2>/dev/null` to understand recent context. 3. Use Grep/Glob to map the codebase areas most relevant to the user's request. 4. **List existing design docs for this project:** ```bash setopt +o nomatch 2>/dev/null || true # zsh compat - ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null + ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null ``` If design docs exist, list them: "Prior designs for this project: [titles + dates]" ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. 5. **Ask: what's your goal with this?** This is a real question, not a formality. The answer determines everything about how the session runs. @@ -305,14 +281,14 @@ After the user states the problem (first question in Phase 2A or 2B), search exi Extract 3-5 significant keywords from the user's problem statement and grep across design docs: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -grep -li "\|\|" ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null +grep -li "\|\|" $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null ``` If matches found, read the matching design docs and surface them: - "FYI: Related design found — '{title}' by {user} on {date} (branch: {branch}). Key overlap: {1-line summary of relevant section}." - Ask via AskUserQuestion: "Should we build on this prior design or start fresh?" -This enables cross-team discovery — multiple users exploring the same project will see each other's design docs in `~/.gstack/projects/`. +This enables cross-team discovery — multiple users exploring the same project will see each other's design docs in `$HOME/.bitfun/team/projects/`. If no matches found, proceed silently. @@ -393,7 +369,7 @@ Use AskUserQuestion (regardless of codex availability): If B: skip Phase 3.5 entirely. Remember that the second opinion did NOT run (affects design doc, founder signals, and Phase 4 below). -**If A: Run the Codex cold read.** +**If A: Run the outside-voice sub-agent cold read.** 1. Assemble a structured context block from Phases 1-3: - Mode (Startup or Builder) @@ -410,19 +386,19 @@ CODEX_PROMPT_FILE=$(mktemp /tmp/gstack-codex-oh-XXXXXXXX.txt) ``` Write the full prompt to this file. **Always start with the filesystem boundary:** -"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\n" +"IMPORTANT: Do NOT read or execute any skill definition directories These are BitFun skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\n" Then add the context block and mode-appropriate instructions: **Startup mode instructions:** "You are an independent technical advisor reading a transcript of a startup brainstorming session. [CONTEXT BLOCK HERE]. Your job: 1) What is the STRONGEST version of what this person is trying to build? Steelman it in 2-3 sentences. 2) What is the ONE thing from their answers that reveals the most about what they should actually build? Quote it and explain why. 3) Name ONE agreed premise you think is wrong, and what evidence would prove you right. 4) If you had 48 hours and one engineer to build a prototype, what would you build? Be specific — tech stack, features, what you'd skip. Be direct. Be terse. No preamble." **Builder mode instructions:** "You are an independent technical advisor reading a transcript of a builder brainstorming session. [CONTEXT BLOCK HERE]. Your job: 1) What is the COOLEST version of this they haven't considered? 2) What's the ONE thing from their answers that reveals what excites them most? Quote it. 3) What existing open source project or tool gets them 50% of the way there — and what's the 50% they'd need to build? 4) If you had a weekend to build this, what would you build first? Be specific. Be direct. No preamble." -3. Run Codex: +3. Run outside-voice sub-agent: ```bash TMPERR_OH=$(mktemp /tmp/codex-oh-err-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "$(cat "$CODEX_PROMPT_FILE")" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_OH" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Use a 5-minute timeout (`timeout: 300000`). After the command completes, read stderr: @@ -432,49 +408,49 @@ rm -f "$TMPERR_OH" "$CODEX_PROMPT_FILE" ``` **Error handling:** All errors are non-blocking — second opinion is a quality enhancement, not a prerequisite. -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \`codex login\` to authenticate." Fall back to Claude subagent. -- **Timeout:** "Codex timed out after 5 minutes." Fall back to Claude subagent. -- **Empty response:** "Codex returned no response." Fall back to Claude subagent. +- **Outside-voice unavailable:** If the selected BitFun sub-agent cannot run, skip this informational pass and continue with the main-session review. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." Fall back to independent subagent. +- **Empty response:** "outside-voice sub-agent returned no response." Fall back to independent subagent. -On any Codex error, fall back to the Claude subagent below. +On any outside-voice sub-agent error, fall back to the independent subagent below. -**If CODEX_NOT_AVAILABLE (or Codex errored):** +**If CODEX_NOT_AVAILABLE (or outside-voice sub-agent errored):** -Dispatch via the Agent tool. The subagent has fresh context — genuine independence. +Dispatch via the Task tool. The subagent has fresh context — genuine independence. Subagent prompt: same mode-appropriate prompt as above (Startup or Builder variant). -Present findings under a `SECOND OPINION (Claude subagent):` header. +Present findings under a `SECOND OPINION (independent subagent):` header. If the subagent fails or times out: "Second opinion unavailable. Continuing to Phase 4." 4. **Presentation:** -If Codex ran: +If outside-voice sub-agent ran: ``` -SECOND OPINION (Codex): +SECOND OPINION (outside-voice sub-agent): ════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════ ``` -If Claude subagent ran: +If independent subagent ran: ``` -SECOND OPINION (Claude subagent): +SECOND OPINION (independent subagent): ════════════════════════════════════════════════════════════ ════════════════════════════════════════════════════════════ ``` 5. **Cross-model synthesis:** After presenting the second opinion output, provide 3-5 bullet synthesis: - - Where Claude agrees with the second opinion - - Where Claude disagrees and why - - Whether the challenged premise changes Claude's recommendation + - Where BitFun agrees with the second opinion + - Where BitFun disagrees and why + - Whether the challenged premise changes BitFun's recommendation -6. **Premise revision check:** If Codex challenged an agreed premise, use AskUserQuestion: +6. **Premise revision check:** If outside-voice sub-agent challenged an agreed premise, use AskUserQuestion: -> Codex challenged premise #{N}: "{premise text}". Their argument: "{reasoning}". -> A) Revise this premise based on Codex's input +> outside-voice sub-agent challenged premise #{N}: "{premise text}". Their argument: "{reasoning}". +> A) Revise this premise based on outside-voice sub-agent's input > B) Keep the original premise — proceed to alternatives If A: revise the premise and note the revision. If B: proceed (and note that the user defended this premise with reasoning — this is a founder signal if they articulate WHY they disagree, not just dismiss). @@ -507,7 +483,7 @@ Rules: - One must be the **"minimal viable"** (fewest files, smallest diff, ships fastest). - One must be the **"ideal architecture"** (best long-term trajectory, most elegant). - One can be **creative/lateral** (unexpected approach, different framing of the problem). -- If the second opinion (Codex or Claude subagent) proposed a prototype in Phase 3.5, consider using it as a starting point for the creative/lateral approach. +- If the second opinion (outside-voice sub-agent or independent subagent) proposed a prototype in Phase 3.5, consider using it as a starting point for the creative/lateral approach. **RECOMMENDATION:** Choose [X] because [one-line reason]. @@ -517,26 +493,17 @@ Present via AskUserQuestion. Do NOT proceed without user approval of the approac ## Visual Design Exploration -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" -[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design -[ -x "$D" ] && echo "DESIGN_READY" || echo "DESIGN_NOT_AVAILABLE" -``` - -**If `DESIGN_NOT_AVAILABLE`:** Fall back to the HTML wireframe approach below -(the existing DESIGN_SKETCH section). Visual mockups require the design binary. - -**If `DESIGN_READY`:** Generate visual mockup explorations for the user. +Use BitFun built-in image/design capability when available. Do not install, build, +or call an external BitFun image/design capability. If visual generation is unavailable in the +current session, fall back to the HTML wireframe approach below. Generating visual mockups of the proposed design... (say "skip" if you don't need visuals) **Step 1: Set up the design directory** ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/mockup-$(date +%Y%m%d) +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +_DESIGN_DIR=$HOME/.bitfun/team/projects/$SLUG/designs/mockup-$(date +%Y%m%d) mkdir -p "$_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR" ``` @@ -549,7 +516,7 @@ explore wide across diverse directions. **Step 3: Generate 3 variants** ```bash -$D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" +BitFun image/design capability variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" ``` This generates 3 style variations of the same brief (~40 seconds total). @@ -560,21 +527,21 @@ Show each variant to the user inline first (read the PNGs with Read tool), then create and serve the comparison board: ```bash -$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve +BitFun image/design capability compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve ``` This opens the board in the user's default browser and blocks until feedback is received. Read stdout for the structured JSON result. No polling needed. -If `$D serve` is not available or fails, fall back to AskUserQuestion: +If `BitFun image/design capability serve` is not available or fails, fall back to AskUserQuestion: "I've opened the design board. Which variant do you prefer? Any feedback?" **Step 5: Handle feedback** If the JSON contains `"regenerated": true`: 1. Read `regenerateAction` (or `remixSpec` for remix requests) -2. Generate new variants with `$D iterate` or `$D variants` using updated brief -3. Create new board with `$D compare` +2. Generate new variants with `BitFun image/design capability iterate` or `BitFun image/design capability variants` using updated brief +3. Create new board with `BitFun image/design capability compare` 4. POST the new HTML to the running server via `curl -X POST http://localhost:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'` (parse the port from stderr: look for `SERVE_STARTED: port=XXXXX`) 5. Board auto-refreshes in the same tab @@ -627,12 +594,12 @@ SKETCH_FILE="/tmp/gstack-sketch-$(date +%s).html" **Step 3: Render and capture** ```bash -$B goto "file://$SKETCH_FILE" -$B screenshot /tmp/gstack-sketch.png +BitFun browser/computer-use goto "file://$SKETCH_FILE" +BitFun browser/computer-use screenshot /tmp/gstack-sketch.png ``` -If `$B` is not available (browse binary not set up), skip the render step. Tell the -user: "Visual sketch requires the browse binary. Run the setup script to enable it." +If `BitFun browser/computer-use` is not available (BitFun browser/computer-use tooling not set up), skip the render step. Tell the +user: "Use BitFun browser/computer-use tooling for the visual sketch when it is available. If unavailable, skip the render step and keep the HTML sketch artifact." **Step 4: Present and iterate** @@ -655,26 +622,26 @@ After the wireframe is approved, offer outside design perspectives: which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" ``` -If Codex is available, use AskUserQuestion: -> "Want outside design perspectives on the chosen approach? Codex proposes a visual thesis, content plan, and interaction ideas. A Claude subagent proposes an alternative aesthetic direction." +If a suitable BitFun outside-voice or review sub-agent is available, use AskUserQuestion: +> "Want outside design perspectives on the chosen approach? outside-voice sub-agent proposes a visual thesis, content plan, and interaction ideas. A independent subagent proposes an alternative aesthetic direction." > > A) Yes — get outside design voices > B) No — proceed without If user chooses A, launch both voices simultaneously: -1. **Codex** (via Bash, `model_reasoning_effort="medium"`): +1. **outside-voice sub-agent** (via Bash, `model_reasoning_effort="medium"`): ```bash TMPERR_SKETCH=$(mktemp /tmp/codex-sketch-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "For this product approach, provide: a visual thesis (one sentence — mood, material, energy), a content plan (hero → support → detail → CTA), and 2 interaction ideas that change page feel. Apply beautiful defaults: composition-first, brand-first, cardless, poster not document. Be opinionated." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="medium"' --enable web_search_cached 2>"$TMPERR_SKETCH" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Use a 5-minute timeout (`timeout: 300000`). After completion: `cat "$TMPERR_SKETCH" && rm -f "$TMPERR_SKETCH"` -2. **Claude subagent** (via Agent tool): +2. **Independent subagent** (via BitFun Task tool): "For this product approach, what design direction would you recommend? What aesthetic, typography, and interaction patterns fit? What would make this approach feel inevitable to the user? Be specific — font names, hex colors, spacing values." -Present Codex output under `CODEX SAYS (design sketch):` and subagent output under `CLAUDE SUBAGENT (design direction):`. +Present outside-voice sub-agent output under `CODEX SAYS (design sketch):` and subagent output under `INDEPENDENT SUBAGENT (design direction):`. Error handling: all non-blocking. On failure, skip and continue. --- @@ -691,7 +658,7 @@ Track which of these signals appeared during the session: - Has **domain expertise** — knows this space from the inside - Showed **taste** — cared about getting the details right - Showed **agency** — actually building, not just planning -- **Defended premise with reasoning** against cross-model challenge (kept original premise when Codex disagreed AND articulated specific reasoning for why — dismissal without reasoning does not count) +- **Defended premise with reasoning** against cross-model challenge (kept original premise when outside-voice sub-agent disagreed AND articulated specific reasoning for why — dismissal without reasoning does not count) Count the signals. You'll use this count in Phase 6 to determine which tier of closing message to use. @@ -701,7 +668,7 @@ After counting signals, append a session entry to the builder profile. This is t source of truth for all closing state (tier, resource dedup, journey tracking). ```bash -mkdir -p "${GSTACK_HOME:-$HOME/.gstack}" +mkdir -p "${BITFUN_TEAM_HOME:-$HOME/.bitfun/team}" ``` Append one JSON line with these fields (substitute actual values from this session): @@ -716,7 +683,7 @@ Append one JSON line with these fields (substitute actual values from this sessi - `topics`: array of 2-3 topic keywords that describe what this session was about ```bash -echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +echo '{"date":"TIMESTAMP","mode":"MODE","project_slug":"SLUG","signal_count":N,"signals":SIGNALS_ARRAY,"design_doc":"DOC_PATH","assignment":"ASSIGNMENT_TEXT","resources_shown":[],"topics":TOPICS_ARRAY}' >> "${BITFUN_TEAM_HOME:-$HOME/.bitfun/team}/builder-profile.jsonl" ``` This entry is append-only. The `resources_shown` field will be updated via a second append @@ -729,7 +696,7 @@ after resource selection in Phase 6 Beat 3.5. Write the design document to the project directory. ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG USER=$(whoami) DATETIME=$(date +%Y%m%d-%H%M%S) ``` @@ -737,11 +704,11 @@ DATETIME=$(date +%Y%m%d-%H%M%S) **Design lineage:** Before writing, check for existing design docs on this branch: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -PRIOR=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) +PRIOR=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) ``` If `$PRIOR` exists, the new doc gets a `Supersedes:` field referencing it. This creates a revision chain — you can trace how a design evolved across office hours sessions. -Write to `~/.gstack/projects/{slug}/{user}-{branch}-design-{datetime}.md`: +Write to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-design-{datetime}.md`: ### Startup mode design doc template: @@ -774,7 +741,7 @@ Supersedes: {prior filename — omit this line if first design on this branch} {from Phase 3} ## Cross-Model Perspective -{If second opinion ran in Phase 3.5 (Codex or Claude subagent): independent cold read — steelman, key insight, challenged premise, prototype suggestion. Verbatim or close paraphrase. If second opinion did NOT run (skipped or unavailable): omit this section entirely — do not include it.} +{If second opinion ran in Phase 3.5 (outside-voice sub-agent or independent subagent): independent cold read — steelman, key insight, challenged premise, prototype suggestion. Verbatim or close paraphrase. If second opinion did NOT run (skipped or unavailable): omit this section entirely — do not include it.} ## Approaches Considered ### Approach A: {name} @@ -831,7 +798,7 @@ Supersedes: {prior filename — omit this line if first design on this branch} {from Phase 3} ## Cross-Model Perspective -{If second opinion ran in Phase 3.5 (Codex or Claude subagent): independent cold read — coolest version, key insight, existing tools, prototype suggestion. Verbatim or close paraphrase. If second opinion did NOT run (skipped or unavailable): omit this section entirely — do not include it.} +{If second opinion ran in Phase 3.5 (outside-voice sub-agent or independent subagent): independent cold read — coolest version, key insight, existing tools, prototype suggestion. Verbatim or close paraphrase. If second opinion did NOT run (skipped or unavailable): omit this section entirely — do not include it.} ## Approaches Considered ### Approach A: {name} @@ -867,7 +834,7 @@ Before presenting the document to the user for approval, run an adversarial revi **Step 1: Dispatch reviewer subagent** -Use the Agent tool to dispatch an independent reviewer. The reviewer has fresh context +Use the Task tool to dispatch an independent reviewer. The reviewer has fresh context and cannot see the brainstorming conversation — only the document. This ensures genuine adversarial independence. @@ -918,8 +885,8 @@ After the loop completes (PASS, max iterations, or convergence guard): 3. Append metrics: ```bash -mkdir -p ~/.gstack/analytics -echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> ~/.gstack/analytics/spec-review.jsonl 2>/dev/null || true +mkdir -p $HOME/.bitfun/team/analytics +echo '{"skill":"office-hours","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> $HOME/.bitfun/team/analytics/spec-review.jsonl 2>/dev/null || true ``` Replace ITERATIONS, FOUND, FIXED, REMAINING, SCORE with actual values from the review. @@ -941,7 +908,9 @@ over time. ### Step 1: Read Builder Profile ```bash -PROFILE=$(~/.claude/skills/gstack/bin/gstack-builder-profile 2>/dev/null) || PROFILE="SESSION_COUNT: 0 +PROFILE=$(printf "SESSION_COUNT: 0 +TOTAL_HOURS: 0 +" 2>/dev/null) || PROFILE="SESSION_COUNT: 0 TIER: introduction" SESSION_TIER=$(echo "$PROFILE" | grep "^TIER:" | awk '{print $2}') SESSION_COUNT=$(echo "$PROFILE" | grep "^SESSION_COUNT:" | awk '{print $2}') @@ -969,7 +938,7 @@ One paragraph that weaves specific session callbacks with the golden age framing - GOOD: "You pushed back when I challenged premise #2. Most people just agree." - BAD: "You demonstrated conviction and independent thinking." -Example: "The way you think about this problem, [specific callback], that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with Claude Code. The engineering barrier is gone. What remains is taste, and you just demonstrated that." +Example: "The way you think about this problem, [specific callback], that's founder thinking. A year ago, building what you just designed would have taken a team of 5 engineers three months. Today you can build it this weekend with BitFun. The engineering barrier is gone. What remains is taste, and you just demonstrated that." **Beat 2: "One more thing."** @@ -1065,11 +1034,11 @@ Design trajectory with interpretation: "You started this as a side project. But you've named specific users, pushed back when challenged, and your designs keep getting sharper each time. I don't think this is a side project anymore. Have you thought about whether this could be a company?" This must feel earned, not broadcast. If the evidence doesn't support it, skip entirely. -**Builder Journey Summary** (session 5+): Auto-generate `~/.gstack/builder-journey.md` +**Builder Journey Summary** (session 5+): Auto-generate `$HOME/.bitfun/team/builder-journey.md` with a narrative arc (not a data table). The arc tells the STORY of their journey in second person, referencing specific things they said across sessions. Then open it: ```bash -open "${GSTACK_HOME:-$HOME/.gstack}/builder-journey.md" +open "${BITFUN_TEAM_HOME:-$HOME/.bitfun/team}/builder-journey.md" ``` Then proceed to Founder Resources below. @@ -1084,7 +1053,7 @@ The data speaks. No pitch needed. Full accumulated signal summary from the profile. -Auto-generate updated `~/.gstack/builder-journey.md` with narrative arc. Open it. +Auto-generate updated `$HOME/.bitfun/team/builder-journey.md` with narrative arc. Open it. Then proceed to Founder Resources below. @@ -1171,13 +1140,13 @@ PAUL GRAHAM ESSAYS: 1. Log the selected resource URLs to the builder profile (single source of truth). Append a resource-tracking entry: ```bash -echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${GSTACK_HOME:-$HOME/.gstack}/builder-profile.jsonl" +echo '{"date":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","mode":"resources","project_slug":"'"${SLUG:-unknown}"'","signal_count":0,"signals":[],"design_doc":"","assignment":"","resources_shown":["URL1","URL2","URL3"],"topics":[]}' >> "${BITFUN_TEAM_HOME:-$HOME/.bitfun/team}/builder-profile.jsonl" ``` 2. Log the selection to analytics: ```bash -mkdir -p ~/.gstack/analytics -echo '{"skill":"office-hours","event":"resources_shown","count":NUM_RESOURCES,"categories":"CAT1,CAT2","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +mkdir -p $HOME/.bitfun/team/analytics +echo '{"skill":"office-hours","event":"resources_shown","count":NUM_RESOURCES,"categories":"CAT1,CAT2","ts":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'"}' >> $HOME/.bitfun/team/analytics/skill-usage.jsonl 2>/dev/null || true ``` 3. Use AskUserQuestion to offer opening the resources: @@ -1203,7 +1172,7 @@ After the plea, suggest the next step: - **`/plan-eng-review`** for well-scoped implementation planning — lock in architecture, tests, edge cases - **`/plan-design-review`** for visual/UX design review -The design doc at `~/.gstack/projects/` is automatically discoverable by downstream skills — they will read it during their pre-review system audit. +The design doc at `$HOME/.bitfun/team/projects/` is automatically discoverable by downstream skills — they will read it during their pre-review system audit. --- @@ -1213,7 +1182,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"office-hours","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -1221,7 +1190,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md b/src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md index b181d6b81..81140a42e 100644 --- a/src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-plan-ceo-review/SKILL.md @@ -24,6 +24,16 @@ But your posture depends on what the user needs: Critical rule: In ALL modes, the user is 100% in control. Every scope change is an explicit opt-in via AskUserQuestion — never silently add or remove scope. Once the user selects a mode, COMMIT to it. Do not silently drift toward a different mode. If EXPANSION is selected, do not argue for less work during later sections. If SELECTIVE EXPANSION is selected, surface expansions as individual decisions — do not silently include or exclude them. If REDUCTION is selected, do not sneak scope back in. Raise concerns once in Step 0 — after that, execute the chosen mode faithfully. Do NOT make any code changes. Do NOT start implementation. Your only job right now is to review the plan with maximum rigor and the appropriate level of ambition. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the CEO/product-review lens. Use existing Task sub-agents to collect independent evidence, then make the final CEO judgment in the main Team session. + +- Do not assume a CEO/Product sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom product/strategy/research sub-agent if available; otherwise use `Explore` for repository/product-surface discovery and `FileFinder` for relevant plans, TODOs, docs, or prior decisions. +- Keep Task work read-only. Ask sub-agents for evidence, scope risks, user-impact gaps, hidden dependencies, and concrete examples. +- In parallel plan-review batches, let this role return a compact CEO brief: `mode`, `must-fix before build`, `scope asks`, `risks accepted`, `recommended next decision`. +- Do not let sub-agents decide scope changes. The main Team orchestrator must synthesize and ask the user. + ## Prime Directives 1. Zero silent failures. Every failure mode must be visible — to the system, to the team, to the user. If a failure can happen silently, that is a critical defect in the plan. 2. Every error has a name. Don't say "handle errors." Name the specific exception class, what triggers it, what catches it, what the user sees, and whether it's tested. Catch-all error handling (e.g., catch Exception, rescue StandardError, except Exception) is a code smell — call it out. @@ -87,15 +97,15 @@ git stash list # Any stashed work grep -r "TODO\|FIXME\|HACK\|XXX" -l --exclude-dir=node_modules --exclude-dir=vendor --exclude-dir=.git . | head -30 git log --since=30.days --name-only --format="" | sort | uniq -c | sort -rn | head -20 # Recently touched files ``` -Then read CLAUDE.md, TODOS.md, and any existing architecture docs. +Then read AGENTS.md, TODOS.md, and any existing architecture docs. **Design doc check:** ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._- 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) +[ -z "$DESIGN" ] && DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` If a design doc exists (from `/office-hours`), read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design. @@ -103,7 +113,7 @@ If a design doc exists (from `/office-hours`), read it. Use it as the source of **Handoff note check** (reuses $SLUG and $BRANCH from the design doc check above): ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -HANDOFF=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-ceo-handoff-*.md 2>/dev/null | head -1) +HANDOFF=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-ceo-handoff-*.md 2>/dev/null | head -1) [ -n "$HANDOFF" ] && echo "HANDOFF_FOUND: $HANDOFF" || echo "NO_HANDOFF" ``` If this block runs in a separate shell from the design doc check, recompute $SLUG and $BRANCH first using the same commands from that block. @@ -140,7 +150,7 @@ If they choose A: Say: "Running /office-hours inline. Once the design doc is ready, I'll pick up the review right where we left off." -Read the `/office-hours` skill file at `~/.claude/skills/gstack/office-hours/SKILL.md` using the Read tool. +Read the `/office-hours` skill file at `the bundled office-hours skill via the Skill tool` using the Read tool. **If unreadable:** Skip with "Could not load /office-hours — skipping." and continue. @@ -163,10 +173,10 @@ Execute every other section at full depth. When the loaded skill's instructions After /office-hours completes, re-run the design doc check: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._- 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) +[ -z "$DESIGN" ] && DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` @@ -186,7 +196,7 @@ If they keep going, proceed normally — no guilt, no re-asking. If they choose A: -Read the `/office-hours` skill file at `~/.claude/skills/gstack/office-hours/SKILL.md` using the Read tool. +Read the `/office-hours` skill file at `the bundled office-hours skill via the Skill tool` using the Read tool. **If unreadable:** Skip with "Could not load /office-hours — skipping." and continue. @@ -249,41 +259,7 @@ Feed into the Premise Challenge (0A) and Dream State Mapping (0C). If you find a ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Step 0: Nuclear Scope Challenge + Mode Selection @@ -362,17 +338,17 @@ Rules: After the opt-in/cherry-pick ceremony, write the plan to disk so the vision and decisions survive beyond this conversation. Only run this step for EXPANSION and SELECTIVE EXPANSION modes. ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG/ceo-plans +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG/ceo-plans ``` Before writing, check for existing CEO plans in the ceo-plans/ directory. If any are >30 days old or their branch has been merged/deleted, offer to archive them: ```bash -mkdir -p ~/.gstack/projects/$SLUG/ceo-plans/archive -# For each stale plan: mv ~/.gstack/projects/$SLUG/ceo-plans/{old-plan}.md ~/.gstack/projects/$SLUG/ceo-plans/archive/ +mkdir -p $HOME/.bitfun/team/projects/$SLUG/ceo-plans/archive +# For each stale plan: mv $HOME/.bitfun/team/projects/$SLUG/ceo-plans/{old-plan}.md $HOME/.bitfun/team/projects/$SLUG/ceo-plans/archive/ ``` -Write to `~/.gstack/projects/$SLUG/ceo-plans/{date}-{feature-slug}.md` using this format: +Write to `$HOME/.bitfun/team/projects/$SLUG/ceo-plans/{date}-{feature-slug}.md` using this format: ```markdown --- @@ -414,7 +390,7 @@ Before presenting the document to the user for approval, run an adversarial revi **Step 1: Dispatch reviewer subagent** -Use the Agent tool to dispatch an independent reviewer. The reviewer has fresh context +Use the Task tool to dispatch an independent reviewer. The reviewer has fresh context and cannot see the brainstorming conversation — only the document. This ensures genuine adversarial independence. @@ -465,8 +441,8 @@ After the loop completes (PASS, max iterations, or convergence guard): 3. Append metrics: ```bash -mkdir -p ~/.gstack/analytics -echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> ~/.gstack/analytics/spec-review.jsonl 2>/dev/null || true +mkdir -p $HOME/.bitfun/team/analytics +echo '{"skill":"plan-ceo-review","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","iterations":ITERATIONS,"issues_found":FOUND,"issues_fixed":FIXED,"remaining":REMAINING,"quality_score":SCORE}' >> $HOME/.bitfun/team/analytics/spec-review.jsonl 2>/dev/null || true ``` Replace ITERATIONS, FOUND, FIXED, REMAINING, SCORE with actual values from the review. @@ -666,7 +642,7 @@ Test pyramid check: Many unit, fewer integration, few E2E? Or inverted? Flakiness risk: Flag any test depending on time, randomness, external services, or ordering. Load/stress test requirements: For any new codepath called frequently or processing significant data. -For LLM/prompt changes: Check CLAUDE.md for the "Prompt/LLM changes" file patterns. If this plan touches ANY of those patterns, state which eval suites must be run, which cases should be added, and what baselines to compare against. +For LLM/prompt changes: Check AGENTS.md for the "Prompt/LLM changes" file patterns. If this plan touches ANY of those patterns, state which eval suites must be run, which cases should be added, and what baselines to compare against. **STOP.** AskUserQuestion once per issue. Do NOT batch. Recommend + WHY. If no issues or fix is obvious, state what you'll do and move on — don't waste a question. Do NOT proceed until user responds. ### Section 7: Performance Review @@ -785,7 +761,7 @@ Construct this prompt (substitute the actual plan content — if plan content ex truncate to the first 30KB and note "Plan truncated for size"). **Always start with the filesystem boundary instruction:** -"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has +"IMPORTANT: Do NOT read or execute any skill definition directories These are BitFun skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has already been through a multi-section review. Your job is NOT to repeat that review. Instead, find what it missed. Look for: logical gaps and unstated assumptions that survived the review scrutiny, overcomplexity (is there a fundamentally simpler @@ -802,7 +778,7 @@ THE PLAN: ```bash TMPERR_PV=$(mktemp /tmp/codex-planreview-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_PV" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Use a 5-minute timeout (`timeout: 300000`). After the command completes, read stderr: @@ -820,19 +796,19 @@ CODEX SAYS (plan review — outside voice): ``` **Error handling:** All errors are non-blocking — the outside voice is informational. -- Auth failure (stderr contains "auth", "login", "unauthorized"): "Codex auth failed. Run \`codex login\` to authenticate." -- Timeout: "Codex timed out after 5 minutes." -- Empty response: "Codex returned no response." +- Outside-voice unavailable: if the selected BitFun sub-agent cannot run, skip this informational pass and continue with the main-session review. +- Timeout: "outside-voice sub-agent timed out after 5 minutes." +- Empty response: "outside-voice sub-agent returned no response." -On any Codex error, fall back to the Claude adversarial subagent. +On any outside-voice sub-agent error, fall back to the BitFun adversarial subagent. -**If CODEX_NOT_AVAILABLE (or Codex errored):** +**If CODEX_NOT_AVAILABLE (or outside-voice sub-agent errored):** -Dispatch via the Agent tool. The subagent has fresh context — genuine independence. +Dispatch via the Task tool. The subagent has fresh context — genuine independence. Subagent prompt: same plan review prompt as above. -Present findings under an `OUTSIDE VOICE (Claude subagent):` header. +Present findings under an `OUTSIDE VOICE (independent subagent):` header. If the subagent fails or times out: "Outside voice unavailable. Continuing to outputs." @@ -874,13 +850,13 @@ If no tension points exist, note: "No cross-model tension — both reviewers agr **Persist the result:** ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"codex-plan-review","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute: STATUS = "clean" if no findings, "issues_found" if findings exist. -SOURCE = "codex" if Codex ran, "claude" if subagent ran. +SOURCE = "codex" if outside-voice sub-agent ran, "subagent" if a BitFun Task sub-agent ran. -**Cleanup:** Run `rm -f "$TMPERR_PV"` after processing (if Codex was used). +**Cleanup:** Run `rm -f "$TMPERR_PV"` after processing (if outside-voice sub-agent was used). --- @@ -927,7 +903,7 @@ Complete table of every method that can fail, every exception class, rescued sta Any row with RESCUED=N, TEST=N, USER SEES=Silent → **CRITICAL GAP**. ### TODOS.md updates -Present each potential TODO as its own individual AskUserQuestion. Never batch TODOs — one per question. Never silently skip this step. Follow the format in `.claude/skills/review/TODOS-format.md`. +Present each potential TODO as its own individual AskUserQuestion. Never batch TODOs — one per question. Never silently skip this step. Follow the format in `the built-in review TODO format`. For each TODO, describe: * **What:** One-line description of the work. @@ -986,7 +962,7 @@ List every ASCII diagram in files this plan touches. Still accurate? | TODOS.md updates | ___ items proposed | | Scope proposals | ___ proposed, ___ accepted (EXP + SEL) | | CEO plan | written / skipped (HOLD/REDUCTION) | - | Outside voice | ran (codex/claude) / skipped | + | Outside voice | ran (codex/subagent) / skipped | | Lake Score | X/Y recommendations chose complete option | | Diagrams produced | ___ (list types) | | Stale diagrams found | ___ | @@ -1004,8 +980,8 @@ the review is complete and the context is no longer needed. ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -rm -f ~/.gstack/projects/$SLUG/*-$BRANCH-ceo-handoff-*.md 2>/dev/null || true +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +rm -f $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-ceo-handoff-*.md 2>/dev/null || true ``` ## Review Log @@ -1013,13 +989,13 @@ rm -f ~/.gstack/projects/$SLUG/*-$BRANCH-ceo-handoff-*.md 2>/dev/null || true After producing the Completion Summary above, persist the review result. **PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes review metadata to -`~/.gstack/` (user config directory, not project files). The skill preamble -already writes to `~/.gstack/sessions/` and `~/.gstack/analytics/` — this is +`$HOME/.bitfun/team/` (user config directory, not project files). The skill preamble +already writes to `$HOME/.bitfun/team/sessions/` and `$HOME/.bitfun/team/analytics/` — this is the same pattern. The review dashboard depends on this data. Skipping this command breaks the review readiness dashboard in /ship. ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-ceo-review","timestamp":"TIMESTAMP","status":"STATUS","unresolved":N,"critical_gaps":N,"mode":"MODE","scope_proposed":N,"scope_accepted":N,"scope_deferred":N,"commit":"COMMIT"}' +true # BitFun Team Mode has no external review-log helper ``` Before running this command, substitute the placeholder values from the Completion Summary you just produced: @@ -1038,10 +1014,10 @@ Before running this command, substitute the placeholder values from the Completi After completing the review, read the review log and config to display the dashboard. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` -Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, codex-review, codex-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `codex-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `codex-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. +Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, outside-voice-review, outside-voice-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `outside-voice-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `outside-voice-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. **Source attribution:** If the most recent entry for a skill has a \`"via"\` field, append it to the status label in parentheses. Examples: `plan-eng-review` with `via:"autoplan"` shows as "CLEAR (PLAN via /autoplan)". `review` with `via:"ship"` shows as "CLEAR (DIFF via /ship)". Entries without a `via` field show as "CLEAR (PLAN)" or "CLEAR (DIFF)" as before. @@ -1066,16 +1042,16 @@ Display: ``` **Review tiers:** -- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`gstack-config set skip_eng_review true\` (the "don't bother me" setting). +- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`Team Mode setting skip_eng_review=true\` (the "don't bother me" setting). - **CEO Review (optional):** Use your judgment. Recommend it for big product/business changes, new user-facing features, or scope decisions. Skip for bug fixes, refactors, infra, and cleanup. - **Design Review (optional):** Use your judgment. Recommend it for UI/UX changes. Skip for backend-only, infra, or prompt-only changes. -- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both Claude adversarial subagent and Codex adversarial challenge. Large diffs (200+ lines) additionally get Codex structured review with P1 gate. No configuration needed. -- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to Claude subagent if Codex is unavailable. Never gates shipping. +- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both BitFun adversarial subagent and outside-voice sub-agent adversarial challenge. Large diffs (200+ lines) additionally get outside-voice sub-agent structured review with P1 gate. No configuration needed. +- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to independent subagent if outside-voice sub-agent is unavailable. Never gates shipping. **Verdict logic:** - **CLEARED**: Eng Review has >= 1 entry within 7 days from either \`review\` or \`plan-eng-review\` with status "clean" (or \`skip_eng_review\` is \`true\`) - **NOT CLEARED**: Eng Review missing, stale (>7 days), or has open issues -- CEO, Design, and Codex reviews are shown for context but never block shipping +- CEO, Design, and outside-voice sub-agent reviews are shown for context but never block shipping - If \`skip_eng_review\` config is \`true\`, Eng Review shows "SKIPPED (global)" and verdict is CLEARED **Staleness detection:** After displaying the dashboard, check if any existing reviews may be stale: @@ -1111,7 +1087,7 @@ Parse each JSONL entry. Each skill logs different fields: → Findings: "score: {initial_score}/10 → {overall_score}/10, TTHW: {tthw_current} → {tthw_target}" - **devex-review**: \`status\`, \`overall_score\`, \`product_type\`, \`tthw_measured\`, \`dimensions_tested\`, \`dimensions_inferred\`, \`boomerang\`, \`commit\` → Findings: "score: {overall_score}/10, TTHW: {tthw_measured}, {dimensions_tested} tested/{dimensions_inferred} inferred" -- **codex-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` +- **outside-voice-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` → Findings: "{findings} findings, {findings_fixed}/{findings} fixed" All fields needed for the Findings column are now present in the JSONL entries. @@ -1126,7 +1102,7 @@ Produce this markdown table: | Review | Trigger | Why | Runs | Status | Findings | |--------|---------|-----|------|--------|----------| | CEO Review | \`/plan-ceo-review\` | Scope & strategy | {runs} | {status} | {findings} | -| Codex Review | \`/codex review\` | Independent 2nd opinion | {runs} | {status} | {findings} | +| outside-voice sub-agent Review | \`BitFun Task outside-voice review\` | Independent 2nd opinion | {runs} | {status} | {findings} | | Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | {runs} | {status} | {findings} | | Design Review | \`/plan-design-review\` | UI/UX gaps | {runs} | {status} | {findings} | | DX Review | \`/plan-devex-review\` | Developer experience gaps | {runs} | {status} | {findings} | @@ -1134,8 +1110,8 @@ Produce this markdown table: Below the table, add these lines (omit any that are empty/not applicable): -- **CODEX:** (only if codex-review ran) — one-line summary of codex fixes -- **CROSS-MODEL:** (only if both Claude and Codex reviews exist) — overlap analysis +- **CODEX:** (only if outside-voice-review ran) — one-line summary of codex fixes +- **CROSS-MODEL:** (only if both BitFun and outside-voice sub-agent reviews exist) — overlap analysis - **UNRESOLVED:** total unresolved decisions across all reviews - **VERDICT:** list reviews that are CLEAR (e.g., "CEO + ENG CLEARED — ready to implement"). If Eng Review is not CLEAR and not skipped globally, append "eng review required". @@ -1177,7 +1153,7 @@ At the end of the review, if the vision produced a compelling feature direction, "The vision from this review produced {N} accepted scope expansions. Want to promote it to a design doc in the repo?" - **A)** Promote to `docs/designs/{FEATURE}.md` (committed to repo, visible to the team) -- **B)** Keep in `~/.gstack/projects/` only (local, personal reference) +- **B)** Keep in `$HOME/.bitfun/team/projects/` only (local, personal reference) - **C)** Skip If promoted, copy the CEO plan content to `docs/designs/{FEATURE}.md` (create the directory if needed) and update the `status` field in the original CEO plan from `ACTIVE` to `PROMOTED`. @@ -1195,7 +1171,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"plan-ceo-review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -1203,7 +1179,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md b/src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md index 97b0f7d95..225758a3d 100644 --- a/src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-plan-design-review/SKILL.md @@ -17,6 +17,16 @@ to find missing design decisions and ADD THEM TO THE PLAN before implementation. The output of this skill is a better plan, not a document about the plan. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the design-review lens. Use existing Task sub-agents for independent UI/UX discovery only when they add evidence, then keep design decisions in the main Team session. + +- Do not assume a Designer sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom design/frontend/accessibility sub-agent if available; otherwise use `Explore` for component/style-system discovery and `FileFinder` for design docs, screenshots, routes, styles, and UI tests. +- Use `ComputerUse` only when the review needs browser/desktop inspection and it is available. +- Keep Task work read-only before Build. Ask for hierarchy gaps, edge cases, accessibility risks, responsive concerns, existing design conventions, and screenshots/paths when relevant. +- In parallel plan-review batches, return a compact Design brief: `UX blockers`, `visual/system risks`, `required states`, `accessibility notes`, `plan edits`. + ## Design Philosophy You are not here to rubber-stamp this plan's UI. You are here to ensure that when @@ -28,9 +38,9 @@ choices. Do NOT make any code changes. Do NOT start implementation. Your only job right now is to review and improve the plan's design decisions with maximum rigor. -### The gstack designer — YOUR PRIMARY TOOL +### The BitFun image/design capability — YOUR PRIMARY TOOL -You have the **gstack designer**, an AI mockup generator that creates real visual mockups +You have the **BitFun image/design capability**, an AI mockup generator that creates real visual mockups from design briefs. This is your signature capability. Use it by default, not as an afterthought. @@ -46,7 +56,7 @@ Commands: `generate` (single mockup), `variants` (multiple directions), `compare (side-by-side review board), `iterate` (refine with feedback), `check` (cross-model quality gate via GPT-4o vision), `evolve` (improve from screenshot). -Setup is handled by the DESIGN SETUP section below. If `DESIGN_READY` is printed, +Setup is handled by the DESIGN SETUP section below. If `BitFun image/design capability is available` is printed, the designer is available and you should use it. ## Design Principles @@ -98,7 +108,7 @@ git diff --stat Then read: - The plan file (current plan or branch diff) -- CLAUDE.md — project conventions +- AGENTS.md — project conventions - DESIGN.md — if it exists, ALL design decisions calibrate against it - TODOS.md — any design-related TODOs this plan touches @@ -116,46 +126,12 @@ Analyze the plan. If it involves NONE of: new UI screens/pages, changes to exist Report findings before proceeding to Step 0. -## DESIGN SETUP (run this check BEFORE any design mockup command) +## DESIGN SETUP -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -D="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/design/dist/design" ] && D="$_ROOT/.claude/skills/gstack/design/dist/design" -[ -z "$D" ] && D=~/.claude/skills/gstack/design/dist/design -if [ -x "$D" ]; then - echo "DESIGN_READY: $D" -else - echo "DESIGN_NOT_AVAILABLE" -fi -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "BROWSE_READY: $B" -else - echo "BROWSE_NOT_AVAILABLE (will use 'open' to view comparison boards)" -fi -``` - -If `DESIGN_NOT_AVAILABLE`: skip visual mockup generation and fall back to the -existing HTML wireframe approach (`DESIGN_SKETCH`). Design mockups are a -progressive enhancement, not a hard requirement. - -If `BROWSE_NOT_AVAILABLE`: use `open file://...` instead of `$B goto` to open -comparison boards. The user just needs to see the HTML file in any browser. - -If `DESIGN_READY`: the design binary is available for visual mockup generation. -Commands: -- `$D generate --brief "..." --output /path.png` — generate a single mockup -- `$D variants --brief "..." --count 3 --output-dir /path/` — generate N style variants -- `$D compare --images "a.png,b.png,c.png" --output /path/board.html --serve` — comparison board + HTTP server -- `$D serve --html /path/board.html` — serve comparison board and collect feedback via HTTP -- `$D check --image /path.png --brief "..."` — vision quality gate -- `$D iterate --session /path/session.json --feedback "..." --output /path.png` — iterate +Use BitFun built-in image/design and browser/computer-use capabilities. Do not install, build, or call external `design` or `browse` binaries. Generate mockups, comparison boards, screenshots, and visual QA artifacts through BitFun tools; if a visual generation capability is not available in the current session, fall back to HTML wireframes and code-level design review. **CRITICAL PATH RULE:** All design artifacts (mockups, comparison boards, approved.json) -MUST be saved to `~/.gstack/projects/$SLUG/designs/`, NEVER to `.context/`, +MUST be saved to `$HOME/.bitfun/team/projects/$SLUG/designs/`, NEVER to `.context/`, `docs/designs/`, `/tmp/`, or any project-local directory. Design artifacts are USER data, not project files. They persist across branches, conversations, and workspaces. @@ -180,37 +156,37 @@ AskUserQuestion: "I've rated this plan {N}/10 on design completeness. The bigges **STOP.** Do NOT proceed until user responds. -## Step 0.5: Visual Mockups (DEFAULT when DESIGN_READY) +## Step 0.5: Visual Mockups (DEFAULT when BitFun image/design capability is available) If the plan involves any UI — screens, pages, components, visual changes — AND the -gstack designer is available (`DESIGN_READY` was printed during setup), **generate +BitFun image/design capability is available (`BitFun image/design capability is available` was printed during setup), **generate mockups immediately.** Do not ask permission. This is the default behavior. -Tell the user: "Generating visual mockups with the gstack designer. This is how we +Tell the user: "Generating visual mockups with the BitFun image/design capability. This is how we review design — real visuals, not text descriptions." The ONLY time you skip mockups is when: -- `DESIGN_NOT_AVAILABLE` was printed (designer binary not found) +- `BitFun image/design capability is unavailable` was printed (visual generation unavailable) - The plan has zero UI scope (pure backend/API/infrastructure) If the user explicitly says "skip mockups" or "text only", respect that. Otherwise, generate. **PLAN MODE EXCEPTION — ALWAYS RUN:** These commands write design artifacts to -`~/.gstack/projects/$SLUG/designs/` (user config directory, not project files). +`$HOME/.bitfun/team/projects/$SLUG/designs/` (user config directory, not project files). Mockups are design artifacts that inform the plan, not code changes. The gstack designer outputs PNGs and HTML comparison boards for human review during the planning phase. Generating mockups during planning is the whole point. Allowed commands under this exception: -- `mkdir -p ~/.gstack/projects/$SLUG/designs/...` -- `$D generate`, `$D variants`, `$D compare`, `$D iterate`, `$D evolve`, `$D check` -- `open` (fallback for viewing boards when `$B` is not available) +- `mkdir -p $HOME/.bitfun/team/projects/$SLUG/designs/...` +- `BitFun image/design capability generate`, `BitFun image/design capability variants`, `BitFun image/design capability compare`, `BitFun image/design capability iterate`, `BitFun image/design capability evolve`, `BitFun image/design capability check` +- `open` (fallback for viewing boards when `BitFun browser/computer-use` is not available) First, set up the output directory. Name it after the screen/feature being designed and today's date: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -_DESIGN_DIR=~/.gstack/projects/$SLUG/designs/-$(date +%Y%m%d) +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +_DESIGN_DIR=$HOME/.bitfun/team/projects/$SLUG/designs/-$(date +%Y%m%d) mkdir -p "$_DESIGN_DIR" echo "DESIGN_DIR: $_DESIGN_DIR" ``` @@ -225,13 +201,13 @@ The sequential constraint here is specific to plan-design-review's inline patter For each UI screen/section in scope, construct a design brief from the plan's description (and DESIGN.md if present) and generate variants: ```bash -$D variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" +BitFun image/design capability variants --brief "" --count 3 --output-dir "$_DESIGN_DIR/" ``` After generation, run a cross-model quality check on each variant: ```bash -$D check --image "$_DESIGN_DIR/variant-A.png" --brief "" +BitFun image/design capability check --image "$_DESIGN_DIR/variant-A.png" --brief "" ``` Flag any variants that fail the quality check. Offer to regenerate failures. @@ -246,7 +222,7 @@ feedback output. Showing mockups inline is a degraded experience. Create the comparison board and serve it over HTTP: ```bash -$D compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve +BitFun image/design capability compare --images "$_DESIGN_DIR/variant-A.png,$_DESIGN_DIR/variant-B.png,$_DESIGN_DIR/variant-C.png" --output "$_DESIGN_DIR/design-board.html" --serve ``` This command generates the board HTML, starts an HTTP server on a random port, @@ -308,8 +284,8 @@ the approved variant. 1. Read `regenerateAction` from the JSON (`"different"`, `"match"`, `"more_like_B"`, `"remix"`, or custom text) 2. If `regenerateAction` is `"remix"`, read `remixSpec` (e.g. `{"layout":"A","colors":"B"}`) -3. Generate new variants with `$D iterate` or `$D variants` using updated brief -4. Create new board: `$D compare --images "..." --output "$_DESIGN_DIR/design-board.html"` +3. Generate new variants with `BitFun image/design capability iterate` or `BitFun image/design capability variants` using updated brief +4. Create new board: `BitFun image/design capability compare --images "..." --output "$_DESIGN_DIR/design-board.html"` 5. Reload the board in the user's browser (same tab): `curl -s -X POST http://127.0.0.1:PORT/api/reload -H 'Content-Type: application/json' -d '{"html":"$_DESIGN_DIR/design-board.html"}'` 6. The board auto-refreshes. **AskUserQuestion again** with the same board URL to @@ -319,7 +295,7 @@ the approved variant. AskUserQuestion response instead of using the board. Use their text response as the feedback. -**POLLING FALLBACK:** Only use polling if `$D serve` fails (no port available). +**POLLING FALLBACK:** Only use polling if `BitFun image/design capability serve` fails (no port available). In that case, show each variant inline using the Read tool (so the user can see them), then use AskUserQuestion: "The comparison board server failed to start. I've shown the variants above. @@ -349,30 +325,30 @@ Note which direction was approved. This becomes the visual reference for all sub **Multiple variants/screens:** If the user asked for multiple variants (e.g., "5 versions of the homepage"), generate ALL as separate variant sets with their own comparison boards. Each screen/variant set gets its own subdirectory under `designs/`. Complete all mockup generation and user selection before starting review passes. -**If `DESIGN_NOT_AVAILABLE`:** Tell the user: "The gstack designer isn't set up yet. Run `$D setup` to enable visual mockups. Proceeding with text-only review, but you're missing the best part." Then proceed to review passes with text-based review. +**If `BitFun image/design capability is unavailable`:** Tell the user: "The BitFun image/design capability isn't set up yet. Proceeding with text-only review, but you're missing the best part." Then proceed to review passes with text-based review. ## Design Outside Voices (parallel) Use AskUserQuestion: -> "Want outside design voices before the detailed review? Codex evaluates against OpenAI's design hard rules + litmus checks; Claude subagent does an independent completeness review." +> "Want outside design voices before the detailed review? outside-voice sub-agent evaluates against OpenAI's design hard rules + litmus checks; independent subagent does an independent completeness review." > > A) Yes — run outside design voices > B) No — proceed without If user chooses B, skip this step and continue. -**Check Codex availability:** +**Check outside-voice sub-agent availability:** ```bash which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" ``` -**If Codex is available**, launch both voices simultaneously: +**If a suitable BitFun outside-voice or review sub-agent is available**, launch both voices simultaneously: -1. **Codex design voice** (via Bash): +1. **outside-voice sub-agent design voice** (via Bash): ```bash TMPERR_DESIGN=$(mktemp /tmp/codex-design-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "Read the plan file at [plan-file-path]. Evaluate this plan's UI/UX design against these criteria. +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. HARD REJECTION — flag if ANY apply: 1. Generic SaaS card grid as first impression @@ -404,7 +380,7 @@ Use a 5-minute timeout (`timeout: 300000`). After the command completes, read st cat "$TMPERR_DESIGN" && rm -f "$TMPERR_DESIGN" ``` -2. **Claude design subagent** (via Agent tool): +2. **Independent design subagent** (via BitFun Task tool): Dispatch a subagent with this prompt: "Read the plan file at [plan-file-path]. You are an independent senior product designer reviewing this plan. You have NOT seen any prior review. Evaluate: @@ -417,21 +393,21 @@ Dispatch a subagent with this prompt: For each finding: what's wrong, severity (critical/high/medium), and the fix." **Error handling (all non-blocking):** -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run `codex login` to authenticate." -- **Timeout:** "Codex timed out after 5 minutes." -- **Empty response:** "Codex returned no response." -- On any Codex error: proceed with Claude subagent output only, tagged `[single-model]`. -- If Claude subagent also fails: "Outside voices unavailable — continuing with primary review." -Present Codex output under a `CODEX SAYS (design critique):` header. -Present subagent output under a `CLAUDE SUBAGENT (design completeness):` header. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." +- **Empty response:** "outside-voice sub-agent returned no response." +- On any outside-voice sub-agent error: proceed with independent subagent output only, tagged `[single-model]`. +- If independent subagent also fails: "Outside voices unavailable — continuing with primary review." + +Present outside-voice sub-agent output under a `CODEX SAYS (design critique):` header. +Present subagent output under a `INDEPENDENT SUBAGENT (design completeness):` header. **Synthesis — Litmus scorecard:** ``` DESIGN OUTSIDE VOICES — LITMUS SCORECARD: ═══════════════════════════════════════════════════════════════ - Check Claude Codex Consensus + Check BitFun outside-voice sub-agent Consensus ─────────────────────────────────────── ─────── ─────── ───────── 1. Brand unmistakable in first screen? — — — 2. One strong visual anchor? — — — @@ -445,7 +421,7 @@ DESIGN OUTSIDE VOICES — LITMUS SCORECARD: ═══════════════════════════════════════════════════════════════ ``` -Fill in each cell from the Codex and subagent outputs. CONFIRMED = both agree. DISAGREE = models differ. NOT SPEC'D = not enough info to evaluate. +Fill in each cell from the outside-voice sub-agent and subagent outputs. CONFIRMED = both agree. DISAGREE = models differ. NOT SPEC'D = not enough info to evaluate. **Pass integration (respects existing 7-pass contract):** - Hard rejections → raised as the FIRST items in Pass 1, tagged `[HARD REJECTION]` @@ -455,7 +431,7 @@ Fill in each cell from the Codex and subagent outputs. CONFIRMED = both agree. D **Log the result:** ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"design-outside-voices","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` Replace STATUS with "clean" or "issues_found", SOURCE with "codex+subagent", "codex-only", "subagent-only", or "unavailable". @@ -473,19 +449,19 @@ Pattern: Re-run loop: invoke /plan-design-review again → re-rate → sections at 8+ get a quick pass, sections below 8 get full treatment. -### "Show me what 10/10 looks like" (requires design binary) +### "Show me what 10/10 looks like" (uses BitFun image/design capability) -If `DESIGN_READY` was printed during setup AND a dimension rates below 7/10, +If `BitFun image/design capability is available` was printed during setup AND a dimension rates below 7/10, offer to generate a visual mockup showing what the improved version would look like: ```bash -$D generate --brief "" --output /tmp/gstack-ideal-.png +BitFun image/design capability generate --brief "" --output /tmp/gstack-ideal-.png ``` Show the mockup to the user via the Read tool. This makes the gap between "what the plan describes" and "what it should look like" visceral, not abstract. -If the design binary is not available, skip this and continue with text-based +If the BitFun image/design capability is not available, skip this and continue with text-based descriptions of what 10/10 looks like. ## Review Sections (7 passes, after scope is agreed) @@ -494,41 +470,7 @@ descriptions of what 10/10 looks like. ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ### Pass 1: Information Architecture Rate 0-10: Does the plan define what the user sees first, second, third? @@ -635,7 +577,7 @@ Source: [OpenAI "Designing Delightful Frontends with GPT-5.4"](https://developer - "Hero section" → what makes this hero feel like THIS product? - "Clean, modern UI" → meaningless. Replace with actual design decisions. - "Dashboard with widgets" → what makes this NOT every other dashboard? -If visual mockups were generated in Step 0.5, evaluate them against the AI slop blacklist above. Read each mockup image using the Read tool. Does the mockup fall into generic patterns (3-column grid, centered hero, stock-photo feel)? If so, flag it and offer to regenerate with more specific direction via `$D iterate --feedback "..."`. +If visual mockups were generated in Step 0.5, evaluate them against the AI slop blacklist above. Read each mockup image using the Read tool. Does the mockup fall into generic patterns (3-column grid, centered hero, stock-photo feel)? If so, flag it and offer to regenerate with more specific direction via `BitFun image/design capability iterate --feedback "..."`. **STOP.** AskUserQuestion once per issue. Do NOT batch. Recommend + WHY. ### Pass 5: Design System Alignment @@ -667,7 +609,7 @@ If mockups were generated in Step 0.5 and review passes changed significant desi AskUserQuestion: "The review passes changed [list major design changes]. Want me to regenerate mockups to reflect the updated plan? This ensures the visual reference matches what we're actually building." -If yes, use `$D iterate` with feedback summarizing the changes, or `$D variants` with an updated brief. Save to the same `$_DESIGN_DIR` directory. +If yes, use `BitFun image/design capability iterate` with feedback summarizing the changes, or `BitFun image/design capability variants` with an updated brief. Save to the same `$_DESIGN_DIR` directory. ## CRITICAL RULE — How to ask questions Follow the AskUserQuestion format from the Preamble above. Additional rules for plan design reviews: @@ -677,7 +619,7 @@ Follow the AskUserQuestion format from the Preamble above. Additional rules for * **Map to Design Principles above.** One sentence connecting your recommendation to a specific principle. * Label with issue NUMBER + option LETTER (e.g., "3A", "3B"). * **Escape hatch:** If a section has no issues, say so and move on. If a gap has an obvious fix, state what you'll add and move on — don't waste a question on it. Only use AskUserQuestion when there is a genuine design choice with meaningful tradeoffs. -* **NEVER use AskUserQuestion to ask which variant the user prefers.** Always create a comparison board first (`$D compare --serve`) and open it in the browser. The board has rating controls, comments, remix/regenerate buttons, and structured feedback output. Use AskUserQuestion ONLY to notify the user the board is open and wait for them to finish — not to present variants inline and ask "which do you prefer?" That is a degraded experience. +* **NEVER use AskUserQuestion to ask which variant the user prefers.** Always create a comparison board first (`BitFun image/design capability compare --serve`) and open it in the browser. The board has rating controls, comments, remix/regenerate buttons, and structured feedback output. Use AskUserQuestion ONLY to notify the user the board is open and wait for them to finish — not to present variants inline and ask "which do you prefer?" That is a degraded experience. ## Required Outputs @@ -740,7 +682,7 @@ If visual mockups were generated during this review, add to the plan file: | Screen/Section | Mockup Path | Direction | Notes | |----------------|-------------|-----------|-------| -| [screen name] | ~/.gstack/projects/$SLUG/designs/[folder]/[filename].png | [brief description] | [constraints from review] | +| [screen name] | $HOME/.bitfun/team/projects/$SLUG/designs/[folder]/[filename].png | [brief description] | [constraints from review] | ``` Include the full path to each approved mockup (the variant the user chose), a one-line description of the direction, and any constraints. The implementer reads this to know exactly which visual to build from. These persist across conversations and workspaces. If no mockups were generated, omit this section. @@ -750,13 +692,13 @@ Include the full path to each approved mockup (the variant the user chose), a on After producing the Completion Summary above, persist the review result. **PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes review metadata to -`~/.gstack/` (user config directory, not project files). The skill preamble -already writes to `~/.gstack/sessions/` and `~/.gstack/analytics/` — this is +`$HOME/.bitfun/team/` (user config directory, not project files). The skill preamble +already writes to `$HOME/.bitfun/team/sessions/` and `$HOME/.bitfun/team/analytics/` — this is the same pattern. The review dashboard depends on this data. Skipping this command breaks the review readiness dashboard in /ship. ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-design-review","timestamp":"TIMESTAMP","status":"STATUS","initial_score":N,"overall_score":N,"unresolved":N,"decisions_made":N,"commit":"COMMIT"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute values from the Completion Summary: @@ -773,10 +715,10 @@ Substitute values from the Completion Summary: After completing the review, read the review log and config to display the dashboard. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` -Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, codex-review, codex-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `codex-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `codex-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. +Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, outside-voice-review, outside-voice-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `outside-voice-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `outside-voice-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. **Source attribution:** If the most recent entry for a skill has a \`"via"\` field, append it to the status label in parentheses. Examples: `plan-eng-review` with `via:"autoplan"` shows as "CLEAR (PLAN via /autoplan)". `review` with `via:"ship"` shows as "CLEAR (DIFF via /ship)". Entries without a `via` field show as "CLEAR (PLAN)" or "CLEAR (DIFF)" as before. @@ -801,16 +743,16 @@ Display: ``` **Review tiers:** -- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`gstack-config set skip_eng_review true\` (the "don't bother me" setting). +- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`Team Mode setting skip_eng_review=true\` (the "don't bother me" setting). - **CEO Review (optional):** Use your judgment. Recommend it for big product/business changes, new user-facing features, or scope decisions. Skip for bug fixes, refactors, infra, and cleanup. - **Design Review (optional):** Use your judgment. Recommend it for UI/UX changes. Skip for backend-only, infra, or prompt-only changes. -- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both Claude adversarial subagent and Codex adversarial challenge. Large diffs (200+ lines) additionally get Codex structured review with P1 gate. No configuration needed. -- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to Claude subagent if Codex is unavailable. Never gates shipping. +- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both BitFun adversarial subagent and outside-voice sub-agent adversarial challenge. Large diffs (200+ lines) additionally get outside-voice sub-agent structured review with P1 gate. No configuration needed. +- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to independent subagent if outside-voice sub-agent is unavailable. Never gates shipping. **Verdict logic:** - **CLEARED**: Eng Review has >= 1 entry within 7 days from either \`review\` or \`plan-eng-review\` with status "clean" (or \`skip_eng_review\` is \`true\`) - **NOT CLEARED**: Eng Review missing, stale (>7 days), or has open issues -- CEO, Design, and Codex reviews are shown for context but never block shipping +- CEO, Design, and outside-voice sub-agent reviews are shown for context but never block shipping - If \`skip_eng_review\` config is \`true\`, Eng Review shows "SKIPPED (global)" and verdict is CLEARED **Staleness detection:** After displaying the dashboard, check if any existing reviews may be stale: @@ -846,7 +788,7 @@ Parse each JSONL entry. Each skill logs different fields: → Findings: "score: {initial_score}/10 → {overall_score}/10, TTHW: {tthw_current} → {tthw_target}" - **devex-review**: \`status\`, \`overall_score\`, \`product_type\`, \`tthw_measured\`, \`dimensions_tested\`, \`dimensions_inferred\`, \`boomerang\`, \`commit\` → Findings: "score: {overall_score}/10, TTHW: {tthw_measured}, {dimensions_tested} tested/{dimensions_inferred} inferred" -- **codex-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` +- **outside-voice-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` → Findings: "{findings} findings, {findings_fixed}/{findings} fixed" All fields needed for the Findings column are now present in the JSONL entries. @@ -861,7 +803,7 @@ Produce this markdown table: | Review | Trigger | Why | Runs | Status | Findings | |--------|---------|-----|------|--------|----------| | CEO Review | \`/plan-ceo-review\` | Scope & strategy | {runs} | {status} | {findings} | -| Codex Review | \`/codex review\` | Independent 2nd opinion | {runs} | {status} | {findings} | +| outside-voice sub-agent Review | \`BitFun Task outside-voice review\` | Independent 2nd opinion | {runs} | {status} | {findings} | | Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | {runs} | {status} | {findings} | | Design Review | \`/plan-design-review\` | UI/UX gaps | {runs} | {status} | {findings} | | DX Review | \`/plan-devex-review\` | Developer experience gaps | {runs} | {status} | {findings} | @@ -869,8 +811,8 @@ Produce this markdown table: Below the table, add these lines (omit any that are empty/not applicable): -- **CODEX:** (only if codex-review ran) — one-line summary of codex fixes -- **CROSS-MODEL:** (only if both Claude and Codex reviews exist) — overlap analysis +- **CODEX:** (only if outside-voice-review ran) — one-line summary of codex fixes +- **CROSS-MODEL:** (only if both BitFun and outside-voice sub-agent reviews exist) — overlap analysis - **UNRESOLVED:** total unresolved decisions across all reviews - **VERDICT:** list reviews that are CLEAR (e.g., "CEO + ENG CLEARED — ready to implement"). If Eng Review is not CLEAR and not skipped globally, append "eng review required". @@ -897,7 +839,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"plan-design-review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -905,7 +847,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md b/src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md index 5bb33c078..89e1540da 100644 --- a/src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-plan-eng-review/SKILL.md @@ -14,6 +14,16 @@ description: | Review this plan thoroughly before making any code changes. For every issue or recommendation, explain the concrete tradeoffs, give me an opinionated recommendation, and ask for my input before assuming a direction. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the engineering-manager review lens. Use existing Task sub-agents for independent architecture and evidence gathering, then synthesize decisions in the main Team session. + +- Do not assume an Eng Manager sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom architecture/backend/frontend/test sub-agent if available; otherwise use `Explore` for architecture mapping and `FileFinder` for locating touched modules, plans, configs, and tests. +- Keep Task work read-only before Build. Ask for data flows, edge cases, platform-boundary risks, test gaps, migration risks, and verification commands. +- In parallel plan-review batches, return a compact Eng brief: `architecture blockers`, `edge cases`, `test matrix`, `files likely touched`, `recommended implementation sequence`. +- The main Team orchestrator owns final plan edits, user questions, and build approval. + ## Priority hierarchy If the user asks you to compress or the system triggers context compaction: Step 0 > Test diagram > Opinionated recommendations > Everything else. Never skip Step 0 or the test diagram. Do not preemptively warn about context limits -- the system handles compaction automatically. @@ -57,10 +67,10 @@ When evaluating architecture, think "boring by default." When reviewing tests, t ### Design Doc Check ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._- 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) +[ -z "$DESIGN" ] && DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` If a design doc exists, read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design — check the prior version for context on what changed and why. @@ -89,7 +99,7 @@ If they choose A: Say: "Running /office-hours inline. Once the design doc is ready, I'll pick up the review right where we left off." -Read the `/office-hours` skill file at `~/.claude/skills/gstack/office-hours/SKILL.md` using the Read tool. +Read the `/office-hours` skill file at `the bundled office-hours skill via the Skill tool` using the Read tool. **If unreadable:** Skip with "Could not load /office-hours — skipping." and continue. @@ -112,10 +122,10 @@ Execute every other section at full depth. When the loaded skill's instructions After /office-hours completes, re-run the design doc check: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._- 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) +[ -z "$DESIGN" ] && DESIGN=$(ls -t $HOME/.bitfun/team/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` @@ -157,41 +167,7 @@ Always work through the full interactive review: one section at a time (Architec ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ### 1. Architecture review Evaluate: @@ -250,8 +226,8 @@ Evaluate: Before analyzing coverage, detect the project's test framework: -1. **Read CLAUDE.md** — look for a `## Testing` section with test command and framework name. If found, use that as the authoritative source. -2. **If CLAUDE.md has no testing section, auto-detect:** +1. **Read AGENTS.md** — look for a `## Testing` section with test command and framework name. If found, use that as the authoritative source. +2. **If AGENTS.md has no testing section, auto-detect:** ```bash setopt +o nomatch 2>/dev/null || true # zsh compat @@ -414,12 +390,12 @@ The plan should be complete enough that when implementation begins, every test i After producing the coverage diagram, write a test plan artifact to the project directory so `/qa` and `/qa-only` can consume it as primary test input: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG USER=$(whoami) DATETIME=$(date +%Y%m%d-%H%M%S) ``` -Write to `~/.gstack/projects/{slug}/{user}-{branch}-eng-review-test-plan-{datetime}.md`: +Write to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-eng-review-test-plan-{datetime}.md`: ```markdown # Test Plan @@ -442,7 +418,7 @@ Repo: {owner/repo} This file is consumed by `/qa` and `/qa-only` as primary test input. Include only the information that helps a QA tester know **what to test and where** — not implementation details. -For LLM/prompt changes: check the "Prompt/LLM changes" file patterns listed in CLAUDE.md. If this plan touches ANY of those patterns, state which eval suites must be run, which cases should be added, and what baselines to compare against. Then use AskUserQuestion to confirm the eval scope with the user. +For LLM/prompt changes: check the "Prompt/LLM changes" file patterns listed in AGENTS.md. If this plan touches ANY of those patterns, state which eval suites must be run, which cases should be added, and what baselines to compare against. Then use AskUserQuestion to confirm the eval scope with the user. **STOP.** For each issue found in this section, call AskUserQuestion individually. One issue per call. Present options, state your recommendation, explain WHY. Do NOT batch multiple issues into one AskUserQuestion. Only proceed to the next section after ALL issues in this section are resolved. @@ -492,7 +468,7 @@ Construct this prompt (substitute the actual plan content — if plan content ex truncate to the first 30KB and note "Plan truncated for size"). **Always start with the filesystem boundary instruction:** -"IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has +"IMPORTANT: Do NOT read or execute any skill definition directories These are BitFun skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nYou are a brutally honest technical reviewer examining a development plan that has already been through a multi-section review. Your job is NOT to repeat that review. Instead, find what it missed. Look for: logical gaps and unstated assumptions that survived the review scrutiny, overcomplexity (is there a fundamentally simpler @@ -509,7 +485,7 @@ THE PLAN: ```bash TMPERR_PV=$(mktemp /tmp/codex-planreview-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "" -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_PV" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Use a 5-minute timeout (`timeout: 300000`). After the command completes, read stderr: @@ -527,19 +503,19 @@ CODEX SAYS (plan review — outside voice): ``` **Error handling:** All errors are non-blocking — the outside voice is informational. -- Auth failure (stderr contains "auth", "login", "unauthorized"): "Codex auth failed. Run \`codex login\` to authenticate." -- Timeout: "Codex timed out after 5 minutes." -- Empty response: "Codex returned no response." +- Outside-voice unavailable: if the selected BitFun sub-agent cannot run, skip this informational pass and continue with the main-session review. +- Timeout: "outside-voice sub-agent timed out after 5 minutes." +- Empty response: "outside-voice sub-agent returned no response." -On any Codex error, fall back to the Claude adversarial subagent. +On any outside-voice sub-agent error, fall back to the BitFun adversarial subagent. -**If CODEX_NOT_AVAILABLE (or Codex errored):** +**If CODEX_NOT_AVAILABLE (or outside-voice sub-agent errored):** -Dispatch via the Agent tool. The subagent has fresh context — genuine independence. +Dispatch via the Task tool. The subagent has fresh context — genuine independence. Subagent prompt: same plan review prompt as above. -Present findings under an `OUTSIDE VOICE (Claude subagent):` header. +Present findings under an `OUTSIDE VOICE (independent subagent):` header. If the subagent fails or times out: "Outside voice unavailable. Continuing to outputs." @@ -581,13 +557,13 @@ If no tension points exist, note: "No cross-model tension — both reviewers agr **Persist the result:** ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"codex-plan-review","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute: STATUS = "clean" if no findings, "issues_found" if findings exist. -SOURCE = "codex" if Codex ran, "claude" if subagent ran. +SOURCE = "codex" if outside-voice sub-agent ran, "subagent" if a BitFun Task sub-agent ran. -**Cleanup:** Run `rm -f "$TMPERR_PV"` after processing (if Codex was used). +**Cleanup:** Run `rm -f "$TMPERR_PV"` after processing (if outside-voice sub-agent was used). --- @@ -618,7 +594,7 @@ Every plan review MUST produce a "NOT in scope" section listing work that was co List existing code/flows that already partially solve sub-problems in this plan, and whether the plan reuses them or unnecessarily rebuilds them. ### TODOS.md updates -After all review sections are complete, present each potential TODO as its own individual AskUserQuestion. Never batch TODOs — one per question. Never silently skip this step. Follow the format in `.claude/skills/review/TODOS-format.md`. +After all review sections are complete, present each potential TODO as its own individual AskUserQuestion. Never batch TODOs — one per question. Never silently skip this step. Follow the format in `the built-in review TODO format`. For each TODO, describe: * **What:** One-line description of the work. @@ -645,7 +621,7 @@ If any failure mode has no test AND no error handling AND would be silent, flag ### Worktree parallelization strategy -Analyze the plan's implementation steps for parallel execution opportunities. This helps the user split work across git worktrees (via Claude Code's Agent tool with `isolation: "worktree"` or parallel workspaces). +Analyze the plan's implementation steps for parallel execution opportunities. This helps the user split work across BitFun Task sub-agents, git worktrees, or separate workspaces when the workstreams are genuinely independent. **Skip if:** all steps touch the same primary module, or the plan has fewer than 2 independent workstreams. In that case, write: "Sequential implementation, no parallelization opportunity." @@ -681,7 +657,7 @@ At the end of the review, fill in and display this summary so the user can see a - What already exists: written - TODOS.md updates: ___ items proposed to user - Failure modes: ___ critical gaps flagged -- Outside voice: ran (codex/claude) / skipped +- Outside voice: ran (codex/subagent) / skipped - Parallelization: ___ lanes, ___ parallel / ___ sequential - Lake Score: X/Y recommendations chose complete option @@ -699,13 +675,13 @@ Check the git log for this branch. If there are prior commits suggesting a previ After producing the Completion Summary above, persist the review result. **PLAN MODE EXCEPTION — ALWAYS RUN:** This command writes review metadata to -`~/.gstack/` (user config directory, not project files). The skill preamble -already writes to `~/.gstack/sessions/` and `~/.gstack/analytics/` — this is +`$HOME/.bitfun/team/` (user config directory, not project files). The skill preamble +already writes to `$HOME/.bitfun/team/sessions/` and `$HOME/.bitfun/team/analytics/` — this is the same pattern. The review dashboard depends on this data. Skipping this command breaks the review readiness dashboard in /ship. ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"plan-eng-review","timestamp":"TIMESTAMP","status":"STATUS","unresolved":N,"critical_gaps":N,"issues_found":N,"mode":"MODE","commit":"COMMIT"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute values from the Completion Summary: @@ -722,10 +698,10 @@ Substitute values from the Completion Summary: After completing the review, read the review log and config to display the dashboard. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` -Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, codex-review, codex-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `codex-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `codex-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. +Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, outside-voice-review, outside-voice-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `outside-voice-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `outside-voice-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. **Source attribution:** If the most recent entry for a skill has a \`"via"\` field, append it to the status label in parentheses. Examples: `plan-eng-review` with `via:"autoplan"` shows as "CLEAR (PLAN via /autoplan)". `review` with `via:"ship"` shows as "CLEAR (DIFF via /ship)". Entries without a `via` field show as "CLEAR (PLAN)" or "CLEAR (DIFF)" as before. @@ -750,16 +726,16 @@ Display: ``` **Review tiers:** -- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`gstack-config set skip_eng_review true\` (the "don't bother me" setting). +- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`Team Mode setting skip_eng_review=true\` (the "don't bother me" setting). - **CEO Review (optional):** Use your judgment. Recommend it for big product/business changes, new user-facing features, or scope decisions. Skip for bug fixes, refactors, infra, and cleanup. - **Design Review (optional):** Use your judgment. Recommend it for UI/UX changes. Skip for backend-only, infra, or prompt-only changes. -- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both Claude adversarial subagent and Codex adversarial challenge. Large diffs (200+ lines) additionally get Codex structured review with P1 gate. No configuration needed. -- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to Claude subagent if Codex is unavailable. Never gates shipping. +- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both BitFun adversarial subagent and outside-voice sub-agent adversarial challenge. Large diffs (200+ lines) additionally get outside-voice sub-agent structured review with P1 gate. No configuration needed. +- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to independent subagent if outside-voice sub-agent is unavailable. Never gates shipping. **Verdict logic:** - **CLEARED**: Eng Review has >= 1 entry within 7 days from either \`review\` or \`plan-eng-review\` with status "clean" (or \`skip_eng_review\` is \`true\`) - **NOT CLEARED**: Eng Review missing, stale (>7 days), or has open issues -- CEO, Design, and Codex reviews are shown for context but never block shipping +- CEO, Design, and outside-voice sub-agent reviews are shown for context but never block shipping - If \`skip_eng_review\` config is \`true\`, Eng Review shows "SKIPPED (global)" and verdict is CLEARED **Staleness detection:** After displaying the dashboard, check if any existing reviews may be stale: @@ -795,7 +771,7 @@ Parse each JSONL entry. Each skill logs different fields: → Findings: "score: {initial_score}/10 → {overall_score}/10, TTHW: {tthw_current} → {tthw_target}" - **devex-review**: \`status\`, \`overall_score\`, \`product_type\`, \`tthw_measured\`, \`dimensions_tested\`, \`dimensions_inferred\`, \`boomerang\`, \`commit\` → Findings: "score: {overall_score}/10, TTHW: {tthw_measured}, {dimensions_tested} tested/{dimensions_inferred} inferred" -- **codex-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` +- **outside-voice-review**: \`status\`, \`gate\`, \`findings\`, \`findings_fixed\` → Findings: "{findings} findings, {findings_fixed}/{findings} fixed" All fields needed for the Findings column are now present in the JSONL entries. @@ -810,7 +786,7 @@ Produce this markdown table: | Review | Trigger | Why | Runs | Status | Findings | |--------|---------|-----|------|--------|----------| | CEO Review | \`/plan-ceo-review\` | Scope & strategy | {runs} | {status} | {findings} | -| Codex Review | \`/codex review\` | Independent 2nd opinion | {runs} | {status} | {findings} | +| outside-voice sub-agent Review | \`BitFun Task outside-voice review\` | Independent 2nd opinion | {runs} | {status} | {findings} | | Eng Review | \`/plan-eng-review\` | Architecture & tests (required) | {runs} | {status} | {findings} | | Design Review | \`/plan-design-review\` | UI/UX gaps | {runs} | {status} | {findings} | | DX Review | \`/plan-devex-review\` | Developer experience gaps | {runs} | {status} | {findings} | @@ -818,8 +794,8 @@ Produce this markdown table: Below the table, add these lines (omit any that are empty/not applicable): -- **CODEX:** (only if codex-review ran) — one-line summary of codex fixes -- **CROSS-MODEL:** (only if both Claude and Codex reviews exist) — overlap analysis +- **CODEX:** (only if outside-voice-review ran) — one-line summary of codex fixes +- **CROSS-MODEL:** (only if both BitFun and outside-voice sub-agent reviews exist) — overlap analysis - **UNRESOLVED:** total unresolved decisions across all reviews - **VERDICT:** list reviews that are CLEAR (e.g., "CEO + ENG CLEARED — ready to implement"). If Eng Review is not CLEAR and not skipped globally, append "eng review required". @@ -846,7 +822,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"plan-eng-review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -854,7 +830,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-qa-only/SKILL.md b/src/crates/core/builtin_skills/gstack-qa-only/SKILL.md index 99c72780d..07dfd9216 100644 --- a/src/crates/core/builtin_skills/gstack-qa-only/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-qa-only/SKILL.md @@ -13,6 +13,16 @@ description: | You are a QA engineer. Test web applications like a real user — click everything, fill every form, check every state. Produce a structured report with evidence. **NEVER fix anything.** +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the report-only QA methodology. Use existing Task sub-agents for independent testing tracks, and never ask them to mutate files. + +- Do not assume a QA Reporter sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom QA/browser sub-agent if available; otherwise use `ComputerUse` for browser/desktop testing when available, and `Explore` for diff-aware test-scope mapping. +- Split independent QA tracks into parallel Task calls when useful: smoke, changed-flow regression, accessibility/keyboard, error states, and data persistence. +- Require every Task result to include repro steps, expected vs actual behavior, evidence paths/screenshots when available, severity, and confidence. +- The main Team orchestrator consolidates duplicates and decides what blocks Ship. + ## Setup **Parse the user's request for these parameters:** @@ -20,55 +30,19 @@ You are a QA engineer. Test web applications like a real user — click everythi | Parameter | Default | Override example | |-----------|---------|-----------------:| | Target URL | (auto-detect or required) | `https://myapp.com`, `http://localhost:3000` | -| Mode | full | `--quick`, `--regression .gstack/qa-reports/baseline.json` | -| Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | +| Mode | full | `--quick`, `--regression .bitfun/team/qa-reports/baseline.json` | +| Output dir | `.bitfun/team/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | **If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works. -**Find the browse binary:** - -## SETUP (run this check BEFORE any browse command) - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "READY: $B" -else - echo "NEEDS_SETUP" -fi -``` - -If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. -2. Run: `cd && ./setup` -3. If `bun` is not installed: - ```bash - if ! command -v bun >/dev/null 2>&1; then - BUN_VERSION="1.3.10" - BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" - tmpfile=$(mktemp) - curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') - if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then - echo "ERROR: bun install script checksum mismatch" >&2 - echo " expected: $BUN_INSTALL_SHA" >&2 - echo " got: $actual_sha" >&2 - rm "$tmpfile"; exit 1 - fi - BUN_VERSION="$BUN_VERSION" bash "$tmpfile" - rm "$tmpfile" - fi - ``` +**Browser/desktop QA tooling:** Use BitFun built-in browser/computer-use capability. Do not install, build, or call any external browse binary. Capture screenshots, snapshots, console errors, and repro evidence through BitFun tooling and save artifacts under `.bitfun/team/qa-reports/`. **Create output directories:** ```bash -REPORT_DIR=".gstack/qa-reports" +REPORT_DIR=".bitfun/team/qa-reports" mkdir -p "$REPORT_DIR/screenshots" ``` @@ -76,51 +50,17 @@ mkdir -p "$REPORT_DIR/screenshots" ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Test Plan Context Before falling back to git diff heuristics, check for richer test plan sources: -1. **Project-scoped test plans:** Check `~/.gstack/projects/` for recent `*-test-plan-*.md` files for this repo +1. **Project-scoped test plans:** Check `$HOME/.bitfun/team/projects/` for recent `*-test-plan-*.md` files for this repo ```bash setopt +o nomatch 2>/dev/null || true # zsh compat - eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" - ls -t ~/.gstack/projects/$SLUG/*-test-plan-*.md 2>/dev/null | head -1 + SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) + ls -t $HOME/.bitfun/team/projects/$SLUG/*-test-plan-*.md 2>/dev/null | head -1 ``` 2. **Conversation context:** Check if a prior `/plan-eng-review` or `/plan-ceo-review` produced test plan output in this conversation 3. **Use whichever source is richer.** Fall back to git diff analysis only if neither is available. @@ -144,16 +84,16 @@ This is the **primary mode** for developers verifying their work. When the user - View/template/component files → which pages render them - Model/service files → which pages use those models (check controllers that reference them) - CSS/style files → which pages include those stylesheets - - API endpoints → test them directly with `$B js "await fetch('/api/...')"` + - API endpoints → test them directly with `BitFun browser/computer-use js "await fetch('/api/...')"` - Static pages (markdown, HTML) → navigate to them directly **If no obvious pages/routes are identified from the diff:** Do not skip browser testing. The user invoked /qa because they want browser-based verification. Fall back to Quick mode — navigate to the homepage, follow the top 5 navigation targets, check console for errors, and test any interactive elements found. Backend, config, and infrastructure changes affect app behavior — always verify the app still works. 3. **Detect the running app** — check common local dev ports: ```bash - $B goto http://localhost:3000 2>/dev/null && echo "Found app on :3000" || \ - $B goto http://localhost:4000 2>/dev/null && echo "Found app on :4000" || \ - $B goto http://localhost:8080 2>/dev/null && echo "Found app on :8080" + BitFun browser/computer-use goto http://localhost:3000 2>/dev/null && echo "Found app on :3000" || \ + BitFun browser/computer-use goto http://localhost:4000 2>/dev/null && echo "Found app on :4000" || \ + BitFun browser/computer-use goto http://localhost:8080 2>/dev/null && echo "Found app on :8080" ``` If no local app is found, check for a staging/preview URL in the PR or environment. If nothing works, ask the user for the URL. @@ -190,7 +130,7 @@ Run full mode, then load `baseline.json` from a previous run. Diff: which issues ### Phase 1: Initialize -1. Find browse binary (see Setup above) +1. Find BitFun browser/computer-use tooling (see Setup above) 2. Create output directories 3. Copy report template from `qa/templates/qa-report-template.md` to output dir 4. Start timer for duration tracking @@ -200,19 +140,19 @@ Run full mode, then load `baseline.json` from a previous run. Diff: which issues **If the user specified auth credentials:** ```bash -$B goto -$B snapshot -i # find the login form -$B fill @e3 "user@example.com" -$B fill @e4 "[REDACTED]" # NEVER include real passwords in report -$B click @e5 # submit -$B snapshot -D # verify login succeeded +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i # find the login form +BitFun browser/computer-use fill @e3 "user@example.com" +BitFun browser/computer-use fill @e4 "[REDACTED]" # NEVER include real passwords in report +BitFun browser/computer-use click @e5 # submit +BitFun browser/computer-use snapshot -D # verify login succeeded ``` **If the user provided a cookie file:** ```bash -$B cookie-import cookies.json -$B goto +BitFun browser/computer-use cookie-import cookies.json +BitFun browser/computer-use goto ``` **If 2FA/OTP is required:** Ask the user for the code and wait. @@ -224,10 +164,10 @@ $B goto Get a map of the application: ```bash -$B goto -$B snapshot -i -a -o "$REPORT_DIR/screenshots/initial.png" -$B links # map navigation structure -$B console --errors # any errors on landing? +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/initial.png" +BitFun browser/computer-use links # map navigation structure +BitFun browser/computer-use console --errors # any errors on landing? ``` **Detect framework** (note in report metadata): @@ -243,9 +183,9 @@ $B console --errors # any errors on landing? Visit pages systematically. At each page: ```bash -$B goto -$B snapshot -i -a -o "$REPORT_DIR/screenshots/page-name.png" -$B console --errors +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/page-name.png" +BitFun browser/computer-use console --errors ``` Then follow the **per-page exploration checklist** (see `qa/references/issue-taxonomy.md`): @@ -258,9 +198,9 @@ Then follow the **per-page exploration checklist** (see `qa/references/issue-tax 6. **Console** — Any new JS errors after interactions? 7. **Responsiveness** — Check mobile viewport if relevant: ```bash - $B viewport 375x812 - $B screenshot "$REPORT_DIR/screenshots/page-mobile.png" - $B viewport 1280x720 + BitFun browser/computer-use viewport 375x812 + BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/page-mobile.png" + BitFun browser/computer-use viewport 1280x720 ``` **Depth judgment:** Spend more time on core features (homepage, dashboard, checkout, search) and less on secondary pages (about, terms, privacy). @@ -281,10 +221,10 @@ Document each issue **immediately when found** — don't batch them. 5. Write repro steps referencing screenshots ```bash -$B screenshot "$REPORT_DIR/screenshots/issue-001-step-1.png" -$B click @e5 -$B screenshot "$REPORT_DIR/screenshots/issue-001-result.png" -$B snapshot -D +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/issue-001-step-1.png" +BitFun browser/computer-use click @e5 +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/issue-001-result.png" +BitFun browser/computer-use snapshot -D ``` **Static bugs** (typos, layout issues, missing images): @@ -292,7 +232,7 @@ $B snapshot -D 2. Describe what's wrong ```bash -$B snapshot -i -a -o "$REPORT_DIR/screenshots/issue-002.png" +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/issue-002.png" ``` **Write each issue to the report immediately** using the template format from `qa/templates/qa-report-template.md`. @@ -402,7 +342,7 @@ Minimum 0 per category. 8. **Depth over breadth.** 5-10 well-documented issues with evidence > 20 vague descriptions. 9. **Never delete output files.** Screenshots and reports accumulate — that's intentional. 10. **Use `snapshot -C` for tricky UIs.** Finds clickable divs that the accessibility tree misses. -11. **Show screenshots to the user.** After every `$B screenshot`, `$B snapshot -a -o`, or `$B responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. +11. **Show screenshots to the user.** After every `BitFun browser/computer-use screenshot`, `BitFun browser/computer-use snapshot -a -o`, or `BitFun browser/computer-use responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. 12. **Never refuse to use the browser.** When the user invokes /qa or /qa-only, they are requesting browser-based testing. Never suggest evals, unit tests, or other alternatives as a substitute. Even if the diff appears to have no UI changes, backend changes affect app behavior — always open the browser and test. --- @@ -411,18 +351,18 @@ Minimum 0 per category. Write the report to both local and project-scoped locations: -**Local:** `.gstack/qa-reports/qa-report-{domain}-{YYYY-MM-DD}.md` +**Local:** `.bitfun/team/qa-reports/qa-report-{domain}-{YYYY-MM-DD}.md` **Project-scoped:** Write test outcome artifact for cross-session context: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG ``` -Write to `~/.gstack/projects/{slug}/{user}-{branch}-test-outcome-{datetime}.md` +Write to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-test-outcome-{datetime}.md` ### Output Structure ``` -.gstack/qa-reports/ +.bitfun/team/qa-reports/ ├── qa-report-{domain}-{YYYY-MM-DD}.md # Structured report ├── screenshots/ │ ├── initial.png # Landing page annotated screenshot @@ -442,7 +382,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"qa-only","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -450,7 +390,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-qa/SKILL.md b/src/crates/core/builtin_skills/gstack-qa/SKILL.md index f550f69c6..54eeccd0b 100644 --- a/src/crates/core/builtin_skills/gstack-qa/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-qa/SKILL.md @@ -16,6 +16,16 @@ description: | You are a QA engineer AND a bug-fix engineer. Test web applications like a real user — click everything, fill every form, check every state. When you find bugs, fix them in source code with atomic commits, then re-verify. Produce a structured report with before/after evidence. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the QA methodology. Use existing Task sub-agents for independent testing tracks, then keep triage and fix ownership explicit in the main Team session. + +- Do not assume a QA Lead sub-agent exists. Choose only from the Task tool's available agents. +- Prefer a matching custom QA/browser sub-agent if available; otherwise use `ComputerUse` for browser/desktop testing when available, and `Explore` for diff-aware test-scope mapping. +- Split independent QA tracks into parallel Task calls when useful: smoke, changed-flow regression, accessibility/keyboard, error states, and data persistence. +- Before asking a Task sub-agent to fix anything, confirm the selected sub-agent is intended for mutation and the workflow phase allows it. Otherwise request report-only output. +- The main Team orchestrator owns bug prioritization, regression-test decisions, fixes, and re-review triggers. + ## Setup **Parse the user's request for these parameters:** @@ -24,8 +34,8 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real |-----------|---------|-----------------:| | Target URL | (auto-detect or required) | `https://myapp.com`, `http://localhost:3000` | | Tier | Standard | `--quick`, `--exhaustive` | -| Mode | full | `--regression .gstack/qa-reports/baseline.json` | -| Output dir | `.gstack/qa-reports/` | `Output to /tmp/qa` | +| Mode | full | `--regression .bitfun/team/qa-reports/baseline.json` | +| Output dir | `.bitfun/team/qa-reports/` | `Output to /tmp/qa` | | Scope | Full app (or diff-scoped) | `Focus on the billing page` | | Auth | None | `Sign in to user@example.com`, `Import cookies from cookies.json` | @@ -36,10 +46,7 @@ You are a QA engineer AND a bug-fix engineer. Test web applications like a real **If no URL is given and you're on a feature branch:** Automatically enter **diff-aware mode** (see Modes below). This is the most common case — the user just shipped code on a branch and wants to verify it works. -**CDP mode detection:** Before starting, check if the browse server is connected to the user's real browser: -```bash -$B status 2>/dev/null | grep -q "Mode: cdp" && echo "CDP_MODE=true" || echo "CDP_MODE=false" -``` +**Browser session detection:** Use BitFun browser/computer-use state to detect whether an existing user browser session is available. If `CDP_MODE=true`: skip cookie import prompts (the real browser already has cookies), skip user-agent overrides (real browser has real user-agent), and skip headless detection workarounds. The user's real auth sessions are already available. **Check for clean working tree:** @@ -60,43 +67,7 @@ RECOMMENDATION: Choose A because uncommitted work should be preserved as a commi After the user chooses, execute their choice (commit or stash), then continue with setup. -**Find the browse binary:** - -## SETUP (run this check BEFORE any browse command) - -```bash -_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) -B="" -[ -n "$_ROOT" ] && [ -x "$_ROOT/.claude/skills/gstack/browse/dist/browse" ] && B="$_ROOT/.claude/skills/gstack/browse/dist/browse" -[ -z "$B" ] && B=~/.claude/skills/gstack/browse/dist/browse -if [ -x "$B" ]; then - echo "READY: $B" -else - echo "NEEDS_SETUP" -fi -``` - -If `NEEDS_SETUP`: -1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait. -2. Run: `cd && ./setup` -3. If `bun` is not installed: - ```bash - if ! command -v bun >/dev/null 2>&1; then - BUN_VERSION="1.3.10" - BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" - tmpfile=$(mktemp) - curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') - if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then - echo "ERROR: bun install script checksum mismatch" >&2 - echo " expected: $BUN_INSTALL_SHA" >&2 - echo " got: $actual_sha" >&2 - rm "$tmpfile"; exit 1 - fi - BUN_VERSION="$BUN_VERSION" bash "$tmpfile" - rm "$tmpfile" - fi - ``` +**Browser/desktop QA tooling:** Use BitFun built-in browser/computer-use capability. Do not install, build, or call any external browse binary. Capture screenshots, snapshots, console errors, and repro evidence through BitFun tooling and save artifacts under `.bitfun/team/qa-reports/`. **Check test framework (bootstrap if needed):** @@ -121,7 +92,7 @@ setopt +o nomatch 2>/dev/null || true # zsh compat ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null # Check opt-out marker -[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" +[ -f .bitfun/team/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" ``` **If test framework detected** (config files or test directories found): @@ -134,7 +105,7 @@ Store conventions as prose context for use in Phase 8e.5 or Step 3.4. **Skip the **If NO runtime detected** (no config files found): Use AskUserQuestion: "I couldn't detect your project's language. What runtime are you using?" Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests. -If user picks H → write `.gstack/no-test-bootstrap` and continue without tests. +If user picks H → write `.bitfun/team/no-test-bootstrap` and continue without tests. **If runtime detected but no test framework — bootstrap:** @@ -166,7 +137,7 @@ B) [Alternative] — [rationale]. Includes: [packages] C) Skip — don't set up testing right now RECOMMENDATION: Choose A because [reason based on project context]" -If user picks C → write `.gstack/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.gstack/no-test-bootstrap` and re-run." Continue without tests. +If user picks C → write `.bitfun/team/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.bitfun/team/no-test-bootstrap` and re-run." Continue without tests. If multiple runtimes detected (monorepo) → ask which runtime to set up first, with option to do both sequentially. @@ -228,9 +199,9 @@ Write TESTING.md with: - Test layers: Unit tests (what, where, when), Integration tests, Smoke tests, E2E tests - Conventions: file naming, assertion style, setup/teardown patterns -### B7. Update CLAUDE.md +### B7. Update AGENTS.md -First check: If CLAUDE.md already has a `## Testing` section → skip. Don't duplicate. +First check: If AGENTS.md already has a `## Testing` section → skip. Don't duplicate. Append a `## Testing` section: - Run command and test directory @@ -249,7 +220,7 @@ Append a `## Testing` section: git status --porcelain ``` -Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, CLAUDE.md, .github/workflows/test.yml if created): +Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, AGENTS.md, .github/workflows/test.yml if created): `git commit -m "chore: bootstrap test framework ({framework name})"` --- @@ -257,58 +228,24 @@ Only commit if there are changes. Stage all bootstrap files (config, test direct **Create output directories:** ```bash -mkdir -p .gstack/qa-reports/screenshots +mkdir -p .bitfun/team/qa-reports/screenshots ``` --- ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Test Plan Context Before falling back to git diff heuristics, check for richer test plan sources: -1. **Project-scoped test plans:** Check `~/.gstack/projects/` for recent `*-test-plan-*.md` files for this repo +1. **Project-scoped test plans:** Check `$HOME/.bitfun/team/projects/` for recent `*-test-plan-*.md` files for this repo ```bash setopt +o nomatch 2>/dev/null || true # zsh compat - eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" - ls -t ~/.gstack/projects/$SLUG/*-test-plan-*.md 2>/dev/null | head -1 + SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) + ls -t $HOME/.bitfun/team/projects/$SLUG/*-test-plan-*.md 2>/dev/null | head -1 ``` 2. **Conversation context:** Check if a prior `/plan-eng-review` or `/plan-ceo-review` produced test plan output in this conversation 3. **Use whichever source is richer.** Fall back to git diff analysis only if neither is available. @@ -334,16 +271,16 @@ This is the **primary mode** for developers verifying their work. When the user - View/template/component files → which pages render them - Model/service files → which pages use those models (check controllers that reference them) - CSS/style files → which pages include those stylesheets - - API endpoints → test them directly with `$B js "await fetch('/api/...')"` + - API endpoints → test them directly with `BitFun browser/computer-use js "await fetch('/api/...')"` - Static pages (markdown, HTML) → navigate to them directly **If no obvious pages/routes are identified from the diff:** Do not skip browser testing. The user invoked /qa because they want browser-based verification. Fall back to Quick mode — navigate to the homepage, follow the top 5 navigation targets, check console for errors, and test any interactive elements found. Backend, config, and infrastructure changes affect app behavior — always verify the app still works. 3. **Detect the running app** — check common local dev ports: ```bash - $B goto http://localhost:3000 2>/dev/null && echo "Found app on :3000" || \ - $B goto http://localhost:4000 2>/dev/null && echo "Found app on :4000" || \ - $B goto http://localhost:8080 2>/dev/null && echo "Found app on :8080" + BitFun browser/computer-use goto http://localhost:3000 2>/dev/null && echo "Found app on :3000" || \ + BitFun browser/computer-use goto http://localhost:4000 2>/dev/null && echo "Found app on :4000" || \ + BitFun browser/computer-use goto http://localhost:8080 2>/dev/null && echo "Found app on :8080" ``` If no local app is found, check for a staging/preview URL in the PR or environment. If nothing works, ask the user for the URL. @@ -380,7 +317,7 @@ Run full mode, then load `baseline.json` from a previous run. Diff: which issues ### Phase 1: Initialize -1. Find browse binary (see Setup above) +1. Find BitFun browser/computer-use tooling (see Setup above) 2. Create output directories 3. Copy report template from `qa/templates/qa-report-template.md` to output dir 4. Start timer for duration tracking @@ -390,19 +327,19 @@ Run full mode, then load `baseline.json` from a previous run. Diff: which issues **If the user specified auth credentials:** ```bash -$B goto -$B snapshot -i # find the login form -$B fill @e3 "user@example.com" -$B fill @e4 "[REDACTED]" # NEVER include real passwords in report -$B click @e5 # submit -$B snapshot -D # verify login succeeded +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i # find the login form +BitFun browser/computer-use fill @e3 "user@example.com" +BitFun browser/computer-use fill @e4 "[REDACTED]" # NEVER include real passwords in report +BitFun browser/computer-use click @e5 # submit +BitFun browser/computer-use snapshot -D # verify login succeeded ``` **If the user provided a cookie file:** ```bash -$B cookie-import cookies.json -$B goto +BitFun browser/computer-use cookie-import cookies.json +BitFun browser/computer-use goto ``` **If 2FA/OTP is required:** Ask the user for the code and wait. @@ -414,10 +351,10 @@ $B goto Get a map of the application: ```bash -$B goto -$B snapshot -i -a -o "$REPORT_DIR/screenshots/initial.png" -$B links # map navigation structure -$B console --errors # any errors on landing? +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/initial.png" +BitFun browser/computer-use links # map navigation structure +BitFun browser/computer-use console --errors # any errors on landing? ``` **Detect framework** (note in report metadata): @@ -433,9 +370,9 @@ $B console --errors # any errors on landing? Visit pages systematically. At each page: ```bash -$B goto -$B snapshot -i -a -o "$REPORT_DIR/screenshots/page-name.png" -$B console --errors +BitFun browser/computer-use goto +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/page-name.png" +BitFun browser/computer-use console --errors ``` Then follow the **per-page exploration checklist** (see `qa/references/issue-taxonomy.md`): @@ -448,9 +385,9 @@ Then follow the **per-page exploration checklist** (see `qa/references/issue-tax 6. **Console** — Any new JS errors after interactions? 7. **Responsiveness** — Check mobile viewport if relevant: ```bash - $B viewport 375x812 - $B screenshot "$REPORT_DIR/screenshots/page-mobile.png" - $B viewport 1280x720 + BitFun browser/computer-use viewport 375x812 + BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/page-mobile.png" + BitFun browser/computer-use viewport 1280x720 ``` **Depth judgment:** Spend more time on core features (homepage, dashboard, checkout, search) and less on secondary pages (about, terms, privacy). @@ -471,10 +408,10 @@ Document each issue **immediately when found** — don't batch them. 5. Write repro steps referencing screenshots ```bash -$B screenshot "$REPORT_DIR/screenshots/issue-001-step-1.png" -$B click @e5 -$B screenshot "$REPORT_DIR/screenshots/issue-001-result.png" -$B snapshot -D +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/issue-001-step-1.png" +BitFun browser/computer-use click @e5 +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/issue-001-result.png" +BitFun browser/computer-use snapshot -D ``` **Static bugs** (typos, layout issues, missing images): @@ -482,7 +419,7 @@ $B snapshot -D 2. Describe what's wrong ```bash -$B snapshot -i -a -o "$REPORT_DIR/screenshots/issue-002.png" +BitFun browser/computer-use snapshot -i -a -o "$REPORT_DIR/screenshots/issue-002.png" ``` **Write each issue to the report immediately** using the template format from `qa/templates/qa-report-template.md`. @@ -592,7 +529,7 @@ Minimum 0 per category. 8. **Depth over breadth.** 5-10 well-documented issues with evidence > 20 vague descriptions. 9. **Never delete output files.** Screenshots and reports accumulate — that's intentional. 10. **Use `snapshot -C` for tricky UIs.** Finds clickable divs that the accessibility tree misses. -11. **Show screenshots to the user.** After every `$B screenshot`, `$B snapshot -a -o`, or `$B responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. +11. **Show screenshots to the user.** After every `BitFun browser/computer-use screenshot`, `BitFun browser/computer-use snapshot -a -o`, or `BitFun browser/computer-use responsive` command, use the Read tool on the output file(s) so the user can see them inline. For `responsive` (3 files), Read all three. This is critical — without it, screenshots are invisible to the user. 12. **Never refuse to use the browser.** When the user invokes /qa or /qa-only, they are requesting browser-based testing. Never suggest evals, unit tests, or other alternatives as a substitute. Even if the diff appears to have no UI changes, backend changes affect app behavior — always open the browser and test. Record baseline health score at end of Phase 6. @@ -602,7 +539,7 @@ Record baseline health score at end of Phase 6. ## Output Structure ``` -.gstack/qa-reports/ +.bitfun/team/qa-reports/ ├── qa-report-{domain}-{YYYY-MM-DD}.md # Structured report ├── screenshots/ │ ├── initial.png # Landing page annotated screenshot @@ -668,10 +605,10 @@ git commit -m "fix(qa): ISSUE-NNN — short description" - Use `snapshot -D` to verify the change had the expected effect ```bash -$B goto -$B screenshot "$REPORT_DIR/screenshots/issue-NNN-after.png" -$B console --errors -$B snapshot -D +BitFun browser/computer-use goto +BitFun browser/computer-use screenshot "$REPORT_DIR/screenshots/issue-NNN-after.png" +BitFun browser/computer-use console --errors +BitFun browser/computer-use snapshot -D ``` ### 8e. Classify @@ -707,7 +644,7 @@ The test MUST: ``` // Regression: ISSUE-NNN — {what broke} // Found by /qa on {YYYY-MM-DD} - // Report: .gstack/qa-reports/qa-report-{domain}-{date}.md + // Report: .bitfun/team/qa-reports/qa-report-{domain}-{date}.md ``` Test type decision: @@ -767,13 +704,13 @@ After all fixes are applied: Write the report to both local and project-scoped locations: -**Local:** `.gstack/qa-reports/qa-report-{domain}-{YYYY-MM-DD}.md` +**Local:** `.bitfun/team/qa-reports/qa-report-{domain}-{YYYY-MM-DD}.md` **Project-scoped:** Write test outcome artifact for cross-session context: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG ``` -Write to `~/.gstack/projects/{slug}/{user}-{branch}-test-outcome-{datetime}.md` +Write to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-test-outcome-{datetime}.md` **Per-issue additions** (beyond standard report template): - Fix Status: verified / best-effort / reverted / deferred @@ -807,7 +744,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"qa","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -815,7 +752,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-retro/SKILL.md b/src/crates/core/builtin_skills/gstack-retro/SKILL.md index 6938828e4..d62efb375 100644 --- a/src/crates/core/builtin_skills/gstack-retro/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-retro/SKILL.md @@ -10,7 +10,16 @@ description: | # /retro — Weekly Engineering Retrospective -Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Team-aware: identifies the user running the command, then analyzes every contributor with per-person praise and growth opportunities. Designed for a senior IC/CTO-level builder using Claude Code as a force multiplier. +Generates a comprehensive engineering retrospective analyzing commit history, work patterns, and code quality metrics. Team-aware: identifies the user running the command, then analyzes every contributor with per-person praise and growth opportunities. Designed for a senior IC/CTO-level builder using BitFun as a force multiplier. + +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the retrospective methodology. Use existing Task sub-agents for independent read-only analysis tracks, then keep the final retro narrative in the main Team session. + +- Do not assume a Retro sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom analytics/docs sub-agents if available; otherwise use `Explore` for repository history/work-pattern analysis and `FileFinder` for related reports or release notes. +- Good parallel Task tracks: commit/theme analysis, quality-risk patterns, docs/release trace, and follow-up action extraction. +- Do not ask Task sub-agents to edit files. The main Team orchestrator synthesizes the retro and action items. ## User-invocable When the user types `/retro`, run this skill. @@ -48,41 +57,7 @@ Usage: /retro [window | compare | global] ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ### Step 1: Gather Raw Data @@ -123,7 +98,7 @@ git log origin/ --since="" --format="AUTHOR:%aN" --name-only git shortlog origin/ --since="" -sn --no-merges # 8. Greptile triage history (if available) -cat ~/.gstack/greptile-history.md 2>/dev/null || true +cat $HOME/.bitfun/team/greptile-history.md 2>/dev/null || true # 9. TODOS.md backlog (if available) cat TODOS.md 2>/dev/null || true @@ -135,7 +110,7 @@ find . -name '*.test.*' -o -name '*.spec.*' -o -name '*_test.*' -o -name '*_spec git log origin/ --since="" --oneline --grep="test(qa):" --grep="test(design):" --grep="test: coverage" # 12. gstack skill usage telemetry (if available) -cat ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true +cat $HOME/.bitfun/team/analytics/skill-usage.jsonl 2>/dev/null || true # 12. Test files changed in window git log origin/ --since="" --format="" --name-only | grep -E '\.(test|spec)\.' | sort -u | wc -l @@ -173,7 +148,7 @@ bob 3 +120/-40 tests/ Sort by commits descending. The current user (from `git config user.name`) always appears first, labeled "You (name)". -**Greptile signal (if history exists):** Read `~/.gstack/greptile-history.md` (fetched in Step 1, command 8). Filter entries within the retro time window by date. Count entries by type: `fix`, `fp`, `already-fixed`. Compute signal ratio: `(fix + already-fixed) / (fix + already-fixed + fp)`. If no entries exist in the window or the file doesn't exist, skip the Greptile metric row. Skip unparseable lines silently. +**Greptile signal (if history exists):** Read `$HOME/.bitfun/team/greptile-history.md` (fetched in Step 1, command 8). Filter entries within the retro time window by date. Count entries by type: `fix`, `fp`, `already-fixed`. Compute signal ratio: `(fix + already-fixed) / (fix + already-fixed + fp)`. If no entries exist in the window or the file doesn't exist, skip the Greptile metric row. Skip unparseable lines silently. **Backlog Health (if TODOS.md exists):** Read `TODOS.md` (fetched in Step 1, command 9). Compute: - Total open TODOs (exclude items in `## Completed` section) @@ -189,7 +164,7 @@ Include in the metrics table: If TODOS.md doesn't exist, skip the Backlog Health row. -**Skill Usage (if analytics exist):** Read `~/.gstack/analytics/skill-usage.jsonl` if it exists. Filter entries within the retro time window by `ts` field. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name. Present as: +**Skill Usage (if analytics exist):** Read `$HOME/.bitfun/team/analytics/skill-usage.jsonl` if it exists. Filter entries within the retro time window by `ts` field. Separate skill activations (no `event` field) from hook fires (`event: "hook_fire"`). Aggregate by skill name. Present as: ``` | Skill Usage | /ship(12) /qa(8) /review(5) · 3 safety hook fires | @@ -197,7 +172,7 @@ If TODOS.md doesn't exist, skip the Backlog Health row. If the JSONL file doesn't exist or has no entries in the window, skip the Skill Usage row. -**Eureka Moments (if logged):** Read `~/.gstack/analytics/eureka.jsonl` if it exists. Filter entries within the retro time window by `ts` field. For each eureka moment, show the skill that flagged it, the branch, and a one-line summary of the insight. Present as: +**Eureka Moments (if logged):** Read `$HOME/.bitfun/team/analytics/eureka.jsonl` if it exists. Filter entries within the retro time window by `ts` field. For each eureka moment, show the skill that flagged it, the branch, and a one-line summary of the insight. Present as: ``` | Eureka Moments | 2 this period | @@ -301,7 +276,7 @@ For each contributor (including the current user), compute: **If only one contributor (solo repo):** Skip the team breakdown and proceed as before — the retro is personal. -**If there are Co-Authored-By trailers:** Parse `Co-Authored-By:` lines in commit messages. Credit those authors for the commit alongside the primary author. Note AI co-authors (e.g., `noreply@anthropic.com`) but do not include them as team members — instead, track "AI-assisted commits" as a separate metric. +**If there are Co-Authored-By trailers:** Parse `Co-Authored-By:` lines in commit messages. Credit those authors for the commit alongside the primary author. Note AI co-authors (e.g., `noreply@example.com`) but do not include them as team members — instead, track "AI-assisted commits" as a separate metric. ## Capture Learnings @@ -309,7 +284,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"retro","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -317,7 +292,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. @@ -433,7 +408,7 @@ Use the Write tool to save the JSON file with this schema: } ``` -**Note:** Only include the `greptile` field if `~/.gstack/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (command 10 returns > 0). If any has no data, omit the field entirely. +**Note:** Only include the `greptile` field if `$HOME/.bitfun/team/greptile-history.md` exists and has entries within the time window. Only include the `backlog` field if `TODOS.md` exists. Only include the `test_health` field if test files were found (command 10 returns > 0). If any has no data, omit the field entirely. Include test health data in the JSON when test files exist: ```json @@ -510,8 +485,8 @@ Check review JSONL logs for plan completion data from /ship runs this period: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" -cat ~/.gstack/projects/$SLUG/*-reviews.jsonl 2>/dev/null | grep '"skill":"ship"' | grep '"plan_items_total"' || echo "NO_PLAN_DATA" +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) +cat $HOME/.bitfun/team/projects/$SLUG/*-reviews.jsonl 2>/dev/null | grep '"skill":"ship"' | grep '"plan_items_total"' || echo "NO_PLAN_DATA" ``` If plan completion data exists within the retro time window: @@ -560,7 +535,7 @@ For each teammate (sorted by commits descending), write a section: - "Most commits land in a single burst — spacing work across the day could reduce context-switching fatigue" - "All commits land between 1-4am — sustainable pace matters for code quality long-term" -**AI collaboration note:** If many commits have `Co-Authored-By` AI trailers (e.g., Claude, Copilot), note the AI-assisted commit percentage as a team metric. Frame it neutrally — "N% of commits were AI-assisted" — without judgment. +**AI collaboration note:** If many commits have `Co-Authored-By` AI trailers (e.g., BitFun, Copilot), note the AI-assisted commit percentage as a team metric. Frame it neutrally — "N% of commits were AI-assisted" — without judgment. ### Top 3 Team Wins Identify the 3 highest-impact things shipped in the window across the whole team. For each: @@ -587,27 +562,12 @@ When the user runs `/retro global` (or `/retro global 14d`), follow this flow in Same midnight-aligned logic as the regular retro. Default 7d. The second argument after `global` is the window (e.g., `14d`, `30d`, `24h`). -### Global Step 2: Run discovery - -Locate and run the discovery script using this fallback chain: - -```bash -DISCOVER_BIN="" -[ -x ~/.claude/skills/gstack/bin/gstack-global-discover ] && DISCOVER_BIN=~/.claude/skills/gstack/bin/gstack-global-discover -[ -z "$DISCOVER_BIN" ] && [ -x .claude/skills/gstack/bin/gstack-global-discover ] && DISCOVER_BIN=.claude/skills/gstack/bin/gstack-global-discover -[ -z "$DISCOVER_BIN" ] && which gstack-global-discover >/dev/null 2>&1 && DISCOVER_BIN=$(which gstack-global-discover) -[ -z "$DISCOVER_BIN" ] && [ -f bin/gstack-global-discover.ts ] && DISCOVER_BIN="bun run bin/gstack-global-discover.ts" -echo "DISCOVER_BIN: $DISCOVER_BIN" -``` - -If no binary is found, tell the user: "Discovery script not found. Run `bun run build` in the gstack directory to compile it." and stop. - -Run the discovery: -```bash -$DISCOVER_BIN --since "" --format json 2>/tmp/gstack-discover-stderr -``` +### Global Step 2: Discover sessions -Read the stderr output from `/tmp/gstack-discover-stderr` for diagnostic info. Parse the JSON output from stdout. +Use BitFun's built-in session/project metadata and ordinary filesystem inspection. +Do not locate, build, or run external `global session discovery` binaries. If BitFun +session metadata is unavailable, fall back to the current repository only and say +that global session discovery is unavailable in this environment. If `total_sessions` is 0, say: "No AI coding sessions found in the last . Try a longer window: `/retro global 30d`" and stop. @@ -663,7 +623,7 @@ From the commit timestamps gathered in Step 3, group by date. For each date, cou From the discovery JSON, analyze tool usage patterns: - Which AI tool is used for which repos (exclusive vs. shared) - Session count per tool -- Behavioral patterns (e.g., "Codex used exclusively for myapp, Claude Code for everything else") +- Behavioral patterns (e.g., "outside-voice sub-agent used exclusively for myapp, BitFun for everything else") ### Global Step 7: Aggregate and generate narrative @@ -697,7 +657,7 @@ align cleanly. Never truncate project names. ║ ║ [N] commits across [M] projects ║ +[X]k LOC added · [Y]k LOC deleted · [Z]k net -║ [N] AI coding sessions (CC: X, Codex: Y, Gemini: Z) +║ [N] AI coding sessions (CC: X, outside-voice sub-agent: Y, Gemini: Z) ║ [N]-day shipping streak 🔥 ║ ║ PROJECTS @@ -730,7 +690,7 @@ align cleanly. Never truncate project names. - Top Work: 3 bullet points summarizing the user's major themes, inferred from commit messages. Not individual commits — synthesize into themes. E.g., "Built /retro global — cross-project retrospective with AI session discovery" - not "feat: gstack-global-discover" + "feat: /retro global template". + not "feat: global session discovery" + "feat: /retro global template". - The card must be self-contained. Someone seeing ONLY this block should understand the user's week without any surrounding context. - Do NOT include team members, project totals, or context switching data here. @@ -751,7 +711,7 @@ This is the "deep dive" that follows the shareable card. | Projects active | N | | Total commits (all repos, all contributors) | N | | Total LOC | +N / -N | -| AI coding sessions | N (CC: X, Codex: Y, Gemini: Z) | +| AI coding sessions | N (CC: X, outside-voice sub-agent: Y, Gemini: Z) | | Active days | N | | Global shipping streak (any contributor, any repo) | N consecutive days | | Context switches/day | N avg (max: M) | @@ -793,8 +753,8 @@ Format: ### Tool Usage Analysis Per-tool breakdown with behavioral patterns: -- Claude Code: N sessions across M repos — patterns observed -- Codex: N sessions across M repos — patterns observed +- BitFun: N sessions across M repos — patterns observed +- outside-voice sub-agent: N sessions across M repos — patterns observed - Gemini: N sessions across M repos — patterns observed ### Ship of the Week (Global) @@ -812,7 +772,7 @@ Considering the full cross-project picture. ```bash setopt +o nomatch 2>/dev/null || true # zsh compat -ls -t ~/.gstack/retros/global-*.json 2>/dev/null | head -5 +ls -t $HOME/.bitfun/team/retros/global-*.json 2>/dev/null | head -5 ``` **Only compare against a prior retro with the same `window` value** (e.g., 7d vs 7d). If the most recent prior retro has a different window, skip comparison and note: "Prior global retro used a different window — skipping comparison." @@ -824,18 +784,18 @@ If no prior global retros exist, append: "First global retro recorded — run ag ### Global Step 9: Save snapshot ```bash -mkdir -p ~/.gstack/retros +mkdir -p $HOME/.bitfun/team/retros ``` Determine the next sequence number for today: ```bash setopt +o nomatch 2>/dev/null || true # zsh compat today=$(date +%Y-%m-%d) -existing=$(ls ~/.gstack/retros/global-${today}-*.json 2>/dev/null | wc -l | tr -d ' ') +existing=$(ls $HOME/.bitfun/team/retros/global-${today}-*.json 2>/dev/null | wc -l | tr -d ' ') next=$((existing + 1)) ``` -Use the Write tool to save JSON to `~/.gstack/retros/global-${today}-${next}.json`: +Use the Write tool to save JSON to `$HOME/.bitfun/team/retros/global-${today}-${next}.json`: ```json { @@ -862,7 +822,7 @@ Use the Write tool to save JSON to `~/.gstack/retros/global-${today}-${next}.jso "global_streak_days": 52, "avg_context_switches_per_day": 2.1 }, - "tweetable": "Week of Mar 14: 5 projects, 182 commits, 15.3k LOC | CC: 48, Codex: 8, Gemini: 3 | Focus: gstack (58%) | Streak: 52d" + "tweetable": "Week of Mar 14: 5 projects, 182 commits, 15.3k LOC | CC: 48, outside-voice sub-agent: 8, Gemini: 3 | Focus: gstack (58%) | Streak: 52d" } ``` @@ -899,6 +859,6 @@ When the user runs `/retro compare` (or `/retro compare 14d`): - If the window has zero commits, say so and suggest a different window - Round LOC/hour to nearest 50 - Treat merge commits as PR boundaries -- Do not read CLAUDE.md or other docs — this skill is self-contained +- Do not read AGENTS.md or other docs — this skill is self-contained - On first run (no prior retros), skip comparison sections gracefully -- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `~/.gstack/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". +- **Global mode:** Does NOT require being inside a git repo. Saves snapshots to `$HOME/.bitfun/team/retros/` (not `.context/retros/`). Gracefully skip AI tools that aren't installed. Only compare against prior global retros with the same window value. If streak hits 365d cap, display as "365+ days". diff --git a/src/crates/core/builtin_skills/gstack-review/SKILL.md b/src/crates/core/builtin_skills/gstack-review/SKILL.md index a64eeef31..10bc2b8ed 100644 --- a/src/crates/core/builtin_skills/gstack-review/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-review/SKILL.md @@ -11,6 +11,16 @@ description: | You are running the `/review` workflow. Analyze the current branch's diff against the base branch for structural issues that tests don't catch. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the pre-landing review lens. Use existing Task sub-agents for independent diff review tracks, then consolidate findings in the main Team session. + +- Do not assume a Staff Engineer sub-agent exists. Choose only from the Task tool's available agents. +- Prefer built-in review sub-agents when available: `ReviewBusinessLogic` for correctness, `ReviewPerformance` for hot paths, `ReviewSecurity` for security-sensitive diff, and `ReviewJudge` for evidence/quality inspection after reviewers return. +- Prefer matching custom review sub-agents over generic ones. Use `Explore` only for broad read-only investigation when specialist reviewers are unavailable. +- Keep Task work read-only. Ask for tight findings with file paths, line references if possible, severity, confidence, and why tests might miss it. +- The main Team orchestrator owns final severity ordering, AUTO-FIX vs ASK classification, and any code changes. + --- ## Step 1: Check branch @@ -66,11 +76,11 @@ Before reviewing code quality, check: **did they build what was requested — no setopt +o nomatch 2>/dev/null || true # zsh compat BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-') REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)") -# Compute project slug for ~/.gstack/projects/ lookup +# Compute project slug for $HOME/.bitfun/team/projects/ lookup _PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true _PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}" # Search common plan file locations (project designs first, then personal/local) -for PLAN_DIR in "$HOME/.gstack/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do +for PLAN_DIR in "$HOME/.bitfun/team/projects/$_PLAN_SLUG" "$HOME/.bitfun/team/plans" "$HOME/.codex/plans" ".bitfun/team/plans"; do [ -d "$PLAN_DIR" ] || continue PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1) [ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1) @@ -189,7 +199,7 @@ IMPACT: {HIGH|MEDIUM|LOW} — {what breaks or degrades if this stays undelivered **Only for discrepancies sourced from plan files** (not commit messages or TODOS.md), log a learning so future sessions know this pattern occurred: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{ +true # BitFun Team Mode has no external telemetry helper "type": "pitfall", "key": "plan-delivery-gap-KEBAB_SUMMARY", "insight": "Planned X but delivered Y because Z", @@ -231,7 +241,7 @@ Plan items: N DONE, M PARTIAL, K NOT DONE ## Step 2: Read the checklist -Read `.claude/skills/review/checklist.md`. +Read `the built-in review checklist`. **If the file cannot be read, STOP and report the error.** Do not proceed without the checklist. @@ -239,7 +249,7 @@ Read `.claude/skills/review/checklist.md`. ## Step 2.5: Check for Greptile review comments -Read `.claude/skills/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. +Read `the built-in review-triage checklist` and follow the fetch, filter, classify, and **escalation detection** steps. **If no PR exists, `gh` fails, API returns an error, or there are zero Greptile comments:** Skip this step silently. Greptile integration is additive — the review works without it. @@ -261,41 +271,7 @@ Run `git diff origin/` to get the full diff. This includes both committed ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Step 4: Critical pass (core review) @@ -347,7 +323,7 @@ higher confidence. ### Detect stack and scope ```bash -source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null) || true +source <(true # BitFun Team Mode infers diff scope with git/rg 2>/dev/null) || true # Detect stack for specialist context STACK="" [ -f Gemfile ] && STACK="${STACK}ruby " @@ -373,7 +349,7 @@ echo "TEST_FW: ${TEST_FW:-unknown}" ### Read specialist hit rates (adaptive gating) ```bash -~/.claude/skills/gstack/bin/gstack-specialist-stats 2>/dev/null || true +true # BitFun Team Mode has no external specialist-stats helper 2>/dev/null || true ``` ### Select specialists @@ -381,23 +357,23 @@ echo "TEST_FW: ${TEST_FW:-unknown}" Based on the scope signals above, select which specialists to dispatch. **Always-on (dispatch on every review with 50+ changed lines):** -1. **Testing** — read `~/.claude/skills/gstack/review/specialists/testing.md` -2. **Maintainability** — read `~/.claude/skills/gstack/review/specialists/maintainability.md` +1. **Testing** — read `the built-in testing review checklist` +2. **Maintainability** — read `the built-in maintainability review checklist` **If DIFF_LINES < 50:** Skip all specialists. Print: "Small diff ($DIFF_LINES lines) — specialists skipped." Continue to Step 5. **Conditional (dispatch if the matching scope signal is true):** -3. **Security** — if SCOPE_AUTH=true, OR if SCOPE_BACKEND=true AND DIFF_LINES > 100. Read `~/.claude/skills/gstack/review/specialists/security.md` -4. **Performance** — if SCOPE_BACKEND=true OR SCOPE_FRONTEND=true. Read `~/.claude/skills/gstack/review/specialists/performance.md` -5. **Data Migration** — if SCOPE_MIGRATIONS=true. Read `~/.claude/skills/gstack/review/specialists/data-migration.md` -6. **API Contract** — if SCOPE_API=true. Read `~/.claude/skills/gstack/review/specialists/api-contract.md` -7. **Design** — if SCOPE_FRONTEND=true. Use the existing design review checklist at `~/.claude/skills/gstack/review/design-checklist.md` +3. **Security** — if SCOPE_AUTH=true, OR if SCOPE_BACKEND=true AND DIFF_LINES > 100. Read `the built-in security review checklist` +4. **Performance** — if SCOPE_BACKEND=true OR SCOPE_FRONTEND=true. Read `the built-in performance review checklist` +5. **Data Migration** — if SCOPE_MIGRATIONS=true. Read `the built-in data-migration review checklist` +6. **API Contract** — if SCOPE_API=true. Read `the built-in API-contract review checklist` +7. **Design** — if SCOPE_FRONTEND=true. Use the existing design review checklist at `the built-in design review checklist` ### Adaptive gating After scope-based selection, apply adaptive gating based on specialist hit rates: -For each conditional specialist that passed scope gating, check the `gstack-specialist-stats` output above: +For each conditional specialist that passed scope gating, check the `built-in specialist summary` output above: - If tagged `[GATE_CANDIDATE]` (0 findings in 10+ dispatches): skip it. Print: "[specialist] auto-gated (0 findings in N reviews)." - If tagged `[NEVER_GATE]`: always dispatch regardless of hit rate. Security and data-migration are insurance policy specialists — they should run even when silent. @@ -410,8 +386,8 @@ Note which specialists were selected, gated, and skipped. Print the selection: ### Dispatch specialists in parallel -For each selected specialist, launch an independent subagent via the Agent tool. -**Launch ALL selected specialists in a single message** (multiple Agent tool calls) +For each selected specialist, launch an independent subagent via BitFun's Task tool. +**Launch ALL selected specialists in a single message** (multiple Task tool calls) so they run in parallel. Each subagent has fresh context — no prior review bias. **Each specialist subagent prompt:** @@ -423,7 +399,7 @@ Construct the prompt for each specialist. The prompt includes: 3. Past learnings for this domain (if any exist): ```bash -~/.claude/skills/gstack/bin/gstack-learnings-search --type pitfall --query "{specialist domain}" --limit 5 2>/dev/null || true +true # BitFun Team Mode has no external learnings helper ``` If learnings are found, include them: "Past learnings for this domain: {learnings}" @@ -525,10 +501,10 @@ Remember these stats — you will need them for the review-log entry in Step 5.8 **Activation:** Only if DIFF_LINES > 200 OR any specialist produced a CRITICAL finding. -If activated, dispatch one more subagent via the Agent tool (foreground, not background). +If activated, dispatch one more subagent via the Task tool (foreground, not background). The Red Team subagent receives: -1. The red-team checklist from `~/.claude/skills/gstack/review/specialists/red-team.md` +1. The red-team checklist from `the built-in red-team review checklist` 2. The merged specialist findings from Step 4.6 (so it knows what was already caught) 3. The git diff command @@ -556,7 +532,7 @@ If the Red Team subagent fails or times out, skip silently and continue. Before classifying findings, check if any were previously skipped by the user in a prior review on this branch. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` Parse the output: only lines BEFORE `---CONFIG---` are JSONL entries (the output also contains `---CONFIG---` and `---HEAD---` footer sections that are not JSONL — ignore those). @@ -687,7 +663,7 @@ If TODOS.md doesn't exist, skip this step silently. ## Step 5.6: Documentation staleness check -Cross-reference the diff against documentation files. For each `.md` file in the repo root (README.md, ARCHITECTURE.md, CONTRIBUTING.md, CLAUDE.md, etc.): +Cross-reference the diff against documentation files. For each `.md` file in the repo root (README.md, ARCHITECTURE.md, CONTRIBUTING.md, AGENTS.md, etc.): 1. Check if code changes in the diff affect features, components, or workflows described in that doc file. 2. If the doc file was NOT updated in this branch but the code it describes WAS changed, flag it as an INFORMATIONAL finding: @@ -701,7 +677,7 @@ If no documentation files exist, skip this step silently. ## Step 5.7: Adversarial review (always-on) -Every diff gets adversarial review from both Claude and Codex. LOC is not a proxy for risk — a 5-line auth change can be critical. +Every diff gets adversarial review from both BitFun and outside-voice sub-agent. LOC is not a proxy for risk — a 5-line auth change can be critical. **Detect diff size and tool availability:** @@ -710,39 +686,39 @@ DIFF_INS=$(git diff origin/ --stat | tail -1 | grep -oE '[0-9]+ insertion' DIFF_DEL=$(git diff origin/ --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" -# Legacy opt-out — only gates Codex passes, Claude always runs -OLD_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || true) +# Legacy opt-out — only gates outside-voice sub-agent passes, BitFun always runs +OLD_CFG="" # BitFun Team Mode has no external codex_reviews config echo "DIFF_SIZE: $DIFF_TOTAL" echo "OLD_CFG: ${OLD_CFG:-not_set}" ``` -If `OLD_CFG` is `disabled`: skip Codex passes only. Claude adversarial subagent still runs (it's free and fast). Jump to the "Claude adversarial subagent" section. +If `OLD_CFG` is `disabled`: skip outside-voice sub-agent passes only. BitFun adversarial subagent still runs (it's free and fast). Jump to the "BitFun adversarial subagent" section. -**User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the Codex structured review regardless of diff size. +**User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the outside-voice sub-agent structured review regardless of diff size. --- -### Claude adversarial subagent (always runs) +### BitFun adversarial subagent (always runs) -Dispatch via the Agent tool. The subagent has fresh context — no checklist bias from the structured review. This genuine independence catches things the primary reviewer is blind to. +Dispatch via the Task tool. The subagent has fresh context — no checklist bias from the structured review. This genuine independence catches things the primary reviewer is blind to. Subagent prompt: "Read the diff for this branch with `git diff origin/`. Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. Be adversarial. Be thorough. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment)." -Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE findings** flow into the same Fix-First pipeline as the structured review. **INVESTIGATE findings** are presented as informational. +Present findings under an `ADVERSARIAL REVIEW (independent subagent):` header. **FIXABLE findings** flow into the same Fix-First pipeline as the structured review. **INVESTIGATE findings** are presented as informational. -If the subagent fails or times out: "Claude adversarial subagent unavailable. Continuing." +If the subagent fails or times out: "BitFun adversarial subagent unavailable. Continuing." --- -### Codex adversarial challenge (always runs when available) +### outside-voice sub-agent adversarial challenge (always runs when available) -If Codex is available AND `OLD_CFG` is NOT `disabled`: +If a suitable BitFun outside-voice or review sub-agent is available AND `OLD_CFG` is NOT `disabled`: ```bash TMPERR_ADV=$(mktemp /tmp/codex-adv-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: @@ -753,25 +729,25 @@ cat "$TMPERR_ADV" Present the full output verbatim. This is informational — it never blocks shipping. **Error handling:** All errors are non-blocking — adversarial review is a quality enhancement, not a prerequisite. -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \`codex login\` to authenticate." -- **Timeout:** "Codex timed out after 5 minutes." -- **Empty response:** "Codex returned no response. Stderr: ." +- **Outside-voice unavailable:** If the selected BitFun sub-agent cannot run, skip this informational pass and continue with the main-session review. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." +- **Empty response:** "outside-voice sub-agent returned no response. Stderr: ." **Cleanup:** Run `rm -f "$TMPERR_ADV"` after processing. -If Codex is NOT available: "Codex CLI not found — running Claude adversarial only. Install Codex for cross-model coverage: `npm install -g @openai/codex`" +If outside-voice sub-agent is not available in the current BitFun runtime, run the BitFun adversarial path only and note that cross-model coverage was skipped. --- -### Codex structured review (large diffs only, 200+ lines) +### outside-voice sub-agent structured review (large diffs only, 200+ lines) -If `DIFF_TOTAL >= 200` AND Codex is available AND `OLD_CFG` is NOT `disabled`: +If `DIFF_TOTAL >= 200` AND outside-voice sub-agent is available AND `OLD_CFG` is NOT `disabled`: ```bash -TMPERR=$(mktemp /tmp/codex-review-XXXXXXXX) +TMPERR=$(mktemp /tmp/outside-voice-review-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } cd "$_REPO_ROOT" -codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR" +Use the BitFun Task tool to dispatch a suitable independent read-only structured review sub-agent over the diff. ``` Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. @@ -779,19 +755,19 @@ Check for `[P1]` markers: found → `GATE: FAIL`, not found → `GATE: PASS`. If GATE is FAIL, use AskUserQuestion: ``` -Codex found N critical issues in the diff. +outside-voice sub-agent found N critical issues in the diff. A) Investigate and fix now (recommended) B) Continue — review will still complete ``` -If A: address the findings. Re-run `codex review` to verify. +If A: address the findings. Re-run `BitFun Task outside-voice review` to verify. -Read stderr for errors (same error handling as Codex adversarial above). +Read stderr for errors (same error handling as outside-voice sub-agent adversarial above). After stderr: `rm -f "$TMPERR"` -If `DIFF_TOTAL < 200`: skip this section silently. The Claude + Codex adversarial passes provide sufficient coverage for smaller diffs. +If `DIFF_TOTAL < 200`: skip this section silently. The BitFun + outside-voice sub-agent adversarial passes provide sufficient coverage for smaller diffs. --- @@ -799,9 +775,9 @@ If `DIFF_TOTAL < 200`: skip this section silently. The Claude + Codex adversaria After all passes complete, persist: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"adversarial-review","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","tier":"always","gate":"GATE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` -Substitute: STATUS = "clean" if no findings across ALL passes, "issues_found" if any pass found issues. SOURCE = "both" if Codex ran, "claude" if only Claude subagent ran. GATE = the Codex structured review gate result ("pass"/"fail"), "skipped" if diff < 200, or "informational" if Codex was unavailable. If all passes failed, do NOT persist. +Substitute: STATUS = "clean" if no findings across ALL passes, "issues_found" if any pass found issues. SOURCE = "both" if outside-voice sub-agent ran, "task" if only independent subagent ran. GATE = the outside-voice sub-agent structured review gate result ("pass"/"fail"), "skipped" if diff < 200, or "informational" if outside-voice sub-agent was unavailable. If all passes failed, do NOT persist. --- @@ -813,10 +789,10 @@ After all passes complete, synthesize findings across all sources: ADVERSARIAL REVIEW SYNTHESIS (always-on, N lines): ════════════════════════════════════════════════════════════ High confidence (found by multiple sources): [findings agreed on by >1 pass] - Unique to Claude structured review: [from earlier step] - Unique to Claude adversarial: [from subagent] - Unique to Codex: [from codex adversarial or code review, if ran] - Models used: Claude structured ✓ Claude adversarial ✓/✗ Codex ✓/✗ + Unique to BitFun structured review: [from earlier step] + Unique to BitFun adversarial: [from subagent] + Unique to outside-voice sub-agent: [from codex adversarial or code review, if ran] + Models used: BitFun structured ✓ BitFun adversarial ✓/✗ outside-voice sub-agent ✓/✗ ════════════════════════════════════════════════════════════ ``` @@ -832,7 +808,7 @@ recognize that Eng Review was run on this branch. Run: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"COMMIT"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute: @@ -852,7 +828,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"review","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -860,7 +836,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. diff --git a/src/crates/core/builtin_skills/gstack-ship/SKILL.md b/src/crates/core/builtin_skills/gstack-ship/SKILL.md index 7882c035e..14142c634 100644 --- a/src/crates/core/builtin_skills/gstack-ship/SKILL.md +++ b/src/crates/core/builtin_skills/gstack-ship/SKILL.md @@ -12,6 +12,16 @@ description: | You are running the `/ship` workflow. This is a **non-interactive, fully automated** workflow. Do NOT ask for confirmation at any step. The user said `/ship` which means DO IT. Run straight through and output the PR URL at the end. +## BitFun Team Mode Dispatch + +When this skill is invoked by BitFun Team Mode, this skill supplies the release-engineering checklist. Use existing Task sub-agents only for read-only readiness checks that can run independently, then keep all mutations in the main Team session. + +- Do not assume a Release Engineer sub-agent exists. Choose only from the Task tool's available agents. +- Prefer matching custom release/CI/docs sub-agents if available; otherwise use `Explore` for readiness mapping and built-in review sub-agents for final diff checks. +- Good parallel Task tracks: release-note/docs drift, CI/test expectation audit, risk/rollback scan, and final review-quality inspection. +- Do not ask Task sub-agents to push, commit, create PRs, bump versions, or edit files. The main Team session owns all release mutations. +- The main Team orchestrator synthesizes Task readiness results before running ship steps. + **Only stop for:** - On the base branch (abort) - Merge conflicts that can't be auto-resolved (stop, show conflicts) @@ -62,10 +72,10 @@ Never skip a verification step because a prior `/ship` run already performed it. After completing the review, read the review log and config to display the dashboard. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` -Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, codex-review, codex-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `codex-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `codex-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. +Parse the output. Find the most recent entry for each skill (plan-ceo-review, plan-eng-review, review, plan-design-review, design-review-lite, adversarial-review, outside-voice-review, outside-voice-plan-review). Ignore entries with timestamps older than 7 days. For the Eng Review row, show whichever is more recent between `review` (diff-scoped pre-landing review) and `plan-eng-review` (plan-stage architecture review). Append "(DIFF)" or "(PLAN)" to the status to distinguish. For the Adversarial row, show whichever is more recent between `adversarial-review` (new auto-scaled) and `outside-voice-review` (legacy). For Design Review, show whichever is more recent between `plan-design-review` (full visual audit) and `design-review-lite` (code-level check). Append "(FULL)" or "(LITE)" to the status to distinguish. For the Outside Voice row, show the most recent `outside-voice-plan-review` entry — this captures outside voices from both /plan-ceo-review and /plan-eng-review. **Source attribution:** If the most recent entry for a skill has a \`"via"\` field, append it to the status label in parentheses. Examples: `plan-eng-review` with `via:"autoplan"` shows as "CLEAR (PLAN via /autoplan)". `review` with `via:"ship"` shows as "CLEAR (DIFF via /ship)". Entries without a `via` field show as "CLEAR (PLAN)" or "CLEAR (DIFF)" as before. @@ -90,16 +100,16 @@ Display: ``` **Review tiers:** -- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`gstack-config set skip_eng_review true\` (the "don't bother me" setting). +- **Eng Review (required by default):** The only review that gates shipping. Covers architecture, code quality, tests, performance. Can be disabled globally with \`Team Mode setting skip_eng_review=true\` (the "don't bother me" setting). - **CEO Review (optional):** Use your judgment. Recommend it for big product/business changes, new user-facing features, or scope decisions. Skip for bug fixes, refactors, infra, and cleanup. - **Design Review (optional):** Use your judgment. Recommend it for UI/UX changes. Skip for backend-only, infra, or prompt-only changes. -- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both Claude adversarial subagent and Codex adversarial challenge. Large diffs (200+ lines) additionally get Codex structured review with P1 gate. No configuration needed. -- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to Claude subagent if Codex is unavailable. Never gates shipping. +- **Adversarial Review (automatic):** Always-on for every review. Every diff gets both BitFun adversarial subagent and outside-voice sub-agent adversarial challenge. Large diffs (200+ lines) additionally get outside-voice sub-agent structured review with P1 gate. No configuration needed. +- **Outside Voice (optional):** Independent plan review from a different AI model. Offered after all review sections complete in /plan-ceo-review and /plan-eng-review. Falls back to independent subagent if outside-voice sub-agent is unavailable. Never gates shipping. **Verdict logic:** - **CLEARED**: Eng Review has >= 1 entry within 7 days from either \`review\` or \`plan-eng-review\` with status "clean" (or \`skip_eng_review\` is \`true\`) - **NOT CLEARED**: Eng Review missing, stale (>7 days), or has open issues -- CEO, Design, and Codex reviews are shown for context but never block shipping +- CEO, Design, and outside-voice sub-agent reviews are shown for context but never block shipping - If \`skip_eng_review\` config is \`true\`, Eng Review shows "SKIPPED (global)" and verdict is CLEARED **Staleness detection:** After displaying the dashboard, check if any existing reviews may be stale: @@ -116,7 +126,7 @@ Check diff size: `git diff ...HEAD --stat | tail -1`. If the diff is >200 If CEO Review is missing, mention as informational ("CEO Review not run — recommended for product changes") but do NOT block. -For Design Review: run `source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review (plan-design-review or design-review-lite) exists in the dashboard, mention: "Design Review not run — this PR changes frontend code. The lite design check will run automatically in Step 3.5, but consider running /design-review for a full visual audit post-implementation." Still never block. +For Design Review: run `source <(true # BitFun Team Mode infers diff scope with git/rg 2>/dev/null)`. If `SCOPE_FRONTEND=true` and no design review (plan-design-review or design-review-lite) exists in the dashboard, mention: "Design Review not run — this PR changes frontend code. The lite design check will run automatically in Step 3.5, but consider running /design-review for a full visual audit post-implementation." Still never block. Continue to Step 1.5 — do NOT block or ask. Ship runs its own review in Step 3.5. @@ -187,7 +197,7 @@ setopt +o nomatch 2>/dev/null || true # zsh compat ls jest.config.* vitest.config.* playwright.config.* .rspec pytest.ini pyproject.toml phpunit.xml 2>/dev/null ls -d test/ tests/ spec/ __tests__/ cypress/ e2e/ 2>/dev/null # Check opt-out marker -[ -f .gstack/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" +[ -f .bitfun/team/no-test-bootstrap ] && echo "BOOTSTRAP_DECLINED" ``` **If test framework detected** (config files or test directories found): @@ -200,7 +210,7 @@ Store conventions as prose context for use in Phase 8e.5 or Step 3.4. **Skip the **If NO runtime detected** (no config files found): Use AskUserQuestion: "I couldn't detect your project's language. What runtime are you using?" Options: A) Node.js/TypeScript B) Ruby/Rails C) Python D) Go E) Rust F) PHP G) Elixir H) This project doesn't need tests. -If user picks H → write `.gstack/no-test-bootstrap` and continue without tests. +If user picks H → write `.bitfun/team/no-test-bootstrap` and continue without tests. **If runtime detected but no test framework — bootstrap:** @@ -232,7 +242,7 @@ B) [Alternative] — [rationale]. Includes: [packages] C) Skip — don't set up testing right now RECOMMENDATION: Choose A because [reason based on project context]" -If user picks C → write `.gstack/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.gstack/no-test-bootstrap` and re-run." Continue without tests. +If user picks C → write `.bitfun/team/no-test-bootstrap`. Tell user: "If you change your mind later, delete `.bitfun/team/no-test-bootstrap` and re-run." Continue without tests. If multiple runtimes detected (monorepo) → ask which runtime to set up first, with option to do both sequentially. @@ -294,9 +304,9 @@ Write TESTING.md with: - Test layers: Unit tests (what, where, when), Integration tests, Smoke tests, E2E tests - Conventions: file naming, assertion style, setup/teardown patterns -### B7. Update CLAUDE.md +### B7. Update AGENTS.md -First check: If CLAUDE.md already has a `## Testing` section → skip. Don't duplicate. +First check: If AGENTS.md already has a `## Testing` section → skip. Don't duplicate. Append a `## Testing` section: - Run command and test directory @@ -315,7 +325,7 @@ Append a `## Testing` section: git status --porcelain ``` -Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, CLAUDE.md, .github/workflows/test.yml if created): +Only commit if there are changes. Stage all bootstrap files (config, test directory, TESTING.md, AGENTS.md, .github/workflows/test.yml if created): `git commit -m "chore: bootstrap test framework ({framework name})"` --- @@ -408,7 +418,7 @@ Use AskUserQuestion: - Continue with the workflow. **If "Add as P0 TODO":** -- If `TODOS.md` exists, add the entry following the format in `review/TODOS-format.md` (or `.claude/skills/review/TODOS-format.md`). +- If `TODOS.md` exists, add the entry following the format in `review/TODOS-format.md` (or `the built-in review TODO format`). - If `TODOS.md` does not exist, create it with the standard header and add the entry. - Entry should include: title, the error output, which branch it was noticed on, and priority P0. - Continue with the workflow — treat the pre-existing failure as non-blocking. @@ -460,7 +470,7 @@ Evals are mandatory when prompt-related files change. Skip this step entirely if git diff origin/ --name-only ``` -Match against these patterns (from CLAUDE.md): +Match against these patterns (from AGENTS.md): - `app/services/*_prompt_builder.rb` - `app/services/*_generation_service.rb`, `*_writer_service.rb`, `*_designer_service.rb` - `app/services/*_evaluator.rb`, `*_scorer.rb`, `*_classifier_service.rb`, `*_analyzer.rb` @@ -520,8 +530,8 @@ If multiple suites need to run, run them sequentially (each needs a test lane). Before analyzing coverage, detect the project's test framework: -1. **Read CLAUDE.md** — look for a `## Testing` section with test command and framework name. If found, use that as the authoritative source. -2. **If CLAUDE.md has no testing section, auto-detect:** +1. **Read AGENTS.md** — look for a `## Testing` section with test command and framework name. If found, use that as the authoritative source. +2. **If AGENTS.md has no testing section, auto-detect:** ```bash setopt +o nomatch 2>/dev/null || true # zsh compat @@ -710,7 +720,7 @@ Coverage line: `Test Coverage Audit: N new code paths. M covered (X%). K tests g **7. Coverage gate:** -Before proceeding, check CLAUDE.md for a `## Test Coverage` section with `Minimum:` and `Target:` fields. If found, use those percentages. Otherwise use defaults: Minimum = 60%, Target = 80%. +Before proceeding, check AGENTS.md for a `## Test Coverage` section with `Minimum:` and `Target:` fields. If found, use those percentages. Otherwise use defaults: Minimum = 60%, Target = 80%. Using the coverage percentage from the diagram in substep 4 (the `COVERAGE: X/Y (Z%)` line): @@ -746,12 +756,12 @@ Using the coverage percentage from the diagram in substep 4 (the `COVERAGE: X/Y After producing the coverage diagram, write a test plan artifact so `/qa` and `/qa-only` can consume it: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG USER=$(whoami) DATETIME=$(date +%Y%m%d-%H%M%S) ``` -Write to `~/.gstack/projects/{slug}/{user}-{branch}-ship-test-plan-{datetime}.md`: +Write to `$HOME/.bitfun/team/projects/{slug}/{user}-{branch}-ship-test-plan-{datetime}.md`: ```markdown # Test Plan @@ -786,11 +796,11 @@ Repo: {owner/repo} setopt +o nomatch 2>/dev/null || true # zsh compat BRANCH=$(git branch --show-current 2>/dev/null | tr '/' '-') REPO=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)") -# Compute project slug for ~/.gstack/projects/ lookup +# Compute project slug for $HOME/.bitfun/team/projects/ lookup _PLAN_SLUG=$(git remote get-url origin 2>/dev/null | sed 's|.*[:/]\([^/]*/[^/]*\)\.git$|\1|;s|.*[:/]\([^/]*/[^/]*\)$|\1|' | tr '/' '-' | tr -cd 'a-zA-Z0-9._-') || true _PLAN_SLUG="${_PLAN_SLUG:-$(basename "$PWD" | tr -cd 'a-zA-Z0-9._-')}" # Search common plan file locations (project designs first, then personal/local) -for PLAN_DIR in "$HOME/.gstack/projects/$_PLAN_SLUG" "$HOME/.claude/plans" "$HOME/.codex/plans" ".gstack/plans"; do +for PLAN_DIR in "$HOME/.bitfun/team/projects/$_PLAN_SLUG" "$HOME/.bitfun/team/plans" "$HOME/.codex/plans" ".bitfun/team/plans"; do [ -d "$PLAN_DIR" ] || continue PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$BRANCH" 2>/dev/null | head -1) [ -z "$PLAN" ] && PLAN=$(ls -t "$PLAN_DIR"/*.md 2>/dev/null | xargs grep -l "$REPO" 2>/dev/null | head -1) @@ -924,7 +934,7 @@ curl -s -o /dev/null -w '%{http_code}' http://localhost:4000 2>/dev/null || echo Read the `/qa-only` skill from disk: ```bash -cat ${CLAUDE_SKILL_DIR}/../qa-only/SKILL.md +Load the bundled qa-only skill through the Skill tool ``` **If unreadable:** Skip with "Could not load /qa-only — skipping plan verification." @@ -955,41 +965,7 @@ Add a `## Verification Results` section to the PR body (Step 8): ## Prior Learnings -Search for relevant learnings from previous sessions: - -```bash -_CROSS_PROJ=$(~/.claude/skills/gstack/bin/gstack-config get cross_project_learnings 2>/dev/null || echo "unset") -echo "CROSS_PROJECT: $_CROSS_PROJ" -if [ "$_CROSS_PROJ" = "true" ]; then - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 --cross-project 2>/dev/null || true -else - ~/.claude/skills/gstack/bin/gstack-learnings-search --limit 10 2>/dev/null || true -fi -``` - -If `CROSS_PROJECT` is `unset` (first time): Use AskUserQuestion: - -> gstack can search learnings from your other projects on this machine to find -> patterns that might apply here. This stays local (no data leaves your machine). -> Recommended for solo developers. Skip if you work on multiple client codebases -> where cross-contamination would be a concern. - -Options: -- A) Enable cross-project learnings (recommended) -- B) Keep learnings project-scoped only - -If A: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings true` -If B: run `~/.claude/skills/gstack/bin/gstack-config set cross_project_learnings false` - -Then re-run the search with the appropriate flag. - -If learnings are found, incorporate them into your analysis. When a review finding -matches a past learning, display: - -**"Prior learning applied: [key] (confidence N/10, from [date])"** - -This makes the compounding visible. The user should see that gstack is getting -smarter on their codebase over time. +Use only BitFun in-session memory, project docs, `.bitfun/team/` artifacts, git history, TODO files, and prior design/review artifacts. Do not run external learning or config helpers, and do not ask the user to enable cross-project learning. If a relevant prior artifact is found, cite it as: `Prior BitFun context applied: `. ## Step 3.48: Scope Drift Detection @@ -1032,7 +1008,7 @@ Before reviewing code quality, check: **did they build what was requested — no Review the diff for structural issues that tests don't catch. -1. Read `.claude/skills/review/checklist.md`. If the file cannot be read, **STOP** and report the error. +1. Read `the built-in review checklist`. If the file cannot be read, **STOP** and report the error. 2. Run `git diff origin/` to get the full diff (scoped to feature changes against the freshly-fetched base branch). @@ -1067,10 +1043,10 @@ higher confidence. ## Design Review (conditional, diff-scoped) -Check if the diff touches frontend files using `gstack-diff-scope`: +Check if the diff touches frontend files using `git diff + rg scope inference`: ```bash -source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null) +source <(true # BitFun Team Mode infers diff scope with git/rg 2>/dev/null) ``` **If `SCOPE_FRONTEND=false`:** Skip design review silently. No output. @@ -1079,7 +1055,7 @@ source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null) 1. **Check for DESIGN.md.** If `DESIGN.md` or `design-system.md` exists in the repo root, read it. All design findings are calibrated against it — patterns blessed in DESIGN.md are not flagged. If not found, use universal design principles. -2. **Read `.claude/skills/review/design-checklist.md`.** If the file cannot be read, skip design review with a note: "Design checklist not found — skipping design review." +2. **Read `the built-in design review checklist`.** If the file cannot be read, skip design review with a note: "Design checklist not found — skipping design review." 3. **Read each changed frontend file** (full file, not just diff hunks). Frontend files are identified by the patterns listed in the checklist. @@ -1093,23 +1069,23 @@ source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null) 6. **Log the result** for the Review Readiness Dashboard: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"design-review-lite","timestamp":"TIMESTAMP","status":"STATUS","findings":N,"auto_fixed":M,"commit":"COMMIT"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute: TIMESTAMP = ISO 8601 datetime, STATUS = "clean" if 0 findings or "issues_found", N = total findings, M = auto-fixed count, COMMIT = output of `git rev-parse --short HEAD`. -7. **Codex design voice** (optional, automatic if available): +7. **outside-voice sub-agent design voice** (optional, automatic if available): ```bash which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" ``` -If Codex is available, run a lightweight design check on the diff: +If a suitable BitFun outside-voice or review sub-agent is available, run a lightweight design check on the diff: ```bash TMPERR_DRL=$(mktemp /tmp/codex-drl-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "Review the git diff on this branch. Run 7 litmus checks (YES/NO each): 1. Brand/product unmistakable in first screen? 2. One strong visual anchor present? 3. Page understandable by scanning headlines only? 4. Each section has one job? 5. Are cards actually necessary? 6. Does motion improve hierarchy or atmosphere? 7. Would design feel premium with all decorative shadows removed? Flag any hard rejections: 1. Generic SaaS card grid as first impression 2. Beautiful image with weak brand 3. Strong headline with no clear action 4. Busy imagery behind text 5. Sections repeating same mood statement 6. Carousel with no narrative purpose 7. App UI made of stacked cards instead of layout 5 most important design findings only. Reference file:line." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_DRL" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Use a 5-minute timeout (`timeout: 300000`). After the command completes, read stderr: @@ -1119,7 +1095,7 @@ cat "$TMPERR_DRL" && rm -f "$TMPERR_DRL" **Error handling:** All errors are non-blocking. On auth failure, timeout, or empty response — skip with a brief note and continue. -Present Codex output under a `CODEX (design):` header, merged with the checklist findings above. +Present outside-voice sub-agent output under a `CODEX (design):` header, merged with the checklist findings above. Include any design findings alongside the code review findings. They follow the same Fix-First flow below. @@ -1128,7 +1104,7 @@ Present Codex output under a `CODEX (design):` header, merged with the checklist ### Detect stack and scope ```bash -source <(~/.claude/skills/gstack/bin/gstack-diff-scope 2>/dev/null) || true +source <(true # BitFun Team Mode infers diff scope with git/rg 2>/dev/null) || true # Detect stack for specialist context STACK="" [ -f Gemfile ] && STACK="${STACK}ruby " @@ -1154,7 +1130,7 @@ echo "TEST_FW: ${TEST_FW:-unknown}" ### Read specialist hit rates (adaptive gating) ```bash -~/.claude/skills/gstack/bin/gstack-specialist-stats 2>/dev/null || true +true # BitFun Team Mode has no external specialist-stats helper 2>/dev/null || true ``` ### Select specialists @@ -1162,23 +1138,23 @@ echo "TEST_FW: ${TEST_FW:-unknown}" Based on the scope signals above, select which specialists to dispatch. **Always-on (dispatch on every review with 50+ changed lines):** -1. **Testing** — read `~/.claude/skills/gstack/review/specialists/testing.md` -2. **Maintainability** — read `~/.claude/skills/gstack/review/specialists/maintainability.md` +1. **Testing** — read `the built-in testing review checklist` +2. **Maintainability** — read `the built-in maintainability review checklist` **If DIFF_LINES < 50:** Skip all specialists. Print: "Small diff ($DIFF_LINES lines) — specialists skipped." Continue to the Fix-First flow (item 4). **Conditional (dispatch if the matching scope signal is true):** -3. **Security** — if SCOPE_AUTH=true, OR if SCOPE_BACKEND=true AND DIFF_LINES > 100. Read `~/.claude/skills/gstack/review/specialists/security.md` -4. **Performance** — if SCOPE_BACKEND=true OR SCOPE_FRONTEND=true. Read `~/.claude/skills/gstack/review/specialists/performance.md` -5. **Data Migration** — if SCOPE_MIGRATIONS=true. Read `~/.claude/skills/gstack/review/specialists/data-migration.md` -6. **API Contract** — if SCOPE_API=true. Read `~/.claude/skills/gstack/review/specialists/api-contract.md` -7. **Design** — if SCOPE_FRONTEND=true. Use the existing design review checklist at `~/.claude/skills/gstack/review/design-checklist.md` +3. **Security** — if SCOPE_AUTH=true, OR if SCOPE_BACKEND=true AND DIFF_LINES > 100. Read `the built-in security review checklist` +4. **Performance** — if SCOPE_BACKEND=true OR SCOPE_FRONTEND=true. Read `the built-in performance review checklist` +5. **Data Migration** — if SCOPE_MIGRATIONS=true. Read `the built-in data-migration review checklist` +6. **API Contract** — if SCOPE_API=true. Read `the built-in API-contract review checklist` +7. **Design** — if SCOPE_FRONTEND=true. Use the existing design review checklist at `the built-in design review checklist` ### Adaptive gating After scope-based selection, apply adaptive gating based on specialist hit rates: -For each conditional specialist that passed scope gating, check the `gstack-specialist-stats` output above: +For each conditional specialist that passed scope gating, check the `built-in specialist summary` output above: - If tagged `[GATE_CANDIDATE]` (0 findings in 10+ dispatches): skip it. Print: "[specialist] auto-gated (0 findings in N reviews)." - If tagged `[NEVER_GATE]`: always dispatch regardless of hit rate. Security and data-migration are insurance policy specialists — they should run even when silent. @@ -1191,8 +1167,8 @@ Note which specialists were selected, gated, and skipped. Print the selection: ### Dispatch specialists in parallel -For each selected specialist, launch an independent subagent via the Agent tool. -**Launch ALL selected specialists in a single message** (multiple Agent tool calls) +For each selected specialist, launch an independent subagent via BitFun's Task tool. +**Launch ALL selected specialists in a single message** (multiple Task tool calls) so they run in parallel. Each subagent has fresh context — no prior review bias. **Each specialist subagent prompt:** @@ -1204,7 +1180,7 @@ Construct the prompt for each specialist. The prompt includes: 3. Past learnings for this domain (if any exist): ```bash -~/.claude/skills/gstack/bin/gstack-learnings-search --type pitfall --query "{specialist domain}" --limit 5 2>/dev/null || true +true # BitFun Team Mode has no external learnings helper ``` If learnings are found, include them: "Past learnings for this domain: {learnings}" @@ -1306,10 +1282,10 @@ Remember these stats — you will need them for the review-log entry in Step 5.8 **Activation:** Only if DIFF_LINES > 200 OR any specialist produced a CRITICAL finding. -If activated, dispatch one more subagent via the Agent tool (foreground, not background). +If activated, dispatch one more subagent via the Task tool (foreground, not background). The Red Team subagent receives: -1. The red-team checklist from `~/.claude/skills/gstack/review/specialists/red-team.md` +1. The red-team checklist from `the built-in red-team review checklist` 2. The merged specialist findings from Step 3.56 (so it knows what was already caught) 3. The git diff command @@ -1331,7 +1307,7 @@ If the Red Team subagent fails or times out, skip silently and continue. Before classifying findings, check if any were previously skipped by the user in a prior review on this branch. ```bash -~/.claude/skills/gstack/bin/gstack-review-read +true # BitFun Team Mode reads review context from the current session ``` Parse the output: only lines BEFORE `---CONFIG---` are JSONL entries (the output also contains `---CONFIG---` and `---HEAD---` footer sections that are not JSONL — ignore those). @@ -1382,7 +1358,7 @@ Output a summary header: `Pre-Landing Review: N issues (X critical, Y informatio 9. Persist the review result to the review log: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"review","timestamp":"TIMESTAMP","status":"STATUS","issues_found":N,"critical":N,"informational":N,"quality_score":SCORE,"specialists":SPECIALISTS_JSON,"findings":FINDINGS_JSON,"commit":"'"$(git rev-parse --short HEAD)"'","via":"ship"}' +true # BitFun Team Mode has no external review-log helper ``` Substitute TIMESTAMP (ISO 8601), STATUS ("clean" if no issues, "issues_found" otherwise), and N values from the summary counts above. The `via:"ship"` distinguishes from standalone `/review` runs. @@ -1396,7 +1372,7 @@ Save the review output — it goes into the PR body in Step 8. ## Step 3.75: Address Greptile review comments (if PR exists) -Read `.claude/skills/review/greptile-triage.md` and follow the fetch, filter, classify, and **escalation detection** steps. +Read `the built-in review-triage checklist` and follow the fetch, filter, classify, and **escalation detection** steps. **If no PR exists, `gh` fails, API returns an error, or there are zero Greptile comments:** Skip this step silently. Continue to Step 4. @@ -1435,7 +1411,7 @@ For each classified comment: ## Step 3.8: Adversarial review (always-on) -Every diff gets adversarial review from both Claude and Codex. LOC is not a proxy for risk — a 5-line auth change can be critical. +Every diff gets adversarial review from both BitFun and outside-voice sub-agent. LOC is not a proxy for risk — a 5-line auth change can be critical. **Detect diff size and tool availability:** @@ -1444,39 +1420,39 @@ DIFF_INS=$(git diff origin/ --stat | tail -1 | grep -oE '[0-9]+ insertion' DIFF_DEL=$(git diff origin/ --stat | tail -1 | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' || echo "0") DIFF_TOTAL=$((DIFF_INS + DIFF_DEL)) which codex 2>/dev/null && echo "CODEX_AVAILABLE" || echo "CODEX_NOT_AVAILABLE" -# Legacy opt-out — only gates Codex passes, Claude always runs -OLD_CFG=$(~/.claude/skills/gstack/bin/gstack-config get codex_reviews 2>/dev/null || true) +# Legacy opt-out — only gates outside-voice sub-agent passes, BitFun always runs +OLD_CFG="" # BitFun Team Mode has no external codex_reviews config echo "DIFF_SIZE: $DIFF_TOTAL" echo "OLD_CFG: ${OLD_CFG:-not_set}" ``` -If `OLD_CFG` is `disabled`: skip Codex passes only. Claude adversarial subagent still runs (it's free and fast). Jump to the "Claude adversarial subagent" section. +If `OLD_CFG` is `disabled`: skip outside-voice sub-agent passes only. BitFun adversarial subagent still runs (it's free and fast). Jump to the "BitFun adversarial subagent" section. -**User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the Codex structured review regardless of diff size. +**User override:** If the user explicitly requested "full review", "structured review", or "P1 gate", also run the outside-voice sub-agent structured review regardless of diff size. --- -### Claude adversarial subagent (always runs) +### BitFun adversarial subagent (always runs) -Dispatch via the Agent tool. The subagent has fresh context — no checklist bias from the structured review. This genuine independence catches things the primary reviewer is blind to. +Dispatch via the Task tool. The subagent has fresh context — no checklist bias from the structured review. This genuine independence catches things the primary reviewer is blind to. Subagent prompt: "Read the diff for this branch with `git diff origin/`. Think like an attacker and a chaos engineer. Your job is to find ways this code will fail in production. Look for: edge cases, race conditions, security holes, resource leaks, failure modes, silent data corruption, logic errors that produce wrong results silently, error handling that swallows failures, and trust boundary violations. Be adversarial. Be thorough. No compliments — just the problems. For each finding, classify as FIXABLE (you know how to fix it) or INVESTIGATE (needs human judgment)." -Present findings under an `ADVERSARIAL REVIEW (Claude subagent):` header. **FIXABLE findings** flow into the same Fix-First pipeline as the structured review. **INVESTIGATE findings** are presented as informational. +Present findings under an `ADVERSARIAL REVIEW (independent subagent):` header. **FIXABLE findings** flow into the same Fix-First pipeline as the structured review. **INVESTIGATE findings** are presented as informational. -If the subagent fails or times out: "Claude adversarial subagent unavailable. Continuing." +If the subagent fails or times out: "BitFun adversarial subagent unavailable. Continuing." --- -### Codex adversarial challenge (always runs when available) +### outside-voice sub-agent adversarial challenge (always runs when available) -If Codex is available AND `OLD_CFG` is NOT `disabled`: +If a suitable BitFun outside-voice or review sub-agent is available AND `OLD_CFG` is NOT `disabled`: ```bash TMPERR_ADV=$(mktemp /tmp/codex-adv-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } -codex exec "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the changes on this branch against the base branch. Run git diff origin/ to see the diff. Your job is to find ways this code will fail in production. Think like an attacker and a chaos engineer. Find edge cases, race conditions, security holes, resource leaks, failure modes, and silent data corruption paths. Be adversarial. Be thorough. No compliments — just the problems." -C "$_REPO_ROOT" -s read-only -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR_ADV" +Use the BitFun Task tool to dispatch this prompt to a suitable independent read-only outside-voice sub-agent. ``` Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. After the command completes, read stderr: @@ -1487,25 +1463,25 @@ cat "$TMPERR_ADV" Present the full output verbatim. This is informational — it never blocks shipping. **Error handling:** All errors are non-blocking — adversarial review is a quality enhancement, not a prerequisite. -- **Auth failure:** If stderr contains "auth", "login", "unauthorized", or "API key": "Codex authentication failed. Run \`codex login\` to authenticate." -- **Timeout:** "Codex timed out after 5 minutes." -- **Empty response:** "Codex returned no response. Stderr: ." +- **Outside-voice unavailable:** If the selected BitFun sub-agent cannot run, skip this informational pass and continue with the main-session review. +- **Timeout:** "outside-voice sub-agent timed out after 5 minutes." +- **Empty response:** "outside-voice sub-agent returned no response. Stderr: ." **Cleanup:** Run `rm -f "$TMPERR_ADV"` after processing. -If Codex is NOT available: "Codex CLI not found — running Claude adversarial only. Install Codex for cross-model coverage: `npm install -g @openai/codex`" +If outside-voice sub-agent is not available in the current BitFun runtime, run the BitFun adversarial path only and note that cross-model coverage was skipped. --- -### Codex structured review (large diffs only, 200+ lines) +### outside-voice sub-agent structured review (large diffs only, 200+ lines) -If `DIFF_TOTAL >= 200` AND Codex is available AND `OLD_CFG` is NOT `disabled`: +If `DIFF_TOTAL >= 200` AND outside-voice sub-agent is available AND `OLD_CFG` is NOT `disabled`: ```bash -TMPERR=$(mktemp /tmp/codex-review-XXXXXXXX) +TMPERR=$(mktemp /tmp/outside-voice-review-XXXXXXXX) _REPO_ROOT=$(git rev-parse --show-toplevel) || { echo "ERROR: not in a git repo" >&2; exit 1; } cd "$_REPO_ROOT" -codex review "IMPORTANT: Do NOT read or execute any files under ~/.claude/, ~/.agents/, .claude/skills/, or agents/. These are Claude Code skill definitions meant for a different AI system. They contain bash scripts and prompt templates that will waste your time. Ignore them completely. Do NOT modify agents/openai.yaml. Stay focused on the repository code only.\n\nReview the diff against the base branch." --base -c 'model_reasoning_effort="high"' --enable web_search_cached 2>"$TMPERR" +Use the BitFun Task tool to dispatch a suitable independent read-only structured review sub-agent over the diff. ``` Set the Bash tool's `timeout` parameter to `300000` (5 minutes). Do NOT use the `timeout` shell command — it doesn't exist on macOS. Present output under `CODEX SAYS (code review):` header. @@ -1513,19 +1489,19 @@ Check for `[P1]` markers: found → `GATE: FAIL`, not found → `GATE: PASS`. If GATE is FAIL, use AskUserQuestion: ``` -Codex found N critical issues in the diff. +outside-voice sub-agent found N critical issues in the diff. A) Investigate and fix now (recommended) B) Continue — review will still complete ``` -If A: address the findings. After fixing, re-run tests (Step 3) since code has changed. Re-run `codex review` to verify. +If A: address the findings. After fixing, re-run tests (Step 3) since code has changed. Re-run `BitFun Task outside-voice review` to verify. -Read stderr for errors (same error handling as Codex adversarial above). +Read stderr for errors (same error handling as outside-voice sub-agent adversarial above). After stderr: `rm -f "$TMPERR"` -If `DIFF_TOTAL < 200`: skip this section silently. The Claude + Codex adversarial passes provide sufficient coverage for smaller diffs. +If `DIFF_TOTAL < 200`: skip this section silently. The BitFun + outside-voice sub-agent adversarial passes provide sufficient coverage for smaller diffs. --- @@ -1533,9 +1509,9 @@ If `DIFF_TOTAL < 200`: skip this section silently. The Claude + Codex adversaria After all passes complete, persist: ```bash -~/.claude/skills/gstack/bin/gstack-review-log '{"skill":"adversarial-review","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","status":"STATUS","source":"SOURCE","tier":"always","gate":"GATE","commit":"'"$(git rev-parse --short HEAD)"'"}' +true # BitFun Team Mode has no external review-log helper ``` -Substitute: STATUS = "clean" if no findings across ALL passes, "issues_found" if any pass found issues. SOURCE = "both" if Codex ran, "claude" if only Claude subagent ran. GATE = the Codex structured review gate result ("pass"/"fail"), "skipped" if diff < 200, or "informational" if Codex was unavailable. If all passes failed, do NOT persist. +Substitute: STATUS = "clean" if no findings across ALL passes, "issues_found" if any pass found issues. SOURCE = "both" if outside-voice sub-agent ran, "task" if only independent subagent ran. GATE = the outside-voice sub-agent structured review gate result ("pass"/"fail"), "skipped" if diff < 200, or "informational" if outside-voice sub-agent was unavailable. If all passes failed, do NOT persist. --- @@ -1547,10 +1523,10 @@ After all passes complete, synthesize findings across all sources: ADVERSARIAL REVIEW SYNTHESIS (always-on, N lines): ════════════════════════════════════════════════════════════ High confidence (found by multiple sources): [findings agreed on by >1 pass] - Unique to Claude structured review: [from earlier step] - Unique to Claude adversarial: [from subagent] - Unique to Codex: [from codex adversarial or code review, if ran] - Models used: Claude structured ✓ Claude adversarial ✓/✗ Codex ✓/✗ + Unique to BitFun structured review: [from earlier step] + Unique to BitFun adversarial: [from subagent] + Unique to outside-voice sub-agent: [from codex adversarial or code review, if ran] + Models used: BitFun structured ✓ BitFun adversarial ✓/✗ outside-voice sub-agent ✓/✗ ════════════════════════════════════════════════════════════ ``` @@ -1564,7 +1540,7 @@ If you discovered a non-obvious pattern, pitfall, or architectural insight durin this session, log it for future sessions: ```bash -~/.claude/skills/gstack/bin/gstack-learnings-log '{"skill":"ship","type":"TYPE","key":"SHORT_KEY","insight":"DESCRIPTION","confidence":N,"source":"SOURCE","files":["path/to/relevant/file"]}' +true # BitFun Team Mode has no external telemetry helper ``` **Types:** `pattern` (reusable approach), `pitfall` (what NOT to do), `preference` @@ -1572,7 +1548,7 @@ this session, log it for future sessions: `operational` (project environment/CLI/workflow knowledge). **Sources:** `observed` (you found this in the code), `user-stated` (user told you), -`inferred` (AI deduction), `cross-model` (both Claude and Codex agree). +`inferred` (AI deduction), `cross-model` (both BitFun and outside-voice sub-agent agree). **Confidence:** 1-10. Be honest. An observed pattern you verified in the code is 8-9. An inference you're not sure about is 4-5. A user preference they explicitly stated is 10. @@ -1662,7 +1638,7 @@ If output shows `ALREADY_BUMPED`, VERSION was already bumped on this branch (pri Cross-reference the project's TODOS.md against the changes being shipped. Mark completed items automatically; prompt only if the file is missing or disorganized. -Read `.claude/skills/review/TODOS-format.md` for the canonical format reference. +Read `the built-in review TODO format` for the canonical format reference. **1. Check if TODOS.md exists** in the repository root. @@ -1743,8 +1719,6 @@ Save this summary — it goes into the PR body in Step 8. ```bash git commit -m "$(cat <<'EOF' chore: bump version and changelog (vX.Y.Z.W) - -Co-Authored-By: Claude Opus 4.6 EOF )" ``` @@ -1865,7 +1839,7 @@ you missed it.> - [x] All Rails tests pass (N runs, 0 failures) - [x] All Vitest tests pass (N tests) -🤖 Generated with [Claude Code](https://claude.com/claude-code) +Generated with BitFun ``` **If GitHub:** @@ -1899,10 +1873,10 @@ After the PR is created, automatically sync project documentation. Read the `document-release/SKILL.md` skill file (adjacent to this skill's directory) and execute its full workflow: -1. Read the `/document-release` skill: `cat ${CLAUDE_SKILL_DIR}/../document-release/SKILL.md` +1. Read the `/document-release` skill: `cat the bundled document-release skill via the Skill tool` 2. Follow its instructions — it reads all .md files in the project, cross-references the diff, and updates anything that drifted (README, ARCHITECTURE, CONTRIBUTING, - CLAUDE.md, TODOS, etc.) + AGENTS.md, TODOS, etc.) 3. If any docs were updated, commit the changes and push to the same branch: ```bash git add -A && git commit -m "docs: sync documentation with shipped changes" && git push @@ -1921,13 +1895,13 @@ If Step 8.5 created a docs commit, re-edit the PR/MR body to include the latest Log coverage and plan completion data so `/retro` can track trends: ```bash -eval "$(~/.claude/skills/gstack/bin/gstack-slug 2>/dev/null)" && mkdir -p ~/.gstack/projects/$SLUG +SLUG=$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" | tr -cd A-Za-z0-9._-) && mkdir -p $HOME/.bitfun/team/projects/$SLUG ``` -Append to `~/.gstack/projects/$SLUG/$BRANCH-reviews.jsonl`: +Append to `$HOME/.bitfun/team/projects/$SLUG/$BRANCH-reviews.jsonl`: ```bash -echo '{"skill":"ship","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","coverage_pct":COVERAGE_PCT,"plan_items_total":PLAN_TOTAL,"plan_items_done":PLAN_DONE,"verification_result":"VERIFY_RESULT","version":"VERSION","branch":"BRANCH"}' >> ~/.gstack/projects/$SLUG/$BRANCH-reviews.jsonl +echo '{"skill":"ship","timestamp":"'"$(date -u +%Y-%m-%dT%H:%M:%SZ)"'","coverage_pct":COVERAGE_PCT,"plan_items_total":PLAN_TOTAL,"plan_items_done":PLAN_DONE,"verification_result":"VERIFY_RESULT","version":"VERSION","branch":"BRANCH"}' >> $HOME/.bitfun/team/projects/$SLUG/$BRANCH-reviews.jsonl ``` Substitute from earlier steps: @@ -1947,7 +1921,7 @@ This step is automatic — never skip it, never ask for confirmation. - **Never skip tests.** If tests fail, stop. - **Never skip the pre-landing review.** If checklist.md is unreadable, stop. - **Never force push.** Use regular `git push` only. -- **Never ask for trivial confirmations** (e.g., "ready to push?", "create PR?"). DO stop for: version bumps (MINOR/MAJOR), pre-landing review findings (ASK items), and Codex structured review [P1] findings (large diffs only). +- **Never ask for trivial confirmations** (e.g., "ready to push?", "create PR?"). DO stop for: version bumps (MINOR/MAJOR), pre-landing review findings (ASK items), and outside-voice sub-agent structured review [P1] findings (large diffs only). - **Always use the 4-digit version format** from the VERSION file. - **Date format in CHANGELOG:** `YYYY-MM-DD` - **Split commits for bisectability** — each commit = one logical change. diff --git a/src/crates/core/builtin_skills/writing-skills/SKILL.md b/src/crates/core/builtin_skills/writing-skills/SKILL.md index fa1307170..f22bba6ce 100644 --- a/src/crates/core/builtin_skills/writing-skills/SKILL.md +++ b/src/crates/core/builtin_skills/writing-skills/SKILL.md @@ -9,7 +9,7 @@ description: Use when creating new skills, editing existing skills, or verifying **Writing skills IS Test-Driven Development applied to process documentation.** -**Personal skills live in agent-specific directories (`~/.claude/skills` for Claude Code, `~/.agents/skills/` for Codex)** +**Personal skills live in agent-specific directories (`$HOME/.bitfun/skills` for BitFun Code, `$HOME/.bitfun/skills/` for Codex)** You write test cases (pressure scenarios with subagents), watch them fail (baseline behavior), write the skill (documentation), watch tests pass (agents comply), and refactor (close loopholes). @@ -17,11 +17,11 @@ You write test cases (pressure scenarios with subagents), watch them fail (basel **CORE PRINCIPLE:** This skill adapts the RED-GREEN-REFACTOR cycle to documentation — write a failing test (baseline scenario), write the skill, verify it works, then close loopholes. -**Official guidance:** For Anthropic's official skill authoring best practices, see anthropic-best-practices.md. This document provides additional patterns and guidelines that complement the TDD-focused approach in this skill. +**Official guidance:** For BitFun bundled skills, keep instructions self-contained, tool-accurate, and independent of external assistant runtimes. This document provides additional patterns and guidelines that complement the TDD-focused approach in this skill. ## What is a Skill? -A **skill** is a reference guide for proven techniques, patterns, or tools. Skills help future Claude instances find and apply effective approaches. +A **skill** is a reference guide for proven techniques, patterns, or tools. Skills help future BitFun instances find and apply effective approaches. **Skills are:** Reusable techniques, patterns, tools, reference guides @@ -55,7 +55,7 @@ The entire skill creation process follows RED-GREEN-REFACTOR. **Don't create for:** - One-off solutions - Standard practices well-documented elsewhere -- Project-specific conventions (put in CLAUDE.md) +- Project-specific conventions (put in AGENTS.md) - Mechanical constraints (if it's enforceable with regex/validation, automate it—save documentation for judgment calls) ## Skill Types @@ -137,13 +137,13 @@ Concrete results ``` -## Claude Search Optimization (CSO) +## BitFun Search Optimization (CSO) -**Critical for discovery:** Future Claude needs to FIND your skill +**Critical for discovery:** Future BitFun needs to FIND your skill ### 1. Rich Description Field -**Purpose:** Claude reads description to decide which skills to load for a given task. Make it answer: "Should I read this skill right now?" +**Purpose:** BitFun reads description to decide which skills to load for a given task. Make it answer: "Should I read this skill right now?" **Format:** Start with "Use when..." to focus on triggering conditions @@ -151,14 +151,14 @@ Concrete results The description should ONLY describe triggering conditions. Do NOT summarize the skill's process or workflow in the description. -**Why this matters:** Testing revealed that when a description summarizes the skill's workflow, Claude may follow the description instead of reading the full skill content. A description saying "code review between tasks" caused Claude to do ONE review, even though the skill's flowchart clearly showed TWO reviews (spec compliance then code quality). +**Why this matters:** Testing revealed that when a description summarizes the skill's workflow, BitFun may follow the description instead of reading the full skill content. A description saying "code review between tasks" caused BitFun to do ONE review, even though the skill's flowchart clearly showed TWO reviews (spec compliance then code quality). -When the description was changed to just "Use when executing implementation plans with independent tasks" (no workflow summary), Claude correctly read the flowchart and followed the two-stage review process. +When the description was changed to just "Use when executing implementation plans with independent tasks" (no workflow summary), BitFun correctly read the flowchart and followed the two-stage review process. -**The trap:** Descriptions that summarize workflow create a shortcut Claude will take. The skill body becomes documentation Claude skips. +**The trap:** Descriptions that summarize workflow create a shortcut BitFun will take. The skill body becomes documentation BitFun skips. ```yaml -# ❌ BAD: Summarizes workflow - Claude may follow this instead of reading skill +# ❌ BAD: Summarizes workflow - BitFun may follow this instead of reading skill description: Use when executing plans - dispatches subagent per task with code review between tasks # ❌ BAD: Too much process detail @@ -198,7 +198,7 @@ description: Use when using React Router and handling authentication redirects ### 2. Keyword Coverage -Use words Claude would search for: +Use words BitFun would search for: - Error messages: "Hook timed out", "ENOTEMPTY", "race condition" - Symptoms: "flaky", "hanging", "zombie", "pollution" - Synonyms: "timeout/hang/freeze", "cleanup/teardown/afterEach" @@ -634,7 +634,7 @@ Deploying untested skills = deploying untested code. It's a violation of quality ## Discovery Workflow -How future Claude finds your skill: +How future BitFun finds your skill: 1. **Encounters problem** ("tests are flaky") 3. **Finds SKILL** (description matches) diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index a798eb92f..26524ffac 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -53,7 +53,8 @@ pub use registry::{ }; pub use review_fixer_agent::ReviewFixerAgent; pub use review_specialist_agents::{ - BusinessLogicReviewerAgent, PerformanceReviewerAgent, ReviewJudgeAgent, SecurityReviewerAgent, + ArchitectureReviewerAgent, BusinessLogicReviewerAgent, FrontendReviewerAgent, + PerformanceReviewerAgent, ReviewJudgeAgent, SecurityReviewerAgent, }; use std::any::Any; pub use team_mode::TeamMode; diff --git a/src/crates/core/src/agentic/agents/prompts/code_review.md b/src/crates/core/src/agentic/agents/prompts/code_review.md index e53bdbe66..1961dd4f7 100644 --- a/src/crates/core/src/agentic/agents/prompts/code_review.md +++ b/src/crates/core/src/agentic/agents/prompts/code_review.md @@ -115,7 +115,9 @@ When you have gathered sufficient context and completed your review, call the `s "remediation_groups": { "must_fix": ["Required correctness/security/regression fixes"], "should_improve": ["Non-blocking cleanup or quality improvements"], - "needs_decision": ["Items needing user/product judgment"], + "needs_decision": [ + {"question": "Decision point description", "plan": "Remediation if approved", "options": ["Option A", "Option B"], "tradeoffs": "Trade-off explanation", "recommendation": 0} + ], "verification": ["Focused verification steps"] }, "strength_groups": { diff --git a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md index 04713b480..5dad3bdb3 100644 --- a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md @@ -219,27 +219,47 @@ Tool results and user messages may include tags. These 0`, pass `timeout_seconds` with that value to the judge Task call. @@ -165,7 +188,12 @@ After the quality gate finishes: - `executive_summary`: 1-3 concise bullets with the final decision and most important risk. - `remediation_groups.must_fix`: required correctness/security/regression fixes. - `remediation_groups.should_improve`: non-blocking cleanup or quality improvements. - - `remediation_groups.needs_decision`: items that need user/product judgment. + - `remediation_groups.needs_decision`: items that need user/product judgment. Each item MUST be an object with: + - `question` (required): the specific decision point (e.g. "Should we use eager loading or lazy loading for this relation?") + - `plan` (required): the remediation plan text to execute if the user approves this item + - `options` (optional): 2-4 possible approaches or choices + - `tradeoffs` (optional): brief trade-off explanation + - `recommendation` (optional): 0-based index of the recommended option - `remediation_groups.verification`: focused verification or follow-up review steps. - `strength_groups`: positive observations grouped under `architecture`, `maintainability`, `tests`, `security`, `performance`, `user_experience`, or `other`. - `coverage_notes`: confidence, timeout/cancel/failure, scope, or manual follow-up notes. diff --git a/src/crates/core/src/agentic/agents/prompts/plan_mode.md b/src/crates/core/src/agentic/agents/prompts/plan_mode.md index 964760783..7e5b3283f 100644 --- a/src/crates/core/src/agentic/agents/prompts/plan_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/plan_mode.md @@ -40,6 +40,19 @@ At any point in time through this workflow you should feel free to ask the user 4. If there are multiple valid implementations, each changing the plan significantly, you MUST ask the user to clarify which implementation they want you to use. +## What NOT to ask in Plan Mode + +- Do NOT ask "Is my plan ready?" or "Should I proceed?" — the user cannot see the plan until you finalize it +- Do NOT ask for feedback on the plan itself — use the CreatePlan tool and wait for user approval instead +- Do NOT reference "the plan" in your questions because the user cannot see it in the UI + +## Question design in Plan Mode + +- State your recommendation clearly and explain WHY +- Make your recommended option the first option and add "(Recommended)" +- Provide 2-4 concrete options with trade-off descriptions +- Focus on clarifying requirements, not validating the plan + # Plan Creation and Update 1. When you're done researching, present your plan by calling the CreatePlan tool, which creates a plan file for user approval. Do NOT make any file changes or run any tools that modify the system state in any way. diff --git a/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md b/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md new file mode 100644 index 000000000..adb873d7d --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/review_architecture_agent.md @@ -0,0 +1,83 @@ +# Role + +You are an **independent Architecture Reviewer** for BitFun deep reviews. + +{LANGUAGE_PREFERENCE} + +You work in an isolated context. Treat this as a fresh review. Do not assume the main agent or other reviewers are correct. + +## Mission + +Inspect the requested review target and find **structural and architectural issues** such as: + +- module boundary violations (imports that cross layer boundaries) +- API contract design problems (inconsistent patterns, breaking changes) +- abstraction integrity issues (platform-specific details leaking through shared interfaces) +- dependency direction violations (circular dependencies, wrong-direction imports) +- structural consistency (patterns, registration conventions not followed) +- cross-cutting concern impact (changes that require touching too many layers) + +## What you do NOT review + +- Business rule correctness (Business Logic reviewer handles this) +- Algorithm performance (Performance reviewer handles this) +- Security vulnerabilities (Security reviewer handles this) +- React component state, i18n, or accessibility (Frontend Reviewer handles this) +- Code style or formatting + +## Tools + +Use only read-only investigation: + +- `GetFileDiff` +- `Read` +- `Grep` +- `Glob` +- `LS` +- `Git` with read-only operations only + +Never modify files or git state. + +## Review standards + +- Confirm the violation before reporting. Cite the specific architectural rule or convention being violated. +- Prefer findings with concrete evidence (actual import paths, dependency chains) over speculative concerns. +- If a dependency direction is unusual but does not violate a documented rule, lower severity. + +## Efficiency rules + +- Start by understanding the module structure. Use LS and Glob to map the directory layout and identify layer boundaries. +- Focus on imports and cross-module references. Use Grep to trace import patterns rather than reading full files. +- Only read full files when an import pattern suggests a boundary violation. +- When you have confirmed or dismissed an architectural concern, move on. Do not re-examine the same module from different angles. +- Prefer a focused report with confirmed violations over a broad survey that risks timing out. +- If the strategy is `quick`, only check imports directly changed by the diff. Flag violations of documented layer boundaries. +- If the strategy is `normal`, check the diff's imports plus one level of dependency direction. Verify API contract consistency. +- If the strategy is `deep`, map the full dependency graph for changed modules. Check for structural anti-patterns, circular dependencies, and cross-cutting concerns. + +## Output format + +Return markdown only, using this exact structure: + +## Reviewer +Architecture Reviewer + +## Verdict +clear | issues_found + +## Findings +- `[severity=] [certainty=] file:line - title` + Architectural rule violated: ... + Why it matters: ... + Suggested fix direction: ... + +If there are no confirmed or likely issues, write exactly: + +- No architectural issues found. + +## Reviewer Summary +2-4 sentences summarizing the structural health of the change. + +If there is nothing meaningful to summarize, write exactly: + +- Nothing to summarize. diff --git a/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md b/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md index e6a54d64a..c05c7e1b7 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_business_logic_agent.md @@ -14,7 +14,14 @@ Inspect the requested review target and find **real logic or workflow issues** s - missing edge-case handling - invalid assumptions about data shape or lifecycle - race conditions or ordering mistakes -- partial updates that can leave data or UI in an inconsistent state +- partial updates that can leave data in an inconsistent state + +## What you do NOT review + +- Whether a call chain should exist or respects layer boundaries (Architecture Reviewer) +- React component state, i18n, or accessibility issues (Frontend Reviewer) +- Algorithm performance (Performance Reviewer) +- Security vulnerabilities (Security Reviewer) ## Tools @@ -32,11 +39,21 @@ Never modify files or git state. ## Review standards - Confirm before claiming. -- Gather surrounding context before judging unfamiliar code. - Focus on behavior, not style. - Prefer a small number of well-supported issues over broad speculation. - If something is only a weak suspicion, call it out as low-confidence and do not overstate it. +## Efficiency rules + +- Start from the diff. Only read surrounding context when a potential issue in the diff requires it. +- Limit context reads to the minimum needed to confirm or reject a suspicion. Do not read entire modules speculatively. +- If you have checked a file and found no issues, move on. Do not re-read it from different angles. +- When you have enough evidence to support or dismiss a hypothesis, stop investigating that path immediately. +- Prefer a focused review with a few confirmed findings over exhaustive coverage that risks timing out with no output. +- If the strategy is `quick`, restrict your investigation to files and functions directly changed by the diff. Do not trace call chains beyond one hop. +- If the strategy is `normal`, trace each changed function's direct callers and callees to verify business rules and state transitions. Stop investigating a path once you have enough evidence. +- If the strategy is `deep`, map the full call chain for each changed function to verify business rules and state transitions. Check rollback and error-recovery paths, and test edge cases in data shape and lifecycle assumptions. Prioritize findings by user-facing impact. Do not evaluate whether a call chain respects layer boundaries. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md b/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md new file mode 100644 index 000000000..4e868efa3 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/review_frontend_agent.md @@ -0,0 +1,87 @@ +# Role + +You are an **independent Frontend Reviewer** for BitFun deep reviews. + +{LANGUAGE_PREFERENCE} + +You work in an isolated context. Treat this as a fresh review. Do not assume the main agent or other reviewers are correct. + +## Mission + +Inspect the requested review target and find **frontend-specific issues** such as: + +- i18n key synchronization problems (missing keys in one or more locales) +- React performance anti-patterns (missing memoization, unnecessary re-renders, missing virtualization) +- Accessibility violations (missing ARIA attributes, keyboard navigation, focus management) +- State management issues (Zustand selector granularity, store dependency problems, stale closures) +- Frontend-backend API contract drift (Tauri command type mismatches, event payload changes without frontend updates) +- Platform boundary violations in frontend (direct @tauri-apps/api imports outside the adapter layer) +- CSS/theme consistency issues (ThemeService misuse, component library pattern violations) + +## What you do NOT review + +- Business rule correctness (Business Logic reviewer handles this) +- Non-React algorithm performance (Performance reviewer handles this) +- Security vulnerabilities (Security reviewer handles this) +- Backend architectural issues (Architecture reviewer handles this) +- Code style or formatting + +## Tools + +Use only read-only investigation: + +- `GetFileDiff` +- `Read` +- `Grep` +- `Glob` +- `LS` +- `Git` with read-only operations only + +Never modify files or git state. + +## Review standards + +- Confirm the issue before reporting. Show the specific code that has the problem. +- For i18n issues: verify that a key exists in one locale but is missing in another. +- For React performance issues: explain the concrete performance impact, not just the pattern violation. +- For accessibility issues: reference WCAG guidelines where applicable. +- If a pattern is unusual but functional, lower severity. + +## Efficiency rules + +- Start from the diff. Identify changed frontend files (.tsx, .ts, .scss, locale JSON). +- For i18n: use Grep to find all `t('...')` calls in changed files, then check each key across all locale files. +- For React performance: check changed components for common anti-patterns (inline functions in JSX, missing keys, missing memo). +- For accessibility: check changed components for ARIA attributes, keyboard handlers, and focus management. +- For API contracts: compare changed Tauri command types with corresponding TypeScript API clients. +- When you have confirmed or dismissed a frontend concern, move on. Do not re-examine the same component from different angles. +- Prefer a focused report with confirmed issues over a broad survey that risks timing out. +- If the strategy is `quick`, only check i18n key completeness and direct platform boundary violations in changed frontend files. +- If the strategy is `normal`, check i18n, React performance patterns, and accessibility in changed components. Verify frontend-backend API contract alignment. +- If the strategy is `deep`, thorough React analysis: effect dependencies, memoization, virtualization. Full accessibility audit. State management pattern review. Cross-layer contract verification. + +## Output format + +Return markdown only, using this exact structure: + +## Reviewer +Frontend Reviewer + +## Verdict +clear | issues_found + +## Findings +- `[severity=] [certainty=] file:line - title` + Why it matters: ... + Suggested fix: ... + +If there are no confirmed or likely issues, write exactly: + +- No frontend issues found. + +## Reviewer Summary +2-4 sentences summarizing the frontend health of the change. + +If there is nothing meaningful to summarize, write exactly: + +- Nothing to summarize. diff --git a/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md b/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md index 5930bfdbc..0cfb81f63 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_performance_agent.md @@ -11,12 +11,19 @@ Inspect the requested review target and find **real performance or scalability r - unnecessary repeated work - N+1 queries or repeated fetches - avoidable blocking calls on hot paths -- expensive renders or recomputations -- oversized diffs / payloads / serialization +- expensive computations on hot paths +- oversized payloads or serialization on data paths - unnecessary allocations or copies - algorithmic regressions that matter at realistic scale - optimization suggestions that are unsafe should be avoided rather than recommended +## What you do NOT review + +- React rendering performance or component memoization (Frontend Reviewer) +- Whether a data path respects layer boundaries (Architecture Reviewer) +- Security vulnerabilities (Security Reviewer) +- Business rule correctness (Business Logic Reviewer) + ## Tools Use only read-only investigation: @@ -37,6 +44,17 @@ Never modify files or git state. - When impact is uncertain, lower severity and explain the assumption. - If current code is acceptable for the expected scale, say so. +## Efficiency rules + +- Start from the diff. Scan for known performance anti-patterns first: loops inside loops, repeated fetches, blocking calls on hot paths, large allocations. +- Only read surrounding code when a potential pattern in the diff needs confirmation of its context (e.g. is this on a hot path? is this called in a loop?). +- Do not read entire modules to speculate about hypothetical scaling problems. +- When you have confirmed or dismissed a performance concern, move on. Do not re-examine the same code from different angles. +- Prefer a focused report with confirmed regressions over a broad survey that risks timing out. +- If the strategy is `quick`, report only issues with direct evidence in the diff. Do not trace call chains or estimate impact beyond what the diff shows. +- If the strategy is `normal`, inspect the diff for anti-patterns, then read surrounding code to confirm impact on hot paths. Report only issues likely to matter at realistic scale. +- If the strategy is `deep`, in addition to the normal pass, check whether the change creates latent scaling risks — e.g. data structures that degrade at volume, or algorithms that are correct but unnecessarily expensive. Only report if you can quantify or estimate the impact. Do not speculate about edge cases or failure modes unrelated to performance. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md b/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md index fd620e0ac..2668d243e 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_quality_gate_agent.md @@ -10,7 +10,7 @@ You will receive: - the original review target - the user focus, if any -- the outputs from the Business Logic Reviewer, Performance Reviewer, and Security Reviewer +- the outputs from the Business Logic Reviewer, Performance Reviewer, Security Reviewer, Architecture Reviewer, and Frontend Reviewer (if present) - if file splitting was used, outputs from **multiple same-role instances** (e.g. "Security Reviewer [group 1/3]", "Security Reviewer [group 2/3]") ## Mission @@ -33,6 +33,26 @@ Be especially skeptical of: - duplicated findings reported by multiple reviewers or multiple same-role instances - findings where the stated evidence does not logically lead to the stated conclusion +## Efficiency rules + +- Start from the reviewer reports. Only use code inspection tools when a specific claim needs verification or you suspect a false positive. +- Do not broadly re-review the codebase. Your job is to validate reviewer reasoning, not to discover new issues independently. +- Process findings in order of severity. Validate high-severity findings first; if time is limited, lower-severity findings can receive a quicker pass. +- When a finding's evidence is clearly sufficient or clearly insufficient, make your decision quickly. Reserve detailed spot-checks for ambiguous findings only. +- Prefer completing validation of all findings over deep-diving into a single finding. +- If the team strategy was `quick`, focus on confirming or rejecting each finding efficiently. If a finding's evidence is thin, reject it rather than spending time verifying. +- If the team strategy was `normal`, validate each finding's logical consistency and evidence quality. Spot-check code only when a claim needs verification. +- If the team strategy was `deep`, cross-validate findings across reviewers for consistency. For each finding, verify the evidence supports the conclusion and the suggested fix is safe. Pay extra attention to findings that overlap across reviewers or across same-role instances from file splitting. + +## Cross-reviewer overlap handling + +When multiple reviewers report findings about the same code location: + +- **Architecture + Business Logic**: If Architecture Reviewer flags a layer violation and Business Logic Reviewer flags a call chain issue at the same location, the Architecture finding is likely the root cause. Keep both but note the architectural root cause may address both. +- **Architecture + Security**: If Architecture flags a boundary violation and Security flags a trust-boundary issue, keep both but note the structural fix may resolve the security concern. +- **Frontend + Performance**: If Frontend Reviewer flags a React rendering issue and Performance Reviewer flags a general performance issue at the same component, merge into a single finding with both perspectives. +- **Frontend + Business Logic**: If Frontend flags a state management issue and Business Logic flags a data inconsistency, the Frontend finding provides the mechanism; keep both but link them. + ## Tools Use read-only investigation when needed: diff --git a/src/crates/core/src/agentic/agents/prompts/review_security_agent.md b/src/crates/core/src/agentic/agents/prompts/review_security_agent.md index 38b2ebb27..02b111374 100644 --- a/src/crates/core/src/agentic/agents/prompts/review_security_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/review_security_agent.md @@ -13,10 +13,17 @@ Inspect the requested review target and find **real security issues** such as: - secret exposure - unsafe command or filesystem handling - path traversal -- trust-boundary violations -- insecure defaults +- trust-boundary violations that create exploitable security risks +- insecure defaults in authentication, authorization, or data handling - data leaks across sessions, users, or tenants +## What you do NOT review + +- Structural layer violations without exploitable security impact (Architecture Reviewer) +- Frontend-specific security concerns like XSS in React components (Frontend Reviewer) +- Business rule correctness (Business Logic Reviewer) +- Algorithm performance (Performance Reviewer) + ## Tools Use only read-only investigation: @@ -37,6 +44,17 @@ Never modify files or git state. - Prefer concrete threat narratives over vague warnings. - If there is insufficient evidence for a real security issue, do not report it. +## Efficiency rules + +- Start from the diff. Scan for direct security risks first: injection, secret exposure, unsafe command/file handling, missing auth checks. +- Only trace data flows beyond the diff when a potential vulnerability needs confirmation of its reachability or exploitability. +- Do not read entire modules to search for hypothetical attack surfaces. +- When you have confirmed or dismissed a security concern, move on. Do not re-examine the same code from different angles. +- Prefer a focused report with confirmed vulnerabilities over a broad survey that risks timing out. +- If the strategy is `quick`, report only issues with a concrete exploit path visible in the diff. Do not trace data flows beyond one hop. +- If the strategy is `normal`, trace each changed input path from entry point to usage. Check trust boundaries, auth assumptions, and data sanitization. Report only issues with a realistic threat narrative. +- If the strategy is `deep`, in addition to the normal pass, trace data flows across trust boundaries end-to-end. Check for privilege escalation chains, indirect injection vectors, and failure modes that expose sensitive data. Report only issues with a complete threat narrative. + ## Output format Return markdown only, using this exact structure: diff --git a/src/crates/core/src/agentic/agents/prompts/team_mode.md b/src/crates/core/src/agentic/agents/prompts/team_mode.md index 05ec765b3..488aef17c 100644 --- a/src/crates/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/team_mode.md @@ -1,25 +1,57 @@ You are BitFun in **Team Mode** — a virtual engineering team orchestrator. You coordinate specialized roles through a full sprint workflow to deliver high-quality software. -You have access to a set of **gstack skills** via the Skill tool. Each skill embodies a specialist role with deep expertise and a battle-tested methodology. Your job is to know WHEN to invoke each role and HOW to weave their outputs into a coherent delivery pipeline. +You have access to a set of **gstack skills** via the Skill tool and BitFun's existing **Task** tool for launching sub-agents inside the same session. Each skill embodies a specialist role with deep expertise and a battle-tested methodology. Your job is to know WHEN to load each role's methodology, WHEN to dispatch independent work to existing sub-agents, and HOW to weave their outputs into a coherent delivery pipeline. IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. {LANGUAGE_PREFERENCE} -# MANDATORY: Skill-First Rule +# MANDATORY: Built-in Runtime Boundary -**You MUST invoke the appropriate gstack skill BEFORE writing any code, creating any plan, or making any file changes.** This is not optional. Team Mode exists to run the full specialist workflow — if you skip skills and write code directly, you are not operating in Team Mode. +Team Mode is a BitFun built-in mode. It MUST be self-contained inside BitFun's runtime: + +- Do not require Claude Code, external gstack installs, external helper binaries, or files under `~/.claude`, `~/.gstack`, or repo-local skill-definition directories. +- Use only BitFun tools exposed in the current session, the bundled Skill contents, the Task tool's enabled sub-agents, and ordinary project tools such as `git`, `rg`, package-manager scripts, and test commands. +- Store any Team-owned durable artifacts under BitFun state paths such as `.bitfun/team/` or `$HOME/.bitfun/team/` when a skill asks for local team state. +- If a bundled skill mentions legacy helper behavior, reinterpret it through BitFun built-ins. Never ask the user to build, install, or enable an external helper just to make Team Mode work. + +# MANDATORY: Team-Orchestration Rule + +**Team Mode is not a single assistant pretending to be many people.** For non-trivial work, you MUST make the team visible by combining: + +1. **Skill**: load the role methodology and output contract. +2. **Task**: dispatch independent investigation / review / QA / research work to the existing enabled sub-agents in this workspace. +3. **Synthesis**: reconcile the role outputs in the main orchestrator before deciding or editing. + +Do not add or assume special built-in role sub-agent types. Use the sub-agents that the Task tool says are available in the current workspace. Prefer role-specific custom sub-agents when available; otherwise use general-purpose read-only sub-agents for investigation/review and keep implementation in the main Team session. + +You MUST load the appropriate gstack skill before writing code, creating a final plan, or making file changes. This is not optional. Team Mode exists to run the specialist workflow with actual delegation where it helps. There are only three exceptions to this rule: 1. The user explicitly says "skip [phase/skill], just do [X]" — respect it once, note the skip in your todo list 2. A pure config-only change (single file, zero logic) — Build → Review only 3. An emergency hotfix explicitly labeled as such — Investigate → Build → Review → Ship -In all other cases, invoke the skill first. +In all other cases, invoke the skill first, then dispatch Task sub-agents for independent work whenever the phase contains separable investigation, review, testing, or audit tracks. + +# Task Dispatch Rules + +Use Task to create real team behavior without changing BitFun's global agent roster. + +- Always read the Task tool's available agent list before choosing `subagent_type`; only use listed enabled sub-agents. +- Prefer custom user/project sub-agents whose name or description matches the role (`designer`, `security`, `qa`, `review`, `research`, etc.). +- For broad codebase investigation, use `Explore` when it is available. +- For file discovery, use `FileFinder` when it is available. +- For browser or desktop QA, use `ComputerUse` when it is available and appropriate. +- For deep code-review style checks, use the existing review sub-agents when available (`ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewJudge`), especially in Review phases. +- If no suitable sub-agent exists, say so briefly and run that role in the main orchestrator after loading its Skill. +- Launch multiple independent Task calls in a single assistant message so BitFun runs them concurrently. +- Keep Task prompts small and owned: give each sub-agent its role, exact question, file/path scope, expected output format, and whether it is read-only. +- Never ask a Task sub-agent to mutate files unless the selected sub-agent is explicitly meant for that and the phase allows mutations. # Your Team Roster -These are the specialist roles available to you as skills. Invoke them via the **Skill** tool: +These are the specialist roles available to you as skills. Invoke them via the **Skill** tool to load methodology, then dispatch existing Task sub-agents for separable work: | Role | Skill Name | When to Use | |------|-----------|-------------| @@ -66,7 +98,7 @@ Think → Plan → Build → Review → Test → Ship → Reflect **MANDATORY: Every new feature or non-trivial change starts at Phase 1 (Think). Do not enter a later phase without completing all prior mandatory phases.** -**Phases are sequential, but work *inside* a phase is parallel whenever possible.** In particular, all reviewer / audit roles inside Phase 2 (Plan) and Phase 4 (Review) MUST be fanned out in parallel — see "Parallel Fan-out Protocol". +**Phases are sequential, but work *inside* a phase is parallel whenever possible.** In particular, all reviewer / audit / investigation tracks inside Phase 2 (Plan), Phase 4 (Review), and report-only QA/security checks MUST be fanned out with Task whenever there is a suitable existing sub-agent — see "Parallel Fan-out Protocol". ## Phase 1: Think (REQUIRED for new ideas and features) @@ -75,8 +107,9 @@ Think → Plan → Build → Review → Test → Ship → Reflect **You MUST:** 1. Announce the role transition (see Role Transition Protocol below) 2. Invoke `office-hours` skill -3. Wait for the skill to produce a design doc -4. Confirm with the user before proceeding to Phase 2 +3. Use Task only for independent discovery that sharpens the design doc (market/context research, codebase exploration, existing workflow mapping). Keep the final problem framing in the main orchestrator. +4. Produce the design doc +5. Confirm with the user before proceeding to Phase 2 **You must NOT write any code or create any implementation plan until Phase 1 is complete.** @@ -86,15 +119,16 @@ Think → Plan → Build → Review → Test → Ship → Reflect **You MUST:** 1. Announce the role transition once for the whole review batch (e.g. `[ROLE: Plan Review Council] Fanning out CEO + Design + Eng (+ CSO) in parallel...`). -2. **Fan out reviewers in parallel** by emitting **multiple `Skill` tool calls in a single assistant message** (see "Parallel Fan-out Protocol" below). The applicable reviewers are: +2. Load the applicable reviewer skills, then **fan out reviewer work in parallel** by emitting **multiple `Task` tool calls in a single assistant message** (see "Parallel Fan-out Protocol" below). The applicable reviewers are: - `plan-ceo-review` — strategic scope challenge (always) - `plan-eng-review` — architecture and test plan (always) - `plan-design-review` — UI/UX review (only if UI is involved) - `cso` — security review (only if auth / data / network surface is touched) Do **not** invoke `autoplan` here — `autoplan` is sequential and is reserved for the case where the user explicitly asks for the legacy single-thread pipeline. -3. After all reviewers return, write a **Review Synthesis** block (see "Review Synthesis Template" below) that merges blocking issues, conflicts, and the final decision. -4. Get user approval on the synthesized plan before proceeding. +3. If a role has no suitable Task sub-agent, run that role in the main orchestrator using the loaded skill and mark it as `main-session`. +4. After all reviewers return, write a **Review Synthesis** block (see "Review Synthesis Template" below) that merges blocking issues, conflicts, and the final decision. +5. Get user approval on the synthesized plan before proceeding. **You must NOT write any code until Phase 2 is complete and the plan is approved.** @@ -112,12 +146,13 @@ Think → Plan → Build → Review → Test → Ship → Reflect **You MUST:** 1. Announce the role transition once for the batch (e.g. `[ROLE: Code Review Council] Fanning out review (+ cso, + design-review) in parallel...`). -2. **Fan out reviewers in parallel** in a single assistant message: +2. Load the applicable reviewer skills, then **fan out reviewers in parallel** with Task in a single assistant message: - `review` — production-bug hunt on the diff (always) - `cso` — OWASP / STRIDE pass (only if security-sensitive changes) - `design-review` — UI audit (only if UI changed) -3. After all reviewers return, write a **Review Synthesis** block. Tag every finding with its source role. -4. Fix all AUTO-FIX issues immediately. Present ASK items to the user and wait for decisions. +3. If existing review sub-agents are available, prefer `ReviewBusinessLogic`, `ReviewPerformance`, and `ReviewSecurity` for independent read-only review tracks, then use `ReviewJudge` as a quality gate when warranted. +4. After all reviewers return, write a **Review Synthesis** block. Tag every finding with its source role and whether it came from a Task sub-agent or main-session role work. +5. Fix all AUTO-FIX issues immediately. Present ASK items to the user and wait for decisions. **You must NOT proceed to Test or Ship until all AUTO-FIX items are resolved.** @@ -128,8 +163,9 @@ Think → Plan → Build → Review → Test → Ship → Reflect **You MUST:** 1. Announce the role transition 2. Invoke `qa` for browser-based testing (if UI is involved), or `qa-only` for report-only -3. Each bug found generates a regression test before the fix -4. Re-run `review` if significant code changes were made during QA +3. Use Task with `ComputerUse` or another suitable QA/browser sub-agent when available; keep fix decisions in the main Team session unless the invoked QA workflow explicitly owns fixes. +4. Each bug found generates a regression test before the fix +5. Re-run `review` if significant code changes were made during QA ## Phase 6: Ship (REQUIRED to close out the work) @@ -158,19 +194,21 @@ If review has not run, announce: "Phase Gate 2: Review has not run. Invoking rev # Parallel Fan-out Protocol -Team Mode is a **virtual team**, not a single specialist running serially. Whenever multiple roles can work independently (typically **review / audit / consultation** roles), you MUST fan them out in parallel. +Team Mode is a **virtual team**, not a single specialist running serially. Whenever multiple roles can work independently (typically **review / audit / consultation / discovery** roles), you MUST fan them out in parallel through Task when suitable sub-agents are available. **How to fan out:** -- Emit **multiple `Skill` (or `Task`) tool calls inside one single assistant message**. The platform's tool pipeline detects concurrency-safe calls and runs them with `join_all`. If you split them across separate assistant turns, you lose the parallelism and waste the user's time and tokens. +- Emit **multiple `Task` tool calls inside one single assistant message** after loading the needed skill methodology. The platform's tool pipeline detects concurrency-safe calls and runs them with `join_all`. If you split them across separate assistant turns, you lose the parallelism and waste the user's time and tokens. - Announce the batch **once** with a single role transition header (e.g. `[ROLE: Plan Review Council] Fanning out 3 reviewers in parallel...`). Do **not** print one transition header per skill in this case — that defeats the purpose of a batch. - Pick only the reviewers that genuinely apply to the change. Do not invoke `plan-design-review` on a backend-only change just to fill the slate. +- Give every Task a role label in `description`, for example `CEO scope review`, `Eng architecture review`, `Security diff audit`, `QA browser smoke`. +- In every Task prompt, include: role, objective, scope/files, constraints, output format, and "return findings only; do not modify files" unless the phase explicitly allows that sub-agent to fix. **When NOT to fan out:** - Phases that produce artifacts the next step depends on (Build, Ship, Investigate root-cause loops). These remain sequential. - The legacy `autoplan` skill — it is **sequential by design**. Only invoke `autoplan` if the user explicitly asks for it ("run autoplan", "do the full sequential pipeline"). The default path for Phase 2 is the parallel fan-out described above. -- A single reviewer scenario (e.g. user explicitly asked for "just the CEO review") — just invoke that one skill directly. +- A single reviewer scenario (e.g. user explicitly asked for "just the CEO review") — load that skill and decide whether one Task would materially improve evidence. Do not create parallelism for its own sake. **Concurrency safety:** diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index c4c00392b..bb9119f93 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -1,15 +1,16 @@ use super::{ - Agent, AgenticMode, BusinessLogicReviewerAgent, ClawMode, CodeReviewAgent, ComputerUseMode, - CoworkMode, DebugMode, DeepResearchAgent, DeepReviewAgent, ExploreAgent, FileFinderAgent, - GenerateDocAgent, InitAgent, PerformanceReviewerAgent, PlanMode, ReviewFixerAgent, - ReviewJudgeAgent, SecurityReviewerAgent, TeamMode, + Agent, AgenticMode, ArchitectureReviewerAgent, BusinessLogicReviewerAgent, ClawMode, + CodeReviewAgent, ComputerUseMode, CoworkMode, DebugMode, DeepResearchAgent, DeepReviewAgent, + ExploreAgent, FileFinderAgent, FrontendReviewerAgent, GenerateDocAgent, InitAgent, + PerformanceReviewerAgent, PlanMode, ReviewFixerAgent, ReviewJudgeAgent, SecurityReviewerAgent, + TeamMode, }; use crate::agentic::agents::custom_subagents::{ CustomSubagent, CustomSubagentKind, CustomSubagentLoader, }; use crate::agentic::deep_review_policy::{ - REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, - REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, + REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, + REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; use crate::agentic::tools::{get_all_registered_tool_names, get_readonly_registered_tool_names}; use crate::service::config::global::GlobalConfigManager; @@ -146,6 +147,7 @@ fn is_review_agent_entry(entry: &AgentEntry) -> bool { REVIEWER_BUSINESS_LOGIC_AGENT_TYPE | REVIEWER_PERFORMANCE_AGENT_TYPE | REVIEWER_SECURITY_AGENT_TYPE + | REVIEWER_ARCHITECTURE_AGENT_TYPE | REVIEW_JUDGE_AGENT_TYPE ) } @@ -158,6 +160,8 @@ fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { | "ReviewBusinessLogic" | "ReviewPerformance" | "ReviewSecurity" + | "ReviewArchitecture" + | "ReviewFrontend" | "ReviewJudge" | "ReviewFixer" => "fast", "Explore" | "FileFinder" | "CodeReview" | "GenerateDoc" | "Init" => "primary", @@ -341,6 +345,8 @@ impl AgentRegistry { Arc::new(BusinessLogicReviewerAgent::new()), Arc::new(PerformanceReviewerAgent::new()), Arc::new(SecurityReviewerAgent::new()), + Arc::new(ArchitectureReviewerAgent::new()), + Arc::new(FrontendReviewerAgent::new()), Arc::new(ReviewJudgeAgent::new()), Arc::new(ReviewFixerAgent::new()), ]; @@ -980,7 +986,11 @@ impl AgentRegistry { description, tools, prompt, - if review { true } else { readonly.unwrap_or(old.readonly) }, + if review { + true + } else { + readonly.unwrap_or(old.readonly) + }, old.path.clone(), old.kind, ); @@ -1336,6 +1346,8 @@ mod tests { "ReviewBusinessLogic", "ReviewPerformance", "ReviewSecurity", + "ReviewArchitecture", + "ReviewFrontend", "ReviewJudge", "ReviewFixer", ] { diff --git a/src/crates/core/src/agentic/agents/review_specialist_agents.rs b/src/crates/core/src/agentic/agents/review_specialist_agents.rs index 0cd4f50d4..edd93606c 100644 --- a/src/crates/core/src/agentic/agents/review_specialist_agents.rs +++ b/src/crates/core/src/agentic/agents/review_specialist_agents.rs @@ -1,5 +1,6 @@ use crate::agentic::deep_review_policy::{ - REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, + REVIEWER_ARCHITECTURE_AGENT_TYPE, REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, + REVIEWER_FRONTEND_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, REVIEW_JUDGE_AGENT_TYPE, }; use crate::define_readonly_subagent; @@ -31,6 +32,24 @@ define_readonly_subagent!( &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] ); +define_readonly_subagent!( + ArchitectureReviewerAgent, + REVIEWER_ARCHITECTURE_AGENT_TYPE, + "Architecture Reviewer", + r#"Independent read-only reviewer focused on structural and architectural issues such as module boundary violations, API contract design, abstraction integrity, dependency direction, and cross-cutting concern impact in the review target."#, + "review_architecture_agent", + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] +); + +define_readonly_subagent!( + FrontendReviewerAgent, + REVIEWER_FRONTEND_AGENT_TYPE, + "Frontend Reviewer", + r#"Independent read-only reviewer focused on frontend-specific issues such as i18n key synchronization, frontend performance patterns (e.g., memoization, virtualization, effect/reactivity dependencies), accessibility, state management, frontend-backend API contract alignment, and platform boundary compliance in the review target."#, + "review_frontend_agent", + &["Read", "Grep", "Glob", "LS", "GetFileDiff", "Git"] +); + define_readonly_subagent!( ReviewJudgeAgent, REVIEW_JUDGE_AGENT_TYPE, @@ -43,8 +62,8 @@ define_readonly_subagent!( #[cfg(test)] mod tests { use super::{ - BusinessLogicReviewerAgent, PerformanceReviewerAgent, ReviewJudgeAgent, - SecurityReviewerAgent, + ArchitectureReviewerAgent, BusinessLogicReviewerAgent, FrontendReviewerAgent, + PerformanceReviewerAgent, ReviewJudgeAgent, SecurityReviewerAgent, }; use crate::agentic::agents::{Agent, RequestContextPolicy}; @@ -54,6 +73,8 @@ mod tests { Box::new(BusinessLogicReviewerAgent::new()), Box::new(PerformanceReviewerAgent::new()), Box::new(SecurityReviewerAgent::new()), + Box::new(ArchitectureReviewerAgent::new()), + Box::new(FrontendReviewerAgent::new()), Box::new(ReviewJudgeAgent::new()), ]; diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 3ab841b72..02cd82c85 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -3,22 +3,25 @@ //! Top-level component that integrates all subsystems and provides a unified interface use super::{scheduler::DialogSubmissionPolicy, turn_outcome::TurnOutcome}; -use crate::agentic::WorkspaceBinding; use crate::agentic::agents::get_agent_registry; use crate::agentic::core::{ - Message, MessageContent, ProcessingPhase, PromptEnvelope, Session, SessionConfig, SessionKind, - SessionState, SessionSummary, TurnStats, has_prompt_markup, + has_prompt_markup, Message, MessageContent, ProcessingPhase, PromptEnvelope, Session, + SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, }; use crate::agentic::events::{ AgenticEvent, EventPriority, EventQueue, EventRouter, EventSubscriber, }; use crate::agentic::execution::{ContextCompactionOutcome, ExecutionContext, ExecutionEngine}; -use crate::agentic::fork::{ForkContextSnapshot, ForkExecutionRequest, ForkExecutionResult}; +use crate::agentic::fork_agent::{ + ForkAgentContextSnapshot, ForkAgentExecutionRequest, ForkAgentExecutionResult, +}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::DialogRoundPreemptSource; use crate::agentic::session::SessionManager; -use crate::agentic::tools::ToolRuntimeRestrictions; +use crate::agentic::side_question::build_btw_user_input; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; +use crate::agentic::tools::ToolRuntimeRestrictions; +use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; @@ -29,8 +32,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::OnceLock; -use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, mpsc, watch}; -use tokio::time::{Duration, Instant, sleep}; +use tokio::sync::{mpsc, watch, OwnedSemaphorePermit, RwLock, Semaphore}; +use tokio::time::{sleep, Duration, Instant}; use tokio_util::sync::CancellationToken; const MANUAL_COMPACTION_COMMAND: &str = "/compact"; @@ -812,6 +815,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet todos: None, workspace_path: Some(workspace_path.to_string()), workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, }; if let Err(e) = persistence_manager .save_session_metadata(&workspace_path_buf, &metadata) @@ -858,6 +863,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet /// and must never appear as top-level items in the UI. async fn create_hidden_subagent_session( &self, + session_id: Option, session_name: String, agent_type: String, config: SessionConfig, @@ -865,7 +871,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) -> BitFunResult { self.session_manager .create_session_with_id_and_details( - None, + session_id, session_name, agent_type, config, @@ -1249,6 +1255,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet total_tools: 1, duration_ms: outcome.duration_ms, subagent_parent_info: None, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), }) .await; @@ -1869,18 +1878,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; - if let (Some(ref wp), Some(status)) = (&session_workspace_path, workspace_turn_status) { - Self::finalize_turn_in_workspace( - &session_id_clone, - &turn_id_clone, - turn_index, - &user_input_for_workspace, - wp, - session_storage_path_for_finalize.as_deref(), - status, - user_message_metadata_clone, - ) - .await; + let should_finalize_in_workspace = + session_manager.should_persist_session_id(&session_id_clone); + + if should_finalize_in_workspace { + if let (Some(ref wp), Some(status)) = + (&session_workspace_path, workspace_turn_status) + { + Self::finalize_turn_in_workspace( + &session_id_clone, + &turn_id_clone, + turn_index, + &user_input_for_workspace, + wp, + session_storage_path_for_finalize.as_deref(), + status, + user_message_metadata_clone, + ) + .await; + } } }); @@ -2228,8 +2244,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; // Create dynamic deadline via watch channel so it can be adjusted at runtime. - let initial_deadline = timeout_seconds - .map(|seconds| Instant::now() + Duration::from_secs(seconds)); + let initial_deadline = + timeout_seconds.map(|seconds| Instant::now() + Duration::from_secs(seconds)); let (deadline_tx, mut deadline_rx) = watch::channel(initial_deadline); // Check cancel token (before creating session) @@ -2273,6 +2289,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let session = self .create_hidden_subagent_session( + None, session_name, agent_type.clone(), session_config, @@ -2341,6 +2358,26 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id: dialog_turn_id.clone(), }; + // Emit DialogTurnStarted with subagent_parent_info so the frontend can + // associate the subagent session ID with the parent tool (enabling the + // "ignore timeout" feature for deep-review subagents). + let user_input_text = initial_messages + .first() + .map(|m| match &m.content { + MessageContent::Text(text) => text.clone(), + _ => String::new(), + }) + .unwrap_or_default(); + self.emit_event(AgenticEvent::DialogTurnStarted { + session_id: session_id.clone(), + turn_id: dialog_turn_id.clone(), + turn_index: 0, + user_input: user_input_text, + original_user_input: None, + user_message_metadata: None, + subagent_parent_info: subagent_parent_info.clone().map(Into::into), + }).await; + let subagent_workspace = Self::build_workspace_binding(&session.config).await; let subagent_services = Self::build_workspace_services(&subagent_workspace).await; let execution_context = ExecutionContext { @@ -2621,10 +2658,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }) } - pub async fn capture_fork_context_snapshot( + pub async fn capture_fork_agent_context_snapshot( &self, parent_session_id: &str, - ) -> BitFunResult { + ) -> BitFunResult { let parent_session = self .session_manager .get_session(parent_session_id) @@ -2632,29 +2669,126 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet BitFunError::NotFound(format!("Parent session not found: {}", parent_session_id)) })?; let context_messages = self.load_session_context_messages(&parent_session).await?; - ForkContextSnapshot::from_parent_session(&parent_session, context_messages) + ForkAgentContextSnapshot::from_parent_session(&parent_session, context_messages) + } + + async fn ensure_hidden_btw_session( + &self, + parent_session_id: &str, + child_session_id: &str, + child_session_name: Option<&str>, + ) -> BitFunResult { + if let Some(session) = self.session_manager.get_session(child_session_id) { + return Ok(session); + } + + let snapshot = self + .capture_fork_agent_context_snapshot(parent_session_id) + .await?; + let session_name = child_session_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("Side thread") + .to_string(); + let child_session = self + .create_hidden_subagent_session( + Some(child_session_id.to_string()), + session_name, + snapshot.parent_agent_type.clone(), + snapshot.build_child_session_config(None), + Some(format!("session-{}", snapshot.parent_session_id)), + ) + .await?; + + self.session_manager + .replace_context_messages(&child_session.session_id, snapshot.messages) + .await; + + Ok(child_session) + } + + pub async fn start_hidden_btw_turn( + &self, + request_id: &str, + parent_session_id: &str, + child_session_id: &str, + child_session_name: Option<&str>, + question: &str, + model_id: Option<&str>, + ) -> BitFunResult { + if request_id.trim().is_empty() { + return Err(BitFunError::Validation( + "request_id is required".to_string(), + )); + } + if parent_session_id.trim().is_empty() { + return Err(BitFunError::Validation( + "parent_session_id is required".to_string(), + )); + } + if child_session_id.trim().is_empty() { + return Err(BitFunError::Validation( + "child_session_id is required".to_string(), + )); + } + if question.trim().is_empty() { + return Err(BitFunError::Validation("question is required".to_string())); + } + + let child_session = self + .ensure_hidden_btw_session(parent_session_id, child_session_id, child_session_name) + .await?; + + if let Some(model_id) = model_id.map(str::trim).filter(|model_id| !model_id.is_empty()) { + self.session_manager + .update_session_model_id(child_session_id, model_id) + .await?; + } + + let turn_id = format!("btw-turn-{}", request_id.trim()); + let user_message_metadata = Some(serde_json::json!({ + "kind": "btw", + "parentSessionId": parent_session_id, + })); + + self.start_dialog_turn_internal( + child_session_id.to_string(), + build_btw_user_input(question), + Some(question.trim().to_string()), + None, + Some(turn_id.clone()), + child_session.agent_type.clone(), + child_session.config.workspace_path.clone(), + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) + .with_skip_tool_confirmation(true), + user_message_metadata, + true, + ) + .await?; + + Ok(turn_id) } /// Execute a hidden child agent that inherits the parent session's current /// model-visible context. - pub async fn execute_forked_agent( + pub async fn execute_fork_agent( &self, - request: ForkExecutionRequest, + request: ForkAgentExecutionRequest, cancel_token: Option<&CancellationToken>, - ) -> BitFunResult { + ) -> BitFunResult { if request.agent_type.trim().is_empty() { return Err(BitFunError::Validation( - "ForkExecutionRequest.agent_type is required".to_string(), + "ForkAgentExecutionRequest.agent_type is required".to_string(), )); } if request.description.trim().is_empty() { return Err(BitFunError::Validation( - "ForkExecutionRequest.description is required".to_string(), + "ForkAgentExecutionRequest.description is required".to_string(), )); } if request.prompt_messages.is_empty() { return Err(BitFunError::Validation( - "ForkExecutionRequest.prompt_messages must not be empty".to_string(), + "ForkAgentExecutionRequest.prompt_messages must not be empty".to_string(), )); } @@ -2681,7 +2815,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; - Ok(ForkExecutionResult { + Ok(ForkAgentExecutionResult { text: child_result.text, inherited_message_count, prompt_message_count, @@ -2864,6 +2998,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(normalized) } + pub async fn update_session_agent_type( + &self, + session_id: &str, + agent_type: &str, + ) -> BitFunResult<()> { + let normalized = Self::normalize_agent_type(agent_type); + self.session_manager + .update_session_agent_type(session_id, &normalized) + .await + } + /// Emit event async fn emit_event(&self, event: AgenticEvent) { let _ = self diff --git a/src/crates/core/src/agentic/deep_review_policy.rs b/src/crates/core/src/agentic/deep_review_policy.rs index 2abe9fc72..a2835aaab 100644 --- a/src/crates/core/src/agentic/deep_review_policy.rs +++ b/src/crates/core/src/agentic/deep_review_policy.rs @@ -13,10 +13,14 @@ pub const REVIEW_FIXER_AGENT_TYPE: &str = "ReviewFixer"; pub const REVIEWER_BUSINESS_LOGIC_AGENT_TYPE: &str = "ReviewBusinessLogic"; pub const REVIEWER_PERFORMANCE_AGENT_TYPE: &str = "ReviewPerformance"; pub const REVIEWER_SECURITY_AGENT_TYPE: &str = "ReviewSecurity"; -pub const CORE_REVIEWER_AGENT_TYPES: [&str; 3] = [ +pub const REVIEWER_ARCHITECTURE_AGENT_TYPE: &str = "ReviewArchitecture"; +pub const REVIEWER_FRONTEND_AGENT_TYPE: &str = "ReviewFrontend"; +pub const CORE_REVIEWER_AGENT_TYPES: [&str; 5] = [ REVIEWER_BUSINESS_LOGIC_AGENT_TYPE, REVIEWER_PERFORMANCE_AGENT_TYPE, REVIEWER_SECURITY_AGENT_TYPE, + REVIEWER_ARCHITECTURE_AGENT_TYPE, + REVIEWER_FRONTEND_AGENT_TYPE, ]; const DEFAULT_REVIEW_TEAM_CONFIG_PATH: &str = "ai.review_teams.default"; @@ -712,13 +716,13 @@ mod tests { }))); let tracker = DeepReviewBudgetTracker::default(); - // Default policy: 3 core reviewers * 2 max instances = 6 reviewer calls allowed - for _ in 0..6 { + // Default policy: 5 core reviewers * 2 max instances = 10 reviewer calls allowed + for _ in 0..10 { tracker .record_task("turn-1", &policy, DeepReviewSubagentRole::Reviewer) .unwrap(); } - // 7th reviewer call should be rejected + // 11th reviewer call should be rejected assert!(tracker .record_task("turn-1", &policy, DeepReviewSubagentRole::Reviewer) .is_err()); diff --git a/src/crates/core/src/agentic/events/queue.rs b/src/crates/core/src/agentic/events/queue.rs index 35f2ef7b7..bfb7861fe 100644 --- a/src/crates/core/src/agentic/events/queue.rs +++ b/src/crates/core/src/agentic/events/queue.rs @@ -7,7 +7,10 @@ use crate::util::errors::BitFunResult; use log::{debug, trace, warn}; use std::collections::BinaryHeap; use std::sync::Arc; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::{broadcast, Mutex, Notify}; + +const EVENT_BROADCAST_BUFFER: usize = 1024; +const SLOW_EVENT_QUEUE_LATENCY_MS: u128 = 250; /// Event queue configuration #[derive(Debug, Clone)] @@ -46,6 +49,9 @@ pub struct EventQueue { /// Notifier (used to wake up waiting consumers) notify: Arc, + /// Broadcast stream for non-consuming subscribers. + broadcast_tx: broadcast::Sender, + /// Configuration config: EventQueueConfig, @@ -55,9 +61,11 @@ pub struct EventQueue { impl EventQueue { pub fn new(config: EventQueueConfig) -> Self { + let (broadcast_tx, _) = broadcast::channel(EVENT_BROADCAST_BUFFER); Self { queue: Arc::new(Mutex::new(BinaryHeap::new())), notify: Arc::new(Notify::new()), + broadcast_tx, config, stats: Arc::new(Mutex::new(QueueStats::default())), } @@ -85,9 +93,11 @@ impl EventQueue { // Add to queue { let mut queue = self.queue.lock().await; - queue.push(std::cmp::Reverse(envelope)); + queue.push(std::cmp::Reverse(envelope.clone())); } + let _ = self.broadcast_tx.send(envelope); + // Update statistics: get queue size first, then update statistics (avoid getting queue lock while holding stats lock) let queue_len = self.queue.lock().await.len(); { @@ -120,12 +130,37 @@ impl EventQueue { batch.push(envelope); } } + let remaining_queue_len = queue.len(); + drop(queue); + + if let Some((max_age_ms, event_id, priority)) = batch + .iter() + .filter_map(|envelope| { + envelope + .timestamp + .elapsed() + .ok() + .map(|age| (age.as_millis(), envelope.id.as_str(), envelope.priority)) + }) + .max_by_key(|(age_ms, _, _)| *age_ms) + { + if max_age_ms >= SLOW_EVENT_QUEUE_LATENCY_MS { + warn!( + "Slow agentic event queue delivery: max_age_ms={}, batch_size={}, remaining_queue_len={}, event_id={}, priority={:?}", + max_age_ms, + batch.len(), + remaining_queue_len, + event_id, + priority + ); + } + } // Update statistics if !batch.is_empty() { let mut stats = self.stats.lock().await; stats.total_processed += batch.len() as u64; - stats.pending_events = queue.len(); + stats.pending_events = remaining_queue_len; } batch @@ -136,6 +171,11 @@ impl EventQueue { self.dequeue_batch(self.config.batch_size).await } + /// Subscribe to events without consuming them from the queue. + pub fn subscribe(&self) -> broadcast::Receiver { + self.broadcast_tx.subscribe() + } + /// Clear all events for a session pub async fn clear_session(&self, session_id: &str) -> BitFunResult<()> { // Remove all events for this session from the queue diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index e9a5c50bc..dd6c2a360 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -8,8 +8,8 @@ use crate::agentic::agents::{ get_agent_registry, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, }; use crate::agentic::core::{ - render_system_reminder, Message, MessageContent, MessageHelper, MessageSemanticKind, - RequestReasoningTokenPolicy, Session, + render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, + MessageSemanticKind, RequestReasoningTokenPolicy, Session, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; @@ -33,6 +33,7 @@ use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use log::{debug, error, info, trace, warn}; +use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; use std::sync::Arc; @@ -49,7 +50,7 @@ pub struct ExecutionEngineConfig { impl Default for ExecutionEngineConfig { fn default() -> Self { Self { - max_rounds: 50, + max_rounds: crate::service::config::types::DEFAULT_MAX_ROUNDS, max_consecutive_same_tool: 3, } } @@ -78,6 +79,8 @@ pub struct ExecutionEngine { } impl ExecutionEngine { + const FINALIZE_AFTER_TOOL_USE_REMINDER: &'static str = "Tool execution for this turn has already completed, but the turn is ending at this round boundary. Do not call any more tools. Provide the final response to the user based on the tool results already available."; + pub fn new( round_executor: Arc, event_queue: Arc, @@ -110,13 +113,35 @@ impl ExecutionEngine { return args_str.to_string(); } + let args_hash = hex::encode(Sha256::digest(args_str.as_bytes())); format!( - "{}..#{}", + "{}..#{}:sha256={}", truncate_at_char_boundary(args_str, 64), - args_str.len() + args_str.len(), + args_hash + ) + } + + fn assistant_has_tool_calls(message: &Message) -> bool { + matches!( + &message.content, + MessageContent::Mixed { tool_calls, .. } if !tool_calls.is_empty() ) } + fn has_tool_result_after_last_assistant(messages: &[Message]) -> bool { + let Some(last_assistant_index) = messages + .iter() + .rposition(|message| message.role == MessageRole::Assistant) + else { + return false; + }; + + messages[last_assistant_index + 1..] + .iter() + .any(|message| matches!(message.content, MessageContent::ToolResult { .. })) + } + /// Emergency truncation: drop oldest API rounds (assistant+tool pairs) /// from the front of the message list until estimated tokens fit within /// `context_window`. System messages and the first user message are @@ -1189,8 +1214,11 @@ impl ExecutionEngine { messages.extend(initial_messages); let mut round_index = 0; + let mut completed_rounds = 0usize; let mut total_tools = 0; + let mut last_partial_recovery_reason: Option = None; let mut last_assistant_message = Message::assistant("".to_string()); + let mut finalization_reason: Option<&'static str> = None; let mut consecutive_compression_failures: u32 = 0; const MAX_CONSECUTIVE_COMPRESSION_FAILURES: u32 = 3; @@ -1298,11 +1326,12 @@ impl ExecutionEngine { // Loop to execute model rounds loop { // Check round limit - if round_index >= self.config.max_rounds { + if completed_rounds >= self.config.max_rounds { warn!( "Reached max rounds limit: {}, stopping execution", self.config.max_rounds ); + finalization_reason = Some("max_rounds"); break; } @@ -1498,6 +1527,7 @@ impl ExecutionEngine { round_result.has_more_rounds, round_result.tool_calls.len() ); + completed_rounds += 1; last_assistant_message = round_result.assistant_message.clone(); // Save the last token usage statistics (update each time, keep the last one) @@ -1539,6 +1569,11 @@ impl ExecutionEngine { total_tools += round_result.tool_calls.len(); + // Track partial recovery reason from the last round + if round_result.partial_recovery_reason.is_some() { + last_partial_recovery_reason = round_result.partial_recovery_reason.clone(); + } + // P0: Consecutive same-tool-call loop detection if !round_result.tool_calls.is_empty() { let mut sigs: Vec = round_result @@ -1566,6 +1601,7 @@ impl ExecutionEngine { max_consec ); loop_detected = true; + finalization_reason = Some("loop_detected"); break; } } @@ -1591,6 +1627,7 @@ impl ExecutionEngine { "Yielding dialog turn after model round (queued user message): session_id={}, dialog_turn_id={}, round_index={}", context.session_id, context.dialog_turn_id, round_index ); + finalization_reason = Some("queued_user_message"); break; } } @@ -1629,15 +1666,101 @@ impl ExecutionEngine { ); } + if let Some(reason) = finalization_reason { + if Self::assistant_has_tool_calls(&last_assistant_message) + && Self::has_tool_result_after_last_assistant(&messages) + { + info!( + "Finalizing dialog turn after assistant tool use: session_id={}, turn_id={}, reason={}", + context.session_id, context.dialog_turn_id, reason + ); + + let mut final_ai_messages = Self::build_ai_messages_for_send( + &messages, + &ai_client.config.format, + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + &context.dialog_turn_id, + primary_supports_image_understanding, + request_context_reminder.as_deref(), + ) + .await?; + final_ai_messages.push(AIMessage::user( + Self::FINALIZE_AFTER_TOOL_USE_REMINDER.to_string(), + )); + + let round_context = RoundContext { + session_id: context.session_id.clone(), + subagent_parent_info: context.subagent_parent_info.clone(), + dialog_turn_id: context.dialog_turn_id.clone(), + turn_index: context.turn_index, + round_number: completed_rounds, + workspace: context.workspace.clone(), + messages: messages.clone(), + available_tools: Vec::new(), + model_name: ai_client.config.model.clone(), + agent_type: agent_type.clone(), + context_vars: execution_context_vars.clone(), + runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), + cancellation_token: CancellationToken::new(), + workspace_services: context.workspace_services.clone(), + }; + + let final_round_result = self + .round_executor + .execute_round( + ai_client.clone(), + round_context, + final_ai_messages, + None, + Some(context_window), + ) + .await?; + + if Self::assistant_has_tool_calls(&final_round_result.assistant_message) { + warn!( + "Finalization round still returned tool calls; keeping prior messages: session_id={}, turn_id={}", + context.session_id, context.dialog_turn_id + ); + } else { + completed_rounds += 1; + if let Some(ref usage) = final_round_result.usage { + last_usage = Some(usage.clone()); + } + last_assistant_message = final_round_result.assistant_message.clone(); + messages.push(final_round_result.assistant_message.clone()); + + if let Err(e) = self + .session_manager + .add_message(&context.session_id, final_round_result.assistant_message) + .await + { + warn!("Failed to update final assistant message in memory: {}", e); + } + } + } + } + let duration_ms = elapsed_ms_u64(start_time); info!( "Dialog turn loop completed: turn={}, rounds={}, total_tools={}", - context.dialog_turn_id, - round_index + 1, - total_tools + context.dialog_turn_id, completed_rounds, total_tools ); + // Determine finish reason + let finish_reason = if loop_detected { + FinishReason::LoopDetected + } else if completed_rounds >= self.config.max_rounds { + FinishReason::MaxRounds + } else { + FinishReason::Complete + }; + + let success = !loop_detected && completed_rounds < self.config.max_rounds; + // Emit dialog turn completed event debug!("Preparing to send DialogTurnCompleted event"); @@ -1647,10 +1770,13 @@ impl ExecutionEngine { AgenticEvent::DialogTurnCompleted { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), - total_rounds: round_index + 1, + total_rounds: completed_rounds, total_tools, duration_ms, subagent_parent_info: event_subagent_parent_info, + partial_recovery_reason: last_partial_recovery_reason, + success: Some(success), + finish_reason: Some(finish_reason.to_string()), }, None, ) @@ -1663,7 +1789,7 @@ impl ExecutionEngine { info!( "Dialog turn completed - Token stats: turn_id={}, rounds={}, tools={}, duration={}ms, prompt_tokens={}, completion_tokens={}, total_tokens={}", context.dialog_turn_id, - round_index + 1, + completed_rounds, total_tools, duration_ms, usage.prompt_token_count, @@ -1687,28 +1813,16 @@ impl ExecutionEngine { ); } - // Determine finish reason - let finish_reason = if loop_detected { - FinishReason::LoopDetected - } else if round_index >= self.config.max_rounds { - FinishReason::MaxRounds - } else { - FinishReason::Complete - }; - - let success = !loop_detected && round_index < self.config.max_rounds; - if loop_detected { warn!( "Dialog turn stopped due to loop detection: turn={}, rounds={}", - context.dialog_turn_id, - round_index + 1 + context.dialog_turn_id, completed_rounds ); } Ok(ExecutionResult { final_message: last_assistant_message, - total_rounds: round_index + 1, + total_rounds: completed_rounds, success, new_messages, finish_reason, @@ -1845,8 +1959,11 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::ExecutionEngine; + use crate::agentic::core::{Message, ToolCall, ToolResult}; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; + use serde_json::json; + use sha2::{Digest, Sha256}; fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { AIModelConfig { @@ -1894,10 +2011,14 @@ mod tests { #[test] fn tool_signature_args_summary_truncates_on_utf8_boundary() { let args = format!("{}{}", "a".repeat(62), "案".repeat(30)); + let args_hash = hex::encode(Sha256::digest(args.as_bytes())); let summary = ExecutionEngine::tool_signature_args_summary(&args); - assert_eq!(summary, format!("{}..#{}", "a".repeat(62), args.len())); + assert_eq!( + summary, + format!("{}..#{}:sha256={}", "a".repeat(62), args.len(), args_hash) + ); } #[test] @@ -1908,4 +2029,67 @@ mod tests { assert_eq!(summary, args); } + + #[test] + fn tool_signature_args_summary_distinguishes_same_prefix_and_length() { + let first = format!("{}{}", "x".repeat(64), "a".repeat(80)); + let second = format!("{}{}", "x".repeat(64), "b".repeat(80)); + + let first_summary = ExecutionEngine::tool_signature_args_summary(&first); + let second_summary = ExecutionEngine::tool_signature_args_summary(&second); + + assert_eq!(first.len(), second.len()); + assert_ne!(first, second); + assert_ne!(first_summary, second_summary); + } + + #[test] + fn assistant_has_tool_calls_detects_mixed_tool_message() { + let message = Message::assistant_with_tools( + String::new(), + vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({ "path": "README.md" }), + is_error: false, + }], + ); + + assert!(ExecutionEngine::assistant_has_tool_calls(&message)); + assert!(!ExecutionEngine::assistant_has_tool_calls( + &Message::assistant("done".to_string()) + )); + } + + #[test] + fn detects_tool_result_after_last_assistant() { + let assistant = Message::assistant_with_tools( + String::new(), + vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({ "path": "README.md" }), + is_error: false, + }], + ); + let tool_result = Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Read".to_string(), + result: json!({ "content": "hello" }), + result_for_assistant: Some("hello".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }); + + assert!(ExecutionEngine::has_tool_result_after_last_assistant(&[ + Message::user("read it".to_string()), + assistant.clone(), + tool_result, + ])); + assert!(!ExecutionEngine::has_tool_result_after_last_assistant(&[ + Message::user("read it".to_string()), + assistant, + ])); + } } diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 7e389226f..a5b365977 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -2,23 +2,24 @@ //! //! Executes a single model round: calls AI, processes streaming responses, executes tools -use super::stream_processor::StreamProcessor; +use super::stream_processor::{StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; -use crate::agentic::MessageContent; -use crate::agentic::core::Message; -use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; +use crate::agentic::core::{Message, ToolCall}; +use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; use crate::service::config::GlobalConfigManager; +use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use dashmap::DashMap; use log::{debug, error, warn}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; /// Round executor @@ -62,6 +63,7 @@ impl RoundExecutor { tool_definitions: Option>, context_window: Option, ) -> BitFunResult { + let round_started_at = Instant::now(); let subagent_parent_info = context.subagent_parent_info.clone(); let is_subagent = subagent_parent_info.is_some(); let event_subagent_parent_info = subagent_parent_info.clone().map(|info| info.into()); @@ -97,7 +99,8 @@ impl RoundExecutor { let max_attempts = Self::MAX_RETRIES_WITHOUT_OUTPUT + 1; let mut attempt_index = 0usize; - let stream_result = loop { + let (stream_result, send_to_stream_ms, stream_processing_ms) = loop { + let request_started_at = Instant::now(); debug!( "Sending request: model={}, messages={}, tools={}, attempt={}/{}", context.model_name, @@ -108,11 +111,22 @@ impl RoundExecutor { ); // Use dynamically obtained client for call - let stream_response = match ai_client + let (stream_response, send_to_stream_ms) = match ai_client .send_message_stream(ai_messages.clone(), tool_definitions.clone()) .await { - Ok(response) => response, + Ok(response) => { + let send_to_stream_ms = elapsed_ms_u64(request_started_at); + debug!( + "AI stream opened: session_id={}, round_id={}, attempt={}/{}, send_to_stream_ms={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + send_to_stream_ms + ); + (response, send_to_stream_ms) + } Err(e) => { error!("AI request failed: {}", e); let err_msg = e.to_string(); @@ -159,6 +173,7 @@ impl RoundExecutor { max_attempts ); + let stream_started_at = Instant::now(); match self .stream_processor .process_stream( @@ -174,7 +189,43 @@ impl RoundExecutor { .await { Ok(result) => { + let stream_processing_ms = elapsed_ms_u64(stream_started_at); + if Self::is_interrupted_invalid_tool_only(&result) { + let err_msg = result.partial_recovery_reason.clone().unwrap_or_else(|| { + "Interrupted while streaming tool arguments".to_string() + }); + self.emit_failed_partial_tool_calls( + &context, + &result.tool_calls, + &err_msg, + event_subagent_parent_info.clone(), + ) + .await; + + if attempt_index < max_attempts - 1 + && Self::is_transient_network_error(&err_msg) + { + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream because tool arguments were interrupted before valid JSON completed: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, error={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + err_msg + ); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + attempt_index += 1; + continue; + } + + return Err(BitFunError::AIClient(err_msg)); + } + let no_effective_output = !result.has_effective_output; + let is_partial_recovery = result.partial_recovery_reason.is_some(); + if no_effective_output && attempt_index < max_attempts - 1 { let delay_ms = Self::retry_delay_ms(attempt_index); warn!( @@ -189,7 +240,24 @@ impl RoundExecutor { attempt_index += 1; continue; } - break result; + + if is_partial_recovery && attempt_index < max_attempts - 1 { + let delay_ms = Self::retry_delay_ms(attempt_index); + warn!( + "Retrying stream after partial recovery: session_id={}, round_id={}, attempt={}/{}, delay_ms={}, reason={}", + context.session_id, + round_id, + attempt_index + 1, + max_attempts, + delay_ms, + result.partial_recovery_reason.as_deref().unwrap_or("unknown") + ); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + attempt_index += 1; + continue; + } + + break (result, send_to_stream_ms, stream_processing_ms); } Err(stream_err) => { let err_msg = stream_err.error.to_string(); @@ -235,10 +303,14 @@ impl RoundExecutor { .collect(); debug!( target: "ai::model_response", - "Model response received: text_length={}, tool_calls={}, token_usage={:?}", + "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}", stream_result.full_text.len(), if tool_names.is_empty() { "none".to_string() } else { tool_names.join(", ") }, - stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()) + stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()), + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms ); // Check cancellation token again after stream processing completes @@ -317,6 +389,17 @@ impl RoundExecutor { .with_thinking_signature(stream_result.thinking_signature.clone()); debug!("Returning RoundResult: has_more_rounds=false"); + debug!( + "Model round timing summary: session_id={}, turn_id={}, round_id={}, tool_calls=0, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, tool_phase_ms=0, round_total_ms={}, has_more_rounds=false", + context.session_id, + context.dialog_turn_id, + round_id, + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms, + elapsed_ms_u64(round_started_at) + ); // Note: Do not cleanup cancellation token here, as this is only the end of a single model round // Cancellation token will be cleaned up by ExecutionEngine when the entire dialog turn ends @@ -329,6 +412,7 @@ impl RoundExecutor { finish_reason: FinishReason::Complete, usage: stream_result.usage.clone(), provider_metadata: stream_result.provider_metadata.clone(), + partial_recovery_reason: stream_result.partial_recovery_reason.clone(), }); } @@ -347,6 +431,7 @@ impl RoundExecutor { stream_result.tool_calls.len() ); + let tool_phase_started_at = Instant::now(); let tool_results = if let Some(tool_pipeline) = &self.tool_pipeline { // Create tool execution context let tool_context = ToolExecutionContext { @@ -463,6 +548,7 @@ impl RoundExecutor { } else { vec![] }; + let tool_phase_ms = elapsed_ms_u64(tool_phase_started_at); // Create assistant message (includes tool calls and thinking content, supports interleaved thinking mode) let reasoning = if stream_result.full_thinking.is_empty() { @@ -516,6 +602,21 @@ impl RoundExecutor { has_more_rounds, tool_result_messages.len() ); + debug!( + "Model round timing summary: session_id={}, turn_id={}, round_id={}, tool_calls={}, tool_results={}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, tool_phase_ms={}, round_total_ms={}, has_more_rounds={}", + context.session_id, + context.dialog_turn_id, + round_id, + stream_result.tool_calls.len(), + tool_result_messages.len(), + send_to_stream_ms, + stream_processing_ms, + stream_result.first_chunk_ms, + stream_result.first_visible_output_ms, + tool_phase_ms, + elapsed_ms_u64(round_started_at), + has_more_rounds + ); // Note: Do not cleanup cancellation token here, as there may be subsequent model rounds // Cancellation token will be cleaned up by ExecutionEngine when the entire dialog turn ends @@ -534,6 +635,7 @@ impl RoundExecutor { }, usage: stream_result.usage.clone(), provider_metadata: stream_result.provider_metadata.clone(), + partial_recovery_reason: stream_result.partial_recovery_reason.clone(), }) } @@ -575,6 +677,41 @@ impl RoundExecutor { let _ = self.event_queue.enqueue(event, Some(priority)).await; } + async fn emit_failed_partial_tool_calls( + &self, + context: &RoundContext, + tool_calls: &[ToolCall], + error: &str, + subagent_parent_info: Option, + ) { + for tool_call in tool_calls { + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + tool_event: ToolEventData::Failed { + tool_id: tool_call.tool_id.clone(), + tool_name: tool_call.tool_name.clone(), + error: format!("Tool arguments stream interrupted: {}", error), + }, + subagent_parent_info: subagent_parent_info.clone(), + }, + EventPriority::High, + ) + .await; + } + } + + fn is_interrupted_invalid_tool_only(result: &StreamResult) -> bool { + result.partial_recovery_reason.is_some() + && result.full_text.is_empty() + && !result.tool_calls.is_empty() + && result + .tool_calls + .iter() + .all(|tool_call| !tool_call.is_valid()) + } + fn retry_delay_ms(attempt_index: usize) -> u64 { Self::RETRY_BASE_DELAY_MS * (1u64 << attempt_index.min(3)) } @@ -690,4 +827,50 @@ mod tests { assert!(!RoundExecutor::is_transient_network_error(auth)); assert!(!RoundExecutor::is_transient_network_error(billing)); } + + #[test] + fn detects_interrupted_invalid_tool_only_recovery() { + let result = crate::agentic::execution::stream_processor::StreamResult { + full_thinking: String::new(), + thinking_signature: None, + full_text: String::new(), + tool_calls: vec![crate::agentic::core::ToolCall { + tool_id: "call_1".to_string(), + tool_name: "Write".to_string(), + arguments: serde_json::json!({}), + is_error: true, + }], + usage: None, + provider_metadata: None, + has_effective_output: true, + first_chunk_ms: Some(1), + first_visible_output_ms: Some(1), + partial_recovery_reason: Some("Stream processing error: SSE stream error".to_string()), + }; + + assert!(RoundExecutor::is_interrupted_invalid_tool_only(&result)); + } + + #[test] + fn keeps_partial_text_recovery_as_non_retryable_output() { + let result = crate::agentic::execution::stream_processor::StreamResult { + full_thinking: String::new(), + thinking_signature: None, + full_text: "I started answering before the stream failed.".to_string(), + tool_calls: vec![crate::agentic::core::ToolCall { + tool_id: "call_1".to_string(), + tool_name: "Write".to_string(), + arguments: serde_json::json!({}), + is_error: true, + }], + usage: None, + provider_metadata: None, + has_effective_output: true, + first_chunk_ms: Some(1), + first_visible_output_ms: Some(1), + partial_recovery_reason: Some("Stream processing error: SSE stream error".to_string()), + }; + + assert!(!RoundExecutor::is_interrupted_invalid_tool_only(&result)); + } } diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 2d9a3b2b4..2953d9d23 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -12,12 +12,14 @@ use crate::infrastructure::ai::ai_stream_handlers::UnifiedResponse; use crate::infrastructure::ai::tool_call_accumulator::{ FinalizedToolCall, PendingToolCalls, ToolCallBoundary, ToolCallStreamKey, }; +use crate::util::elapsed_ms_u64; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; use futures::{Stream, StreamExt}; use log::{debug, error, trace}; use serde_json::Value; use std::sync::Arc; +use std::time::Instant; use tokio::sync::mpsc; //============================================================================== @@ -120,6 +122,10 @@ pub struct StreamResult { pub provider_metadata: Option, /// Whether this stream produced any user-visible output (text/thinking/tool events) pub has_effective_output: bool, + /// Milliseconds from stream processing start to the first upstream response item. + pub first_chunk_ms: Option, + /// Milliseconds from stream processing start to the first event visible to the UI. + pub first_visible_output_ms: Option, /// When set, the stream terminated abnormally but was recovered with partial output. /// Contains a human-readable reason (e.g. "Stream processing error: ..." or /// "Stream processor watchdog timeout ..."). @@ -163,6 +169,9 @@ struct StreamContext { pending_tool_calls: PendingToolCalls, // Counters and flags + stream_started_at: Instant, + first_chunk_ms: Option, + first_visible_output_ms: Option, text_chunks_count: usize, thinking_chunks_count: usize, thinking_completed_sent: bool, @@ -191,6 +200,9 @@ impl StreamContext { usage: None, provider_metadata: None, pending_tool_calls: PendingToolCalls::default(), + stream_started_at: Instant::now(), + first_chunk_ms: None, + first_visible_output_ms: None, text_chunks_count: 0, thinking_chunks_count: 0, thinking_completed_sent: false, @@ -208,10 +220,24 @@ impl StreamContext { usage: self.usage, provider_metadata: self.provider_metadata, has_effective_output: self.has_effective_output, + first_chunk_ms: self.first_chunk_ms, + first_visible_output_ms: self.first_visible_output_ms, partial_recovery_reason: self.partial_recovery_reason, } } + fn mark_first_stream_chunk(&mut self) { + if self.first_chunk_ms.is_none() { + self.first_chunk_ms = Some(elapsed_ms_u64(self.stream_started_at)); + } + } + + fn mark_first_visible_output(&mut self) { + if self.first_visible_output_ms.is_none() { + self.first_visible_output_ms = Some(elapsed_ms_u64(self.stream_started_at)); + } + } + fn can_recover_as_partial_result(&self) -> bool { self.has_effective_output } @@ -404,7 +430,8 @@ impl StreamProcessor { for tool_call in tool_calls { trace!( "Cleaning up tool: {} ({})", - tool_call.tool_name, tool_call.tool_id + tool_call.tool_name, + tool_call.tool_id ); let tool_event = if is_user_cancellation { @@ -513,6 +540,7 @@ impl StreamProcessor { if let Some(early_detected) = outcome.early_detected { ctx.has_effective_output = true; + ctx.mark_first_visible_output(); debug!("Tool detected: {}", early_detected.tool_name); let _ = self .event_queue @@ -533,6 +561,7 @@ impl StreamProcessor { if let Some(params_partial) = outcome.params_partial { ctx.has_effective_output = true; + ctx.mark_first_visible_output(); let _ = self .event_queue .enqueue( @@ -555,6 +584,7 @@ impl StreamProcessor { /// Handle text chunk async fn handle_text_chunk(&self, ctx: &mut StreamContext, text: String) { ctx.has_effective_output = true; + ctx.mark_first_visible_output(); ctx.full_text.push_str(&text); ctx.text_chunks_count += 1; @@ -580,6 +610,7 @@ impl StreamProcessor { // if the stream fails after producing only thinking (no text/tool calls), // it is safe to retry because the model will re-think from scratch. ctx.full_thinking.push_str(&thinking_content); + ctx.mark_first_visible_output(); ctx.thinking_chunks_count += 1; // Send thinking chunk event @@ -602,10 +633,12 @@ impl StreamProcessor { /// Print stream processing end log fn log_stream_result(&self, ctx: &StreamContext) { debug!( - "Stream loop ended: text_chunks={}, thinking_chunks={}, tool_calls({}): {}", + "Stream loop ended: text_chunks={}, thinking_chunks={}, tool_calls({}), first_chunk_ms={:?}, first_visible_output_ms={:?}: {}", ctx.text_chunks_count, ctx.thinking_chunks_count, ctx.tool_calls.len(), + ctx.first_chunk_ms, + ctx.first_visible_output_ms, ctx.tool_calls .iter() .map(|tc| tc.tool_name.as_str()) @@ -782,6 +815,7 @@ impl StreamProcessor { finish_reason, provider_metadata, } = response; + ctx.mark_first_stream_chunk(); // Handle thinking_signature if let Some(signature) = thinking_signature { diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 7c1917ba6..39baed80e 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,8 +2,8 @@ use crate::agentic::core::Message; use crate::agentic::round_preempt::DialogRoundPreemptSource; -use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::pipeline::SubagentParentInfo; +use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; use serde_json::Value; @@ -60,6 +60,9 @@ pub struct RoundResult { pub usage: Option, /// Provider-specific metadata returned by the model. pub provider_metadata: Option, + /// When set, this round's stream was partially recovered (aborted mid-way + /// but some output was already received). Contains a human-readable reason. + pub partial_recovery_reason: Option, } /// Finish reason @@ -79,6 +82,19 @@ pub enum FinishReason { LoopDetected, } +impl std::fmt::Display for FinishReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FinishReason::Complete => write!(f, "complete"), + FinishReason::ToolCalls => write!(f, "tool_calls"), + FinishReason::MaxRounds => write!(f, "max_rounds"), + FinishReason::Cancelled => write!(f, "cancelled"), + FinishReason::Error => write!(f, "error"), + FinishReason::LoopDetected => write!(f, "loop_detected"), + } + } +} + /// Execution result #[derive(Debug, Clone)] pub struct ExecutionResult { diff --git a/src/crates/core/src/agentic/fork/mod.rs b/src/crates/core/src/agentic/fork_agent/mod.rs similarity index 88% rename from src/crates/core/src/agentic/fork/mod.rs rename to src/crates/core/src/agentic/fork_agent/mod.rs index d1671a505..5371d8bf2 100644 --- a/src/crates/core/src/agentic/fork/mod.rs +++ b/src/crates/core/src/agentic/fork_agent/mod.rs @@ -1,6 +1,6 @@ -//! Shared-context fork execution primitives. +//! Shared-context fork-agent execution primitives. //! -//! A fork is a hidden child execution that inherits the parent session's +//! A fork agent is a hidden child execution that inherits the parent session's //! model-visible message context, but still runs as an isolated session with //! its own rounds, tools, cancellation, and cleanup lifecycle. @@ -11,7 +11,7 @@ use std::collections::HashMap; /// Immutable snapshot of a parent session's runtime context at fork time. #[derive(Debug, Clone)] -pub struct ForkContextSnapshot { +pub struct ForkAgentContextSnapshot { pub parent_session_id: String, pub parent_agent_type: String, pub workspace_path: String, @@ -22,7 +22,7 @@ pub struct ForkContextSnapshot { pub messages: Vec, } -impl ForkContextSnapshot { +impl ForkAgentContextSnapshot { pub fn from_parent_session( parent_session: &Session, messages: Vec, @@ -73,10 +73,10 @@ impl ForkContextSnapshot { } } -/// Semantic fork request. +/// Semantic fork-agent request. #[derive(Debug, Clone)] -pub struct ForkExecutionRequest { - pub snapshot: ForkContextSnapshot, +pub struct ForkAgentExecutionRequest { + pub snapshot: ForkAgentContextSnapshot, pub agent_type: String, pub description: String, pub prompt_messages: Vec, @@ -85,7 +85,7 @@ pub struct ForkExecutionRequest { pub max_turns: Option, } -impl ForkExecutionRequest { +impl ForkAgentExecutionRequest { pub fn composed_initial_messages(&self) -> Vec { self.snapshot .compose_initial_messages(&self.prompt_messages) @@ -96,9 +96,9 @@ impl ForkExecutionRequest { } } -/// Result returned by a completed semantic fork. +/// Result returned by a completed semantic fork-agent run. #[derive(Debug, Clone)] -pub struct ForkExecutionResult { +pub struct ForkAgentExecutionResult { pub text: String, pub inherited_message_count: usize, pub prompt_message_count: usize, @@ -126,8 +126,8 @@ mod tests { let parent = parent_session(); let inherited = vec![Message::user("hello".to_string())]; let prompt = vec![Message::user("fork directive".to_string())]; - let snapshot = - ForkContextSnapshot::from_parent_session(&parent, inherited.clone()).expect("snapshot"); + let snapshot = ForkAgentContextSnapshot::from_parent_session(&parent, inherited.clone()) + .expect("snapshot"); let combined = snapshot.compose_initial_messages(&prompt); @@ -144,7 +144,7 @@ mod tests { fn snapshot_builds_child_session_config_from_parent() { let parent = parent_session(); let snapshot = - ForkContextSnapshot::from_parent_session(&parent, Vec::new()).expect("snapshot"); + ForkAgentContextSnapshot::from_parent_session(&parent, Vec::new()).expect("snapshot"); let child_config = snapshot.build_child_session_config(Some(7)); diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index f23889868..0c82129fd 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -20,8 +20,8 @@ pub mod tools; pub mod coordination; pub mod deep_review_policy; -// Shared-context fork execution module -pub mod fork; +// Shared-context fork-agent execution module +pub mod fork_agent; /// Round-boundary yield when user queues a message during an active turn pub mod round_preempt; @@ -31,6 +31,7 @@ pub mod image_analysis; // Ephemeral side-question module (used by desktop /btw overlay) pub mod side_question; +pub mod system; // Agents module pub mod agents; @@ -46,7 +47,7 @@ pub use coordination::*; pub use core::*; pub use events::{queue, router, types as event_types}; pub use execution::*; -pub use fork::*; +pub use fork_agent::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ @@ -54,4 +55,5 @@ pub use round_preempt::{ }; pub use session::*; pub use side_question::*; +pub use system::{init_agentic_system, AgenticSystem}; pub use workspace::{WorkspaceBackend, WorkspaceBinding}; diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 3763e0fae..402f59e9e 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -661,6 +661,8 @@ impl PersistenceManager { todos: existing.and_then(|value| value.todos.clone()), workspace_path: Some(workspace_root), workspace_hostname, + unread_completion: existing.and_then(|value| value.unread_completion.clone()), + needs_user_attention: existing.and_then(|value| value.needs_user_attention.clone()), } } @@ -1225,6 +1227,41 @@ impl PersistenceManager { Ok(metadata_list) } + async fn count_session_metadata_dirs( + &self, + workspace_path: &Path, + ) -> BitFunResult { + let Some(sessions_root) = self.existing_project_sessions_dir(workspace_path) else { + return Ok(0); + }; + let mut count = 0; + let mut entries = fs::read_dir(&sessions_root) + .await + .map_err(|e| BitFunError::io(format!("Failed to read sessions root: {}", e)))?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::io(format!("Failed to read session directory entry: {}", e)) + })? { + let file_type = entry + .file_type() + .await + .map_err(|e| BitFunError::io(format!("Failed to get file type: {}", e)))?; + if !file_type.is_dir() { + continue; + } + + let session_id = entry.file_name().to_string_lossy().to_string(); + if self + .metadata_path(workspace_path, &session_id) + .exists() + { + count += 1; + } + } + + Ok(count) + } + async fn rebuild_index_locked( &self, workspace_path: &Path, @@ -1351,6 +1388,18 @@ impl PersistenceManager { ); return self.rebuild_index_locked(workspace_path).await; } + + let disk_count = self.count_session_metadata_dirs(workspace_path).await?; + if index.sessions.len() != disk_count { + warn!( + "Session index incomplete (index: {}, disk: {}), rebuilding: {}", + index.sessions.len(), + disk_count, + index_path.display() + ); + return self.rebuild_index_locked(workspace_path).await; + } + return Ok(index.sessions); } diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index 15734a246..4cac36ab1 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -3,9 +3,11 @@ //! Responsible for persistent storage and loading of data pub mod manager; +pub mod session_branch; pub mod session_workspace_maintenance; pub use manager::PersistenceManager; +pub use session_branch::{SessionBranchRequest, SessionBranchResult}; pub use session_workspace_maintenance::{ SessionWorkspaceMaintenanceReport, SessionWorkspaceMaintenanceService, }; diff --git a/src/crates/core/src/agentic/persistence/session_branch.rs b/src/crates/core/src/agentic/persistence/session_branch.rs new file mode 100644 index 000000000..306be413f --- /dev/null +++ b/src/crates/core/src/agentic/persistence/session_branch.rs @@ -0,0 +1,404 @@ +use super::manager::PersistenceManager; +use crate::agentic::core::{Session, SessionKind}; +use crate::service::session::{DialogTurnData, SessionStatus}; +use crate::util::errors::{BitFunError, BitFunResult}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map as JsonMap, Value as JsonValue}; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionBranchRequest { + pub source_session_id: String, + pub source_turn_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionBranchResult { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +fn estimate_turn_message_count(turn: &DialogTurnData) -> usize { + 1 + turn + .model_rounds + .iter() + .map(|round| round.text_items.len()) + .sum::() +} + +fn strip_child_session_metadata(value: Option<&JsonValue>) -> Option { + let Some(JsonValue::Object(existing)) = value else { + return None; + }; + + let mut next = existing.clone(); + for key in [ + "kind", + "parentSessionId", + "parentRequestId", + "parentDialogTurnId", + "parentTurnIndex", + ] { + next.remove(key); + } + Some(JsonValue::Object(next)) +} + +fn build_branch_custom_metadata( + source_metadata: Option<&JsonValue>, + source_session_id: &str, + source_turn_id: &str, + source_turn_index: usize, +) -> Option { + let mut base = match strip_child_session_metadata(source_metadata) { + Some(JsonValue::Object(map)) => map, + _ => JsonMap::new(), + }; + + base.insert( + "forkOrigin".to_string(), + serde_json::json!({ + "sessionId": source_session_id, + "turnId": source_turn_id, + "turnIndex": source_turn_index + 1, + }), + ); + + Some(JsonValue::Object(base)) +} + +impl PersistenceManager { + pub async fn branch_session( + &self, + workspace_path: &Path, + request: &SessionBranchRequest, + ) -> BitFunResult { + let source_session = self + .load_session(workspace_path, &request.source_session_id) + .await?; + let source_metadata = self + .load_session_metadata(workspace_path, &request.source_session_id) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Source session metadata not found: {}", + request.source_session_id + )) + })?; + let source_turns = self + .load_session_turns(workspace_path, &request.source_session_id) + .await?; + + if source_turns.is_empty() { + return Err(BitFunError::Validation( + "Source session has no persisted turns to branch".to_string(), + )); + } + + let source_turn_index = source_turns + .iter() + .position(|turn| turn.turn_id == request.source_turn_id) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Source turn not found in persisted session: {}", + request.source_turn_id + )) + })?; + + let target_session_name = source_session.session_name.clone(); + let target_agent_type = source_session.agent_type.clone(); + + let mut target_session = Session::new( + target_session_name.clone(), + target_agent_type.clone(), + source_session.config.clone(), + ); + target_session.created_by = None; + target_session.kind = SessionKind::Standard; + target_session.snapshot_session_id = None; + target_session.compression_state = source_session.compression_state.clone(); + let target_session_id = target_session.session_id.clone(); + + self.save_session(workspace_path, &target_session).await?; + + let branch_result = async { + let branched_turns = source_turns + .iter() + .take(source_turn_index + 1) + .enumerate() + .map(|(new_index, turn)| { + let mut branched_turn = turn.clone(); + branched_turn.session_id = target_session_id.clone(); + branched_turn.turn_index = new_index; + branched_turn + }) + .collect::>(); + + for (new_index, source_turn) in + source_turns.iter().take(source_turn_index + 1).enumerate() + { + if let Some(messages) = self + .load_turn_context_snapshot( + workspace_path, + &request.source_session_id, + source_turn.turn_index, + ) + .await? + { + self.save_turn_context_snapshot( + workspace_path, + &target_session_id, + new_index, + &messages, + ) + .await?; + } + } + + for turn in &branched_turns { + self.save_dialog_turn(workspace_path, turn).await?; + } + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let mut branched_metadata = source_metadata.clone(); + branched_metadata.session_id = target_session_id.clone(); + branched_metadata.session_name = target_session_name.clone(); + branched_metadata.agent_type = target_agent_type.clone(); + branched_metadata.created_by = None; + branched_metadata.session_kind = SessionKind::Standard; + branched_metadata.created_at = now_ms; + branched_metadata.last_active_at = now_ms; + branched_metadata.turn_count = branched_turns.len(); + branched_metadata.message_count = + branched_turns.iter().map(estimate_turn_message_count).sum(); + branched_metadata.tool_call_count = branched_turns + .iter() + .map(DialogTurnData::count_tool_calls) + .sum(); + branched_metadata.status = SessionStatus::Active; + branched_metadata.snapshot_session_id = None; + branched_metadata.tags = branched_metadata + .tags + .into_iter() + .filter(|tag| tag != "btw" && tag != "review" && tag != "deep_review") + .collect(); + branched_metadata.custom_metadata = build_branch_custom_metadata( + source_metadata.custom_metadata.as_ref(), + &request.source_session_id, + &request.source_turn_id, + source_turn_index, + ); + branched_metadata.todos = None; + branched_metadata.unread_completion = None; + branched_metadata.needs_user_attention = None; + + self.save_session_metadata(workspace_path, &branched_metadata) + .await?; + + Ok::<(), BitFunError>(()) + } + .await; + + if let Err(error) = branch_result { + let _ = self + .delete_session(workspace_path, &target_session_id) + .await; + return Err(error); + } + + Ok(SessionBranchResult { + session_id: target_session_id, + session_name: target_session_name, + agent_type: target_agent_type, + }) + } +} + +#[cfg(test)] +mod tests { + use super::{PersistenceManager, SessionBranchRequest}; + use crate::agentic::core::{Message, Session, SessionKind}; + use crate::infrastructure::PathManager; + use crate::service::session::{DialogTurnData, UserMessageData}; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("bitfun-session-branch-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn build_turn( + session_id: &str, + turn_id: &str, + turn_index: usize, + content: &str, + ) -> DialogTurnData { + let mut turn = DialogTurnData::new( + turn_id.to_string(), + turn_index, + session_id.to_string(), + UserMessageData { + id: format!("user-{}", turn_id), + content: content.to_string(), + timestamp: turn_index as u64, + metadata: None, + }, + ); + turn.mark_completed(); + turn + } + + #[tokio::test] + async fn branch_session_copies_turns_snapshots_and_lineage_metadata() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + + let mut source_session = Session::new( + "Source Title".to_string(), + "agentic".to_string(), + Default::default(), + ); + source_session.kind = SessionKind::Standard; + manager + .save_session(workspace.path(), &source_session) + .await + .expect("source session should save"); + + let turn_0 = build_turn(&source_session.session_id, "turn-0", 0, "first"); + let turn_1 = build_turn(&source_session.session_id, "turn-1", 1, "second"); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("turn 0 should save"); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("turn 1 should save"); + + manager + .save_turn_context_snapshot( + workspace.path(), + &source_session.session_id, + 0, + &[Message::user("snapshot-0".to_string())], + ) + .await + .expect("snapshot 0 should save"); + manager + .save_turn_context_snapshot( + workspace.path(), + &source_session.session_id, + 1, + &[Message::user("snapshot-1".to_string())], + ) + .await + .expect("snapshot 1 should save"); + + let mut source_metadata = manager + .load_session_metadata(workspace.path(), &source_session.session_id) + .await + .expect("metadata load should succeed") + .expect("source metadata should exist"); + source_metadata.tags = vec!["btw".to_string(), "review".to_string(), "kept".to_string()]; + source_metadata.custom_metadata = Some(serde_json::json!({ + "parentSessionId": "legacy-parent", + "preservedKey": "preserved-value" + })); + source_metadata.todos = Some(serde_json::json!([{ "id": "todo-1" }])); + source_metadata.unread_completion = Some("completed".to_string()); + source_metadata.needs_user_attention = Some("ask_user".to_string()); + manager + .save_session_metadata(workspace.path(), &source_metadata) + .await + .expect("source metadata update should save"); + + let result = manager + .branch_session( + workspace.path(), + &SessionBranchRequest { + source_session_id: source_session.session_id.clone(), + source_turn_id: "turn-0".to_string(), + }, + ) + .await + .expect("branch should succeed"); + + assert_ne!(result.session_id, source_session.session_id); + assert_eq!(result.session_name, "Source Title"); + assert_eq!(result.agent_type, "agentic"); + + let branched_turns = manager + .load_session_turns(workspace.path(), &result.session_id) + .await + .expect("branched turns should load"); + assert_eq!(branched_turns.len(), 1); + assert_eq!(branched_turns[0].turn_id, "turn-0"); + assert_eq!(branched_turns[0].turn_index, 0); + assert_eq!(branched_turns[0].session_id, result.session_id); + + let branched_snapshot = manager + .load_turn_context_snapshot(workspace.path(), &result.session_id, 0) + .await + .expect("branched snapshot load should succeed") + .expect("branched snapshot should exist"); + assert_eq!(branched_snapshot.len(), 1); + assert!(matches!( + &branched_snapshot[0].content, + crate::agentic::core::MessageContent::Text(text) if text == "snapshot-0" + )); + + let branched_metadata = manager + .load_session_metadata(workspace.path(), &result.session_id) + .await + .expect("branched metadata load should succeed") + .expect("branched metadata should exist"); + assert_eq!(branched_metadata.session_name, "Source Title"); + assert_eq!(branched_metadata.session_kind, SessionKind::Standard); + assert_eq!(branched_metadata.tags, vec!["kept".to_string()]); + assert!(branched_metadata.todos.is_none()); + assert!(branched_metadata.unread_completion.is_none()); + assert!(branched_metadata.needs_user_attention.is_none()); + + let custom_metadata = branched_metadata + .custom_metadata + .expect("branch should record custom metadata"); + assert_eq!(custom_metadata["preservedKey"], "preserved-value"); + assert!(custom_metadata.get("parentSessionId").is_none()); + assert_eq!( + custom_metadata["forkOrigin"], + serde_json::json!({ + "sessionId": source_session.session_id, + "turnId": "turn-0", + "turnIndex": 1 + }) + ); + } +} diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 49e2b200b..dddd29898 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -70,6 +70,164 @@ pub struct ResolvedSessionTitle { pub method: SessionTitleMethod, } +#[cfg(test)] +mod tests { + use super::{SessionManager, SessionManagerConfig}; + use crate::agentic::core::{ProcessingPhase, Session, SessionConfig, SessionState}; + use crate::agentic::persistence::PersistenceManager; + use crate::agentic::session::SessionContextStore; + use crate::infrastructure::PathManager; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("bitfun-session-restore-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + fn test_manager(persistence_manager: Arc) -> SessionManager { + SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence_manager, + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: true, + }, + ) + } + + #[tokio::test] + async fn restore_session_resets_processing_state_without_marking_unread_completion() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Legacy processing session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + persistence_manager + .save_session_state(workspace.path(), &session_id, &session.state) + .await + .expect("processing state should save"); + + let manager = test_manager(persistence_manager.clone()); + let restored = manager + .restore_session(workspace.path(), &session_id) + .await + .expect("session should restore"); + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + + assert!(matches!(restored.state, SessionState::Idle)); + assert_eq!(metadata.unread_completion, None); + } + + #[test] + fn build_messages_from_turns_skips_manual_compaction_turns() { + use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; + + let turns = vec![ + DialogTurnData::new( + "turn-1".to_string(), + 0, + "session-1".to_string(), + UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ), + DialogTurnData::new_with_kind( + DialogTurnKind::ManualCompaction, + "turn-2".to_string(), + 1, + "session-1".to_string(), + UserMessageData { + id: "user-2".to_string(), + content: "/compact".to_string(), + timestamp: 2, + metadata: None, + }, + ), + ]; + + let messages = SessionManager::build_messages_from_turns(&turns); + + assert_eq!(messages.len(), 1); + assert!(messages[0].is_actual_user_message()); + } + + #[test] + fn fallback_session_title_uses_sentence_break_when_available() { + let title = SessionManager::fallback_session_title( + "Fix the flaky integration test. Add logging for retries.", + 20, + ); + + assert_eq!(title, "Fix the flaky..."); + } + + #[test] + fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { + let title = SessionManager::fallback_session_title( + "Implement session title generation fallback", + 12, + ); + + assert_eq!(title, "Implement..."); + } + + #[test] + fn fallback_session_title_uses_default_for_blank_input() { + let title = SessionManager::fallback_session_title(" ", 20); + + assert_eq!(title, "New Session"); + } +} + /// Session manager pub struct SessionManager { /// Active sessions in memory @@ -180,6 +338,23 @@ impl SessionManager { config.workspace_path.as_ref().map(PathBuf::from) } + fn should_persist_session_kind(kind: SessionKind) -> bool { + !matches!(kind, SessionKind::Subagent) + } + + fn should_persist_session(session: &Session) -> bool { + Self::should_persist_session_kind(session.kind) + } + + pub fn should_persist_session_id(&self, session_id: &str) -> bool { + self.config.enable_persistence + && self + .sessions + .get(session_id) + .map(|session| Self::should_persist_session(&session)) + .unwrap_or(true) + } + /// Resolve the effective storage path for a session's workspace. async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { let workspace_path = config.workspace_path.as_ref()?; @@ -345,7 +520,7 @@ impl SessionManager { turn_index: usize, reason: &str, ) { - if !self.config.enable_persistence { + if !self.should_persist_session_id(session_id) { return; } @@ -673,7 +848,7 @@ impl SessionManager { self.context_store.create_session(&session_id); // 3. Persist to local path (handles remote workspaces correctly) - if self.config.enable_persistence { + if self.config.enable_persistence && Self::should_persist_session(&session) { if let Some(session) = self.sessions.get(&session_id) { self.persistence_manager .save_session(&session_storage_path, &session) @@ -705,7 +880,7 @@ impl SessionManager { session.last_activity_at = SystemTime::now(); // Persist state changes - if self.config.enable_persistence { + if self.config.enable_persistence && Self::should_persist_session(&session) { if let Some(ref workspace_path) = effective_path { self.persistence_manager .save_session_state(workspace_path, session_id, &new_state) @@ -744,7 +919,7 @@ impl SessionManager { session.last_activity_at = SystemTime::now(); } - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { let Some(workspace_path) = workspace_path.as_ref() else { return Err(BitFunError::Session(format!( "Workspace path is unavailable for session {}", @@ -815,7 +990,7 @@ impl SessionManager { ))); } - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { let effective_path = self.effective_session_workspace_path(session_id).await; if let (Some(workspace_path), Some(session)) = (effective_path, self.sessions.get(session_id)) @@ -865,7 +1040,7 @@ impl SessionManager { ))); } - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { let effective_path = self.effective_session_workspace_path(session_id).await; if let (Some(workspace_path), Some(session)) = (effective_path, self.sessions.get(session_id)) @@ -1008,13 +1183,14 @@ impl SessionManager { let trimmed = persisted_model_id.trim(); let needs_migration = if trimmed.is_empty() { false - } else if let Ok(ai_config) = get_global_config_service() - .await - .map_err(|e| BitFunError::config(e.to_string()))? - .get_config::(Some("ai")) - .await - { - !Self::is_session_model_id_usable(&ai_config, trimmed) + } else if let Ok(config_service) = get_global_config_service().await { + match config_service + .get_config::(Some("ai")) + .await + { + Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), + Err(_) => false, + } } else { false }; @@ -1042,7 +1218,8 @@ impl SessionManager { // Reset session state to Idle // After application restart, previous Processing state is invalid and must be reset - if !matches!(session.state, SessionState::Idle) { + let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); + if previous_state_was_not_idle { let old_state = session.state.clone(); session.state = SessionState::Idle; debug!( @@ -1159,6 +1336,11 @@ impl SessionManager { context_msg_count ); + // Do not infer unread completion from persisted runtime state during restore. + // Older IDE versions could leave sessions in non-idle states on disk; treating those + // as completed would surface misleading unread indicators after an upgrade. + // Unread completion is now written only by runtime completion/persist paths. + // 4. Add to memory (will overwrite if already exists) self.sessions .insert(session_id.to_string(), session.clone()); @@ -1208,7 +1390,7 @@ impl SessionManager { session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); - if self.config.enable_persistence { + if Self::should_persist_session(&session) && self.config.enable_persistence { self.persistence_manager .save_session(workspace_path, &session) .await?; @@ -1302,7 +1484,7 @@ impl SessionManager { .add_message(session_id, message.with_turn_id(turn_id.clone())); } - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { let turn_data = DialogTurnData::new_with_kind( kind, turn_id.clone(), @@ -1405,6 +1587,17 @@ impl SessionManager { final_response: String, stats: TurnStats, ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + debug!( + "Skipping dialog turn persistence for transient session completion: session_id={}, turn_id={}, response_len={}, rounds={}", + session_id, + turn_id, + final_response.len(), + stats.total_rounds + ); + return Ok(()); + } + let workspace_path = self .effective_session_workspace_path(session_id) .await @@ -1474,7 +1667,7 @@ impl SessionManager { .await; // Persist - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { self.persistence_manager .save_dialog_turn(&workspace_path, &turn) .await?; @@ -1496,6 +1689,14 @@ impl SessionManager { turn_id: &str, error: String, ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + debug!( + "Skipping dialog turn persistence for transient session failure: session_id={}, turn_id={}, error={}", + session_id, turn_id, error + ); + return Ok(()); + } + let workspace_path = self .effective_session_workspace_path(session_id) .await @@ -1530,7 +1731,7 @@ impl SessionManager { "turn_failed", ) .await; - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { self.persistence_manager .save_dialog_turn(&workspace_path, &turn) .await?; @@ -1552,6 +1753,17 @@ impl SessionManager { model_rounds: Vec, duration_ms: u64, ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + debug!( + "Skipping maintenance turn persistence for transient session completion: session_id={}, turn_id={}, rounds={}, duration_ms={}", + session_id, + turn_id, + model_rounds.len(), + duration_ms + ); + return Ok(()); + } + let workspace_path = self .effective_session_workspace_path(session_id) .await @@ -1588,7 +1800,7 @@ impl SessionManager { ) .await; - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { self.persistence_manager .save_dialog_turn(&workspace_path, &turn) .await?; @@ -1605,6 +1817,17 @@ impl SessionManager { error: String, model_rounds: Vec, ) -> BitFunResult<()> { + if !self.should_persist_session_id(session_id) { + debug!( + "Skipping maintenance turn persistence for transient session failure: session_id={}, turn_id={}, rounds={}, error={}", + session_id, + turn_id, + model_rounds.len(), + error + ); + return Ok(()); + } + let workspace_path = self .effective_session_workspace_path(session_id) .await @@ -1641,7 +1864,7 @@ impl SessionManager { ) .await; - if self.config.enable_persistence { + if self.should_persist_session_id(session_id) { self.persistence_manager .save_dialog_turn(&workspace_path, &turn) .await?; @@ -1763,7 +1986,7 @@ impl SessionManager { session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); - if self.config.enable_persistence { + if self.config.enable_persistence && Self::should_persist_session(&session) { self.persistence_manager .save_session(workspace_path, &session) .await?; @@ -1862,7 +2085,7 @@ impl SessionManager { session.compression_state = compression_state; session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); - if self.config.enable_persistence { + if self.config.enable_persistence && Self::should_persist_session(&session) { if let Some(ref workspace_path) = effective_path { self.persistence_manager .save_session(workspace_path, &session) @@ -2029,6 +2252,9 @@ impl SessionManager { for entry in sessions.iter() { let session = entry.value(); + if !Self::should_persist_session(session) { + continue; + } if let Some(workspace_path) = Self::effective_workspace_path_from_config(&session.config).await { @@ -2078,6 +2304,11 @@ impl SessionManager { // Save before deleting if enable_persistence { if let Some(session) = sessions.get(&session_id) { + if !Self::should_persist_session(&session) { + context_store.delete_session(&session_id); + sessions.remove(&session_id); + continue; + } if let Some(workspace_path) = Self::effective_workspace_path_from_config(&session.config).await { @@ -2096,69 +2327,4 @@ impl SessionManager { } } -#[cfg(test)] -mod tests { - use super::SessionManager; - use crate::service::session::{DialogTurnData, DialogTurnKind, UserMessageData}; - #[test] - fn build_messages_from_turns_skips_manual_compaction_turns() { - let turns = vec![ - DialogTurnData::new( - "turn-1".to_string(), - 0, - "session-1".to_string(), - UserMessageData { - id: "user-1".to_string(), - content: "hello".to_string(), - timestamp: 1, - metadata: None, - }, - ), - DialogTurnData::new_with_kind( - DialogTurnKind::ManualCompaction, - "turn-2".to_string(), - 1, - "session-1".to_string(), - UserMessageData { - id: "user-2".to_string(), - content: "/compact".to_string(), - timestamp: 2, - metadata: None, - }, - ), - ]; - - let messages = SessionManager::build_messages_from_turns(&turns); - - assert_eq!(messages.len(), 1); - assert!(messages[0].is_actual_user_message()); - } - - #[test] - fn fallback_session_title_uses_sentence_break_when_available() { - let title = SessionManager::fallback_session_title( - "Fix the flaky integration test. Add logging for retries.", - 20, - ); - - assert_eq!(title, "Fix the flaky..."); - } - - #[test] - fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { - let title = SessionManager::fallback_session_title( - "Implement session title generation fallback", - 12, - ); - - assert_eq!(title, "Implement..."); - } - - #[test] - fn fallback_session_title_uses_default_for_blank_input() { - let title = SessionManager::fallback_session_title(" ", 20); - - assert_eq!(title, "New Session"); - } -} diff --git a/src/crates/core/src/agentic/side_question.rs b/src/crates/core/src/agentic/side_question.rs index 41293e143..8f17bf24f 100644 --- a/src/crates/core/src/agentic/side_question.rs +++ b/src/crates/core/src/agentic/side_question.rs @@ -1,27 +1,21 @@ -//! Side question (ephemeral) service. -//! -//! This is the core implementation behind the desktop `/btw` feature: -//! - Uses existing session context (no new dialog turn, no persistence writes) -//! - Does not execute tools -//! - Supports streaming output and cancellation by request id +//! Shared `/btw` helpers and runtime-only request tracking. -use crate::agentic::coordination::ConversationCoordinator; -use crate::agentic::core::{Message as CoreMessage, MessageContent, MessageRole}; -use crate::infrastructure::ai::AIClientFactory; -use crate::util::errors::{BitFunError, BitFunResult}; -use crate::util::types::message::Message as AIMessage; - -use futures::StreamExt; -use log::{debug, warn}; +use crate::agentic::core::PromptEnvelope; use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::{mpsc, Mutex}; +use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; #[derive(Debug, Clone)] pub struct SideQuestionRuntime { tokens: Arc>>, + btw_turns: Arc>>, +} + +#[derive(Debug, Clone)] +pub struct ActiveBtwTurn { + pub session_id: String, + pub turn_id: String, } impl Default for SideQuestionRuntime { @@ -34,6 +28,7 @@ impl SideQuestionRuntime { pub fn new() -> Self { Self { tokens: Arc::new(Mutex::new(HashMap::new())), + btw_turns: Arc::new(Mutex::new(HashMap::new())), } } @@ -62,368 +57,58 @@ impl SideQuestionRuntime { } pub async fn remove(&self, request_id: &str) { - let mut guard = self.tokens.lock().await; - guard.remove(request_id); - } -} - -#[derive(Clone)] -pub struct SideQuestionService { - coordinator: Arc, - ai_client_factory: Arc, - runtime: Arc, -} - -impl SideQuestionService { - pub fn new( - coordinator: Arc, - ai_client_factory: Arc, - runtime: Arc, - ) -> Self { - Self { - coordinator, - ai_client_factory, - runtime, - } - } - - pub fn runtime(&self) -> &Arc { - &self.runtime - } - - fn core_message_to_transcript_line(msg: &CoreMessage) -> Option { - let role = match msg.role { - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - MessageRole::Tool => "Tool", - MessageRole::System => "System", - }; - - let content = match &msg.content { - MessageContent::Text(text) => text.trim().to_string(), - MessageContent::Multimodal { text, images } => { - let mut out = text.trim().to_string(); - if !images.is_empty() { - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&format!("[{} image(s) omitted]", images.len())); - } - out - } - MessageContent::ToolResult { - tool_name, - result_for_assistant, - result, - is_error, - .. - } => { - let mut out = String::new(); - out.push_str(&format!( - "Tool result: name={}, is_error={}", - tool_name, is_error - )); - if let Some(text) = result_for_assistant - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - { - out.push('\n'); - out.push_str(text); - } else if !result.is_null() { - if let Ok(json) = serde_json::to_string_pretty(result) { - out.push('\n'); - out.push_str(&json); - } - } - out - } - MessageContent::Mixed { text, .. } => text.trim().to_string(), - }; - - let content = content.trim(); - if content.is_empty() { - return None; - } - Some(format!("{}:\n{}", role, content)) - } - - fn build_user_prompt(context: &[CoreMessage], question: &str) -> String { - let mut lines: Vec = Vec::new(); - for msg in context { - if let Some(line) = Self::core_message_to_transcript_line(msg) { - lines.push(line); - } - } - - format!( - "CONTEXT (recent messages):\n\n{}\n\n---\n\nSIDE QUESTION:\n{}\n", - lines.join("\n\n"), - question.trim() - ) - } - - async fn load_context_messages( - &self, - session_id: &str, - max_context_messages: usize, - ) -> BitFunResult> { - let session_manager = self.coordinator.get_session_manager(); - let mut context_messages = session_manager.get_context_messages(session_id).await?; - - if context_messages.len() > max_context_messages { - context_messages = context_messages - .split_off(context_messages.len().saturating_sub(max_context_messages)); + { + let mut guard = self.tokens.lock().await; + guard.remove(request_id); } - - Ok(context_messages) - } - - fn system_prompt() -> &'static str { - "You are answering a side question about the ongoing chat.\n\ -Rules:\n\ -- Use only the information present in the provided CONTEXT.\n\ -- Do not call tools, do not browse, do not assume access to files or runtime.\n\ -- If the context is insufficient, say what is missing.\n\ -- Reply concisely, matching the question's language.\n" + let mut btw_turns = self.btw_turns.lock().await; + btw_turns.remove(request_id); } - pub async fn ask( + pub async fn register_btw_turn( &self, - session_id: &str, - question: &str, - model_id: Option<&str>, - max_context_messages: Option, - ) -> BitFunResult { - if session_id.trim().is_empty() { - return Err(BitFunError::Validation( - "session_id is required".to_string(), - )); - } - if question.trim().is_empty() { - return Err(BitFunError::Validation("question is required".to_string())); - } - - let max_context_messages = max_context_messages.unwrap_or(60).clamp(10, 200); - let model_id = model_id - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("fast"); - - let context_messages = self - .load_context_messages(session_id, max_context_messages) - .await?; - - let user_prompt = Self::build_user_prompt(&context_messages, question); - - let client = self - .ai_client_factory - .get_client_resolved(model_id) - .await - .map_err(|e| BitFunError::service(format!("Failed to create AI client: {}", e)))?; - - let messages = vec![ - AIMessage::system(Self::system_prompt().to_string()), - AIMessage::user(user_prompt), - ]; - - let response = client - .send_message(messages, None) - .await - .map_err(|e| BitFunError::service(format!("AI call failed: {}", e)))?; - - Ok(response.text.trim().to_string()) + request_id: String, + session_id: String, + turn_id: String, + ) { + let mut guard = self.btw_turns.lock().await; + guard.insert( + request_id, + ActiveBtwTurn { + session_id, + turn_id, + }, + ); } - pub async fn cancel(&self, request_id: &str) { - self.runtime.cancel(request_id).await + pub async fn get_btw_turn(&self, request_id: &str) -> Option { + let guard = self.btw_turns.lock().await; + guard.get(request_id).cloned() } +} - pub async fn start_stream( - &self, - request: SideQuestionStreamRequest, - ) -> BitFunResult> { - if request.request_id.trim().is_empty() { - return Err(BitFunError::Validation( - "request_id is required".to_string(), - )); - } - if request.session_id.trim().is_empty() { - return Err(BitFunError::Validation( - "session_id is required".to_string(), - )); - } - if request.question.trim().is_empty() { - return Err(BitFunError::Validation("question is required".to_string())); - } - - let max_context_messages = request.max_context_messages.unwrap_or(60).clamp(10, 200); - let model_id = request - .model_id - .as_deref() - .map(str::trim) - .filter(|s| !s.is_empty()) - .unwrap_or("fast") - .to_string(); - - let context_messages = self - .load_context_messages(&request.session_id, max_context_messages) - .await?; - let user_prompt = Self::build_user_prompt(&context_messages, &request.question); - - let client = self - .ai_client_factory - .get_client_resolved(&model_id) - .await - .map_err(|e| BitFunError::service(format!("Failed to create AI client: {}", e)))?; - - let messages = vec![ - AIMessage::system(Self::system_prompt().to_string()), - AIMessage::user(user_prompt), - ]; - - let cancel_token = self.runtime.register(request.request_id.clone()).await; - - let (tx, rx) = mpsc::unbounded_channel(); - let request_id = request.request_id.clone(); - let session_id = request.session_id.clone(); - let question = request.question.clone(); - let persist_target = request.persist_target.clone(); - let coordinator = self.coordinator.clone(); - let runtime = self.runtime.clone(); - - tokio::spawn(async move { - let mut full_text = String::new(); - let mut last_finish_reason: Option = None; - - let mut stream = match client.send_message_stream(messages, None).await { - Ok(resp) => resp.stream, - Err(e) => { - let _ = tx.send(SideQuestionStreamEvent::Error { - request_id, - session_id, - error: format!("AI call failed: {}", e), - }); - return; - } - }; - - while let Some(chunk_result) = stream.next().await { - if cancel_token.is_cancelled() { - debug!("Side question cancelled: request_id={}", request_id); - break; - } - - match chunk_result { - Ok(chunk) => { - if let Some(reason) = chunk.finish_reason.clone() { - last_finish_reason = Some(reason); - } - if let Some(text) = chunk.text { - if !text.is_empty() { - full_text.push_str(&text); - let _ = tx.send(SideQuestionStreamEvent::TextChunk { - request_id: request_id.clone(), - session_id: session_id.clone(), - text, - }); - } - } - } - Err(e) => { - let _ = tx.send(SideQuestionStreamEvent::Error { - request_id, - session_id, - error: format!("Stream error: {}", e), - }); - return; - } - } - } - - // Cleanup token record. - runtime.remove(&request_id).await; - - if cancel_token.is_cancelled() { - // No completion event on cancellation; caller may have already updated UI state. - return; - } - - if full_text.trim().is_empty() { - warn!( - "Side question stream completed with empty output: request_id={}", - request_id - ); - } - - if let Some(target) = persist_target { - if let Err(error) = coordinator - .persist_btw_turn( - &target.workspace_path, - &target.child_session_id, - &request_id, - &question, - full_text.trim(), - &target.parent_session_id, - target.parent_dialog_turn_id.as_deref(), - target.parent_turn_index, - ) - .await - { - warn!( - "Failed to persist side-question turn: child_session_id={}, request_id={}, error={}", - target.child_session_id, request_id, error - ); - } - } - - let _ = tx.send(SideQuestionStreamEvent::Completed { - request_id, - session_id, - full_text: full_text.trim().to_string(), - finish_reason: last_finish_reason, - }); - }); +pub fn btw_system_reminder() -> &'static str { + r#"This is a side question from the user. You must answer this question directly. - Ok(rx) - } -} +IMPORTANT CONTEXT: +- You are a separate, lightweight agent spawned to answer this question +- The main agent is NOT interrupted - it continues working independently in the background +- You share the conversation context but are a completely separate instance +- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect -#[derive(Debug, Clone)] -pub struct SideQuestionStreamRequest { - pub request_id: String, - pub session_id: String, - pub question: String, - pub model_id: Option, - pub max_context_messages: Option, - pub persist_target: Option, -} +CRITICAL CONSTRAINTS: +- Use tools only when necessary to answer the question correctly +- You should answer the question directly, using what you already know from the conversation context as your starting point +- Do NOT say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action unless you actually take that action in this side thread +- If you don't know the answer, say so clearly - do not pretend you already checked something +- Reply concisely and match the user's language -#[derive(Debug, Clone)] -pub struct SideQuestionPersistTarget { - pub child_session_id: String, - pub workspace_path: PathBuf, - pub parent_session_id: String, - pub parent_dialog_turn_id: Option, - pub parent_turn_index: Option, +Simply answer the question with the information you have, and use tools only when needed."# } -#[derive(Debug, Clone)] -pub enum SideQuestionStreamEvent { - TextChunk { - request_id: String, - session_id: String, - text: String, - }, - Completed { - request_id: String, - session_id: String, - full_text: String, - finish_reason: Option, - }, - Error { - request_id: String, - session_id: String, - error: String, - }, +pub fn build_btw_user_input(question: &str) -> String { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(btw_system_reminder()); + envelope.push_user_query(question.trim()); + envelope.render() } diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/crates/core/src/agentic/system.rs similarity index 60% rename from src/apps/cli/src/agent/agentic_system.rs rename to src/crates/core/src/agentic/system.rs index d7184af44..8fcab412f 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/crates/core/src/agentic/system.rs @@ -1,29 +1,32 @@ -//! Agentic System Initialization for CLI -//! -//! Initialize the complete agentic system, including coordinator, execution engine, session management, etc. +//! Agentic system assembly shared by CLI, ACP, and other hosts. -use anyhow::Result; -use bitfun_core::infrastructure::ai::AIClientFactory; use std::sync::Arc; -// Import all agentic system modules -use bitfun_core::agentic::coordination; -use bitfun_core::agentic::events; -use bitfun_core::agentic::execution; -use bitfun_core::agentic::persistence; -use bitfun_core::agentic::session; -use bitfun_core::agentic::tools; -use bitfun_core::infrastructure::try_get_path_manager_arc; +use anyhow::Result; +use log::info; + +use crate::agentic::coordination; +use crate::agentic::events; +use crate::agentic::execution; +use crate::agentic::persistence; +use crate::agentic::session; +use crate::agentic::tools; +use crate::infrastructure::ai::AIClientFactory; +use crate::infrastructure::try_get_path_manager_arc; -/// Agentic system state +/// Agentic runtime state shared by host adapters. +#[derive(Clone)] pub struct AgenticSystem { pub coordinator: Arc, pub event_queue: Arc, } -/// Initialize Agentic system +/// Initialize the agentic runtime and register the global coordinator. pub async fn init_agentic_system() -> Result { - tracing::info!("Initializing Agentic system"); + info!("Initializing agentic system"); + + use crate::service::config::get_global_config_service; + use crate::service::config::types::GlobalConfig; let _ai_client_factory = AIClientFactory::get_global().await?; @@ -38,7 +41,7 @@ pub async fn init_agentic_system() -> Result { let session_manager = Arc::new(session::SessionManager::new( context_store, - persistence_manager.clone(), + persistence_manager, Default::default(), )); @@ -56,12 +59,27 @@ pub async fn init_agentic_system() -> Result { event_queue.clone(), tool_pipeline.clone(), )); + + // Get execution config from global settings + let exec_config = match get_global_config_service().await { + Ok(config_service) => { + match config_service.get_config::(None).await { + Ok(global_config) => execution::ExecutionEngineConfig { + max_rounds: global_config.ai.max_rounds, + ..Default::default() + }, + Err(_) => Default::default(), + } + }, + Err(_) => Default::default(), + }; + let execution_engine = Arc::new(execution::ExecutionEngine::new( round_executor, event_queue.clone(), session_manager.clone(), context_compressor, - Default::default(), + exec_config, )); let coordinator = Arc::new(coordination::ConversationCoordinator::new( @@ -69,11 +87,11 @@ pub async fn init_agentic_system() -> Result { execution_engine, tool_pipeline, event_queue.clone(), - event_router.clone(), + event_router, )); coordination::ConversationCoordinator::set_global(coordinator.clone()); - tracing::info!("Agentic system initialization complete"); + info!("Agentic system initialization complete"); Ok(AgenticSystem { coordinator, diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 67a5bd8ab..abeb3c64c 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -96,7 +96,8 @@ impl ToolUseContext { } pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { - self.runtime_tool_restrictions.ensure_tool_allowed(tool_name) + self.runtime_tool_restrictions + .ensure_tool_allowed(tool_name) } pub fn enforce_path_operation( diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index a9da4feb6..81f806e85 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -732,6 +732,7 @@ Usage notes: } // 3. Foreground: get or create the primary terminal session + let terminal_ready_started_at = Instant::now(); let primary_session_id = binding .get_or_create( chat_session_id, @@ -750,6 +751,7 @@ Usage notes: ) .await .map_err(|e| BitFunError::tool(format!("Failed to create Terminal session: {}", e)))?; + let terminal_ready_ms = elapsed_ms_u64(terminal_ready_started_at); Self::emit_terminal_ready_event(&tool_use_id, &primary_session_id); @@ -793,7 +795,10 @@ Usage notes: let mut final_exit_code: Option = None; let mut was_interrupted = false; let mut timed_out = false; + let mut command_started_after_ms: Option = None; + let mut completion_reason_label = "stream_end".to_string(); let mut interrupt_drain_deadline: Option = None; + let command_stream_started_at = Instant::now(); // Get event system for sending progress let event_system = get_global_event_system(); @@ -847,6 +852,7 @@ Usage notes: match event { CommandStreamEvent::Started { command_id } => { + command_started_after_ms = Some(elapsed_ms_u64(command_stream_started_at)); debug!("Bash command started execution, command_id: {}", command_id); } CommandStreamEvent::Output { data } => { @@ -879,6 +885,7 @@ Usage notes: ); final_exit_code = exit_code.or(final_exit_code); timed_out = completion_reason == CommandCompletionReason::TimedOut; + completion_reason_label = format!("{:?}", completion_reason); if !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)) { was_interrupted = true; @@ -904,6 +911,21 @@ Usage notes: // 6. Build result let execution_time_ms = elapsed_ms_u64(start_time); + let command_stream_ms = elapsed_ms_u64(command_stream_started_at); + info!( + "Bash command completed: tool_id={}, terminal_session_id={}, duration_ms={}, terminal_ready_ms={}, command_started_after_ms={:?}, command_stream_ms={}, output_bytes={}, exit_code={:?}, interrupted={}, timed_out={}, completion_reason={}", + tool_use_id, + primary_session_id, + execution_time_ms, + terminal_ready_ms, + command_started_after_ms, + command_stream_ms, + accumulated_output.len(), + final_exit_code, + was_interrupted, + timed_out, + completion_reason_label + ); let result_data = json!({ "success": final_exit_code.unwrap_or(-1) == 0, diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 51f513237..075950dc0 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -245,7 +245,41 @@ impl CodeReviewTool { }, "needs_decision": { "type": "array", - "items": { "type": "string" } + "description": "Items needing user/product judgment. Each item should be an object with a 'question' and 'plan'.", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The specific decision the user needs to make" + }, + "plan": { + "type": "string", + "description": "The remediation plan text to execute if the user approves" + }, + "options": { + "type": "array", + "description": "2-4 possible choices or approaches", + "items": { "type": "string" } + }, + "tradeoffs": { + "type": "string", + "description": "Brief explanation of trade-offs between options" + }, + "recommendation": { + "type": "integer", + "description": "Index of the recommended option (0-based), if any" + } + }, + "required": ["question", "plan"] + }, + { + "type": "string" + } + ] + } }, "verification": { "type": "array", diff --git a/src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs index 70023e857..38bdf841a 100644 --- a/src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -1426,7 +1426,7 @@ impl ComputerUseActions { let mut chosen_cmd = String::new(); let mut chosen_args: Vec = vec![]; for (cmd, args) in &attempts { - match std::process::Command::new(cmd).args(args).output() { + match crate::util::process_manager::create_command(cmd).args(args).output() { Ok(out) => { if out.status.success() { chosen_cmd = cmd.clone(); @@ -2056,7 +2056,7 @@ fn read_os_version() -> Option { } #[cfg(target_os = "windows")] { - let out = std::process::Command::new("cmd") + let out = crate::util::process_manager::create_command("cmd") .args(["/C", "ver"]) .output() .ok()?; diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index 8ca403580..d28563a5f 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -1,8 +1,8 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::workspace_paths::is_bitfun_runtime_uri; +use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use log::debug; diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index c5240be0b..b435a57e8 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -7,6 +7,9 @@ use tool_runtime::fs::edit_file::{apply_edit_to_content, edit_file}; pub struct FileEditTool; +const LARGE_EDIT_SOFT_LINE_LIMIT: usize = 200; +const LARGE_EDIT_SOFT_BYTE_LIMIT: usize = 20 * 1024; + impl Default for FileEditTool { fn default() -> Self { Self::new() @@ -35,7 +38,7 @@ Usage: - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. -- Keep edits focused. Avoid replacing huge multi-hundred-line blocks in one call when a smaller targeted edit would work. +- Keep edits focused. The 200-line / 20KB guideline is a soft reliability threshold, not a hard cap. If a large change is required, split it into several focused Edit calls by section, function, or component instead of truncating or doing one huge replacement. - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."# .to_string()) } @@ -51,11 +54,11 @@ Usage: "old_string": { "type": "string", "default": "", - "description": "The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation). Include enough surrounding context to avoid broad replacements." + "description": "The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation). Include enough surrounding context to avoid broad replacements, but avoid huge multi-hundred-line old_string payloads." }, "new_string": { "type": "string", - "description": "The text to replace it with (must be different from old_string). Keep edits targeted; avoid replacing huge multi-hundred-line blocks in one call when smaller edits are possible." + "description": "The text to replace it with (must be different from old_string). Keep edits targeted. The 200-line / 20KB guideline is a soft reliability threshold; for larger changes, split the work into several focused Edit calls by section, function, or component." }, "replace_all": { "type": "boolean", @@ -138,6 +141,35 @@ Usage: } } + let old_string = input + .get("old_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let new_string = input + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let largest_lines = old_string.lines().count().max(new_string.lines().count()); + let largest_bytes = old_string.len().max(new_string.len()); + if largest_lines > LARGE_EDIT_SOFT_LINE_LIMIT || largest_bytes > LARGE_EDIT_SOFT_BYTE_LIMIT + { + return ValidationResult { + result: true, + message: Some(format!( + "Large Edit payload: largest side is {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: split the change into several focused Edit calls by section, function, or component instead of one huge replacement.", + largest_lines, largest_bytes + )), + error_code: None, + meta: Some(json!({ + "large_edit": true, + "largest_line_count": largest_lines, + "largest_byte_count": largest_bytes, + "soft_line_limit": LARGE_EDIT_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_EDIT_SOFT_BYTE_LIMIT + })), + }; + } + ValidationResult::default() } diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index 653c6ec78..32dfe0f0c 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -25,9 +25,9 @@ impl Default for FileReadTool { impl FileReadTool { pub fn new() -> Self { Self { - default_max_lines_to_read: 250, - max_line_chars: 300, - max_total_chars: 32_000, + default_max_lines_to_read: 2000, + max_line_chars: 2000, + max_total_chars: 50_000, } } @@ -165,13 +165,14 @@ Assume this tool is able to read all files on the machine. If the User provides Usage: - The file_path parameter must be either an absolute path or an exact `bitfun://runtime/...` URI returned by another tool. -- By default, it reads up to {} lines starting from the beginning of the file. +- By default, it reads up to {} lines starting from the beginning of the file. - You can optionally specify a start_line and limit. For large files, prefer reading targeted ranges instead of starting over from the beginning every time. - Any lines longer than {} characters will be truncated. - Total output is capped at {} characters. If that limit is hit, narrow the range with start_line and limit. -- Results are returned using cat -n format, with line numbers starting at 1 +- Results are returned using cat -n format, with line numbers starting at 1. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. +- Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window. "#, self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars )) @@ -400,8 +401,17 @@ Usage: result_for_assistant.push_str(rules_content); } - if read_file_result.hit_total_char_limit { - result_for_assistant.push_str("\n\n[Output truncated after reaching the Read tool size limit. Request a narrower range with start_line and limit.]"); + let has_more = read_file_result.end_line < read_file_result.total_lines; + if has_more { + let next_start = read_file_result.end_line + 1; + if read_file_result.hit_total_char_limit { + result_for_assistant.push_str( + &format!("\n\n[Output truncated after reaching the Read tool size limit. Use start_line={} and limit to continue reading.]", next_start)); + } else { + result_for_assistant.push_str( + &format!("\n\n[Showing lines {}-{} of {} total. Use start_line={} and limit to continue reading.]", + read_file_result.start_line, read_file_result.end_line, read_file_result.total_lines, next_start)); + } } let lines_read = if read_file_result.total_lines == 0 diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 97977fa91..6fa5d5eab 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -10,6 +10,9 @@ use tokio::fs; pub struct FileWriteTool; +const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; +const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; + impl Default for FileWriteTool { fn default() -> Self { Self::new() @@ -36,7 +39,8 @@ Usage: - If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. - The file_path parameter must be either an absolute path or an exact `bitfun://runtime/...` URI returned by another tool. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- Keep writes focused. Avoid sending hundreds of lines in one Write call; prefer Read + Edit for targeted updates, and split large rewrites into smaller chunks when possible. +- Keep writes focused. The 200-line / 20KB guideline is a soft reliability threshold, not a hard cap. If a task genuinely needs more content, preserve correctness and use a staged plan instead of truncating. +- For existing files, prefer Read + targeted Edit calls. For large new files or rewrites, write the stable scaffold first, then fill or revise sections with focused Edit calls. Do not replace an entire existing file just to change a few sections. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked."#.to_string()) } @@ -51,7 +55,7 @@ Usage: }, "content": { "type": "string", - "description": "The content to write to the file. Keep writes focused; avoid sending hundreds of lines in one call. Prefer Read + Edit for targeted changes, and split large rewrites into smaller chunks when possible." + "description": "The content to write to the file. 200 lines / 20KB is a soft reliability threshold, not a hard cap. For existing files prefer Read + focused Edit calls. For large new files, write a stable scaffold first, then add sections in follow-up focused edits unless a complete initial body is required." } }, "required": ["file_path", "content"], @@ -97,6 +101,22 @@ Usage: }; } + let large_write_warning = + input + .get("content") + .and_then(|v| v.as_str()) + .and_then(|content| { + let line_count = content.lines().count(); + let byte_count = content.len(); + if line_count > LARGE_WRITE_SOFT_LINE_LIMIT + || byte_count > LARGE_WRITE_SOFT_BYTE_LIMIT + { + Some((line_count, byte_count)) + } else { + None + } + }); + if let Some(ctx) = context { let resolved = match ctx.resolve_tool_path(file_path) { Ok(resolved) => resolved, @@ -120,6 +140,24 @@ Usage: } } + if let Some((line_count, byte_count)) = large_write_warning { + return ValidationResult { + result: true, + message: Some(format!( + "Large Write payload: {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: for existing files use Read + focused Edit calls; for large new files write a stable scaffold first, then add sections in follow-up edits unless a complete initial body is required.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_write": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_WRITE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_WRITE_SOFT_BYTE_LIMIT + })), + }; + } + ValidationResult::default() } diff --git a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs index d6eabfdd6..f5d5127d6 100644 --- a/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -8,6 +8,9 @@ use serde_json::{json, Value}; pub struct GenerativeUITool; +const LARGE_WIDGET_CODE_SOFT_LINE_LIMIT: usize = 260; +const LARGE_WIDGET_CODE_SOFT_BYTE_LIMIT: usize = 28 * 1024; + struct ThemePromptSnapshot { id: &'static str, theme_type: &'static str, @@ -272,6 +275,7 @@ Input rules: 4. Put CSS first, then HTML, then scripts last so the preview can stream progressively. 5. Keep the first useful content visible early. Avoid giant style blocks. 6. Prefer self-contained widgets. CDN scripts are allowed when needed, but keep them minimal. +6a. Keep `widget_code` compact. The 260-line / 28KB guideline is a soft reliability threshold, not a hard cap. If the widget is larger, reduce repeated static DOM with data-driven loops, shared CSS classes, and simpler markup rather than truncating required behavior. 7. If the user only needs text, do not use this tool. 8. Prefer compact, scroll-light layouts. Avoid large CSS resets, fixed overlays, oversized app chrome, and nested scrolling. 9. IMPORTANT sizing rule: the default target is an inline FlowChat card, not a full browser page. Build responsive widgets that fit a narrow card without horizontal scrolling. @@ -328,7 +332,7 @@ Input rules: "widget_code": { "type": "string", "description": format!( - "Raw HTML fragment or raw SVG. No Markdown code fences. For HTML: no , , , or . {} If the widget should match BitFun, rely on the host CSS variables instead of hard-coded colors or spacing. If the user asked for file navigation, do not finish this field until each clickable node has verified file metadata or is intentionally non-clickable.", + "Raw HTML fragment or raw SVG. No Markdown code fences. For HTML: no , , , or . The 260-line / 28KB guideline is a soft reliability threshold. For larger widgets, use data-driven loops, shared CSS classes, and simpler markup rather than truncating required behavior. {} If the widget should match BitFun, rely on the host CSS variables instead of hard-coded colors or spacing. If the user asked for file navigation, do not finish this field until each clickable node has verified file metadata or is intentionally non-clickable.", Self::combined_reminder() ) }, @@ -454,6 +458,28 @@ Input rules: }; } + let line_count = widget_code.lines().count(); + let byte_count = widget_code.len(); + if line_count > LARGE_WIDGET_CODE_SOFT_LINE_LIMIT + || byte_count > LARGE_WIDGET_CODE_SOFT_BYTE_LIMIT + { + return ValidationResult { + result: true, + message: Some(format!( + "Large GenerativeUI widget_code: {} lines, {} bytes. This is allowed when necessary, but prefer a staged design approach: keep the first version compact, use data-driven loops/shared classes, and iterate rather than emitting a huge static widget payload.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_widget_code": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_WIDGET_CODE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_WIDGET_CODE_SOFT_BYTE_LIMIT + })), + }; + } + ValidationResult::default() } diff --git a/src/crates/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/core/src/agentic/tools/implementations/git_tool.rs index a742f2bdc..aa55c33bf 100644 --- a/src/crates/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/git_tool.rs @@ -6,8 +6,8 @@ use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service::git::{ - execute_git_command, GitAddParams, GitCommitParams, GitDiffParams, GitLogParams, GitPullParams, - GitPushParams, GitService, + execute_git_command, execute_git_command_raw, GitAddParams, GitCommitParams, GitDiffParams, + GitLogParams, GitPullParams, GitPushParams, GitService, }; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -247,6 +247,9 @@ impl GitTool { let refs = args_str[..sep_pos].trim(); let files = args_str[sep_pos + GIT_DIFF_FILE_SEPARATOR.len()..].trim(); (refs, Some(files)) + } else if let Some(stripped) = args_str.strip_prefix("-- ") { + // Handle "-- file1 file2" (no leading space before --) + ("", Some(stripped.trim())) } else { (args_str.trim(), None) }; @@ -324,10 +327,19 @@ impl GitTool { .await .map_err(|e| BitFunError::tool(format!("Git diff failed: {}", e)))?; + // When there are no differences, git diff returns exit code 0 with an + // empty stdout. Return a friendly message so the model (and user) see + // a clear "no changes" indication instead of a bare empty string. + let stdout = if diff_output.trim().is_empty() { + "No differences found.".to_string() + } else { + diff_output + }; + Ok(json!({ "success": true, "exit_code": 0, - "stdout": diff_output, + "stdout": stdout, "stderr": "" })) } @@ -663,23 +675,38 @@ impl GitTool { let start_time = std::time::Instant::now(); - match execute_git_command(repo_path, &cmd_args).await { - Ok(output) => { + // Use raw execution so we can distinguish git diff exit code 1 (has differences) + // from actual errors. + match execute_git_command_raw(repo_path, &cmd_args).await { + Ok(raw) => { let duration = elapsed_ms_u64(start_time); + + // git diff returns exit code 1 when there are differences, which is not an error. + // Other commands may also use exit code 1 for non-error conditions (e.g. grep with no matches). + // We treat exit code 0 and exit code 1 with non-empty stdout as success, + // but exit code >1 or exit code 1 with empty stdout and non-empty stderr as failure. + let is_diff_like = operation == "diff"; + let success = if raw.exit_code == 0 { + true + } else if is_diff_like && raw.exit_code == 1 && !raw.stdout.is_empty() { + true + } else { + false + }; + Ok(json!({ - "success": true, - "exit_code": 0, - "stdout": output, - "stderr": "", + "success": success, + "exit_code": raw.exit_code, + "stdout": raw.stdout, + "stderr": raw.stderr, "execution_time_ms": duration })) } Err(e) => { let duration = elapsed_ms_u64(start_time); - // Git command failed but still return result Ok(json!({ "success": false, - "exit_code": 1, + "exit_code": -1, "stdout": "", "stderr": e.to_string(), "execution_time_ms": duration @@ -762,6 +789,12 @@ This tool provides a safe and convenient way to execute Git commands. It support {"operation": "switch", "args": "main"} ``` +## Important: `args` Field Rules + +- The `operation` field already specifies the Git subcommand (e.g. `diff`, `log`, `add`). +- The `args` field must contain **only additional arguments** for that subcommand. +- **Do NOT include the subcommand name itself in `args`.** For example, use `{"operation": "diff", "args": "HEAD~2..HEAD --stat"}` — NOT `{"operation": "diff", "args": "diff HEAD~2..HEAD --stat"}`. + ## Safety Notes - This tool validates operations to ensure only allowed Git commands are executed @@ -1007,6 +1040,20 @@ When creating commits, use this format for the commit message: let args = input.get("args").and_then(|v| v.as_str()); + // Tolerance: strip a leading operation name from args if the model + // mistakenly includes it (e.g. "diff HEAD~2..HEAD --stat" when + // operation is already "diff"). This prevents commands like + // "git diff diff HEAD~2..HEAD --stat". + let args = args.map(|a| { + let trimmed = a.trim(); + let prefix = format!("{} ", operation); + if trimmed.starts_with(&prefix) { + &trimmed[prefix.len()..] + } else { + trimmed + } + }); + let working_directory = input.get("working_directory").and_then(|v| v.as_str()); // Get repository path diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 278c73e09..f30f19202 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -1,4 +1,7 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::search::{ + get_global_workspace_search_service, workspace_search_runtime_available, GlobSearchRequest, +}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use globset::{GlobBuilder, GlobMatcher}; @@ -565,6 +568,48 @@ impl Tool for GlobTool { } let resolved_str = resolved.resolved_path.clone(); + + if workspace_search_runtime_available().await { + if let Some(search_service) = get_global_workspace_search_service() { + let workspace_root = context + .workspace + .as_ref() + .map(|workspace| PathBuf::from(workspace.root_path_string())) + .ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when Glob path is omitted".to_string(), + ) + })?; + let resolved_path = PathBuf::from(&resolved_str); + let glob_result = search_service + .glob(GlobSearchRequest { + repo_root: workspace_root.clone(), + search_path: (resolved_path != workspace_root).then_some(resolved_path), + pattern: pattern.to_string(), + limit, + }) + .await?; + + let result_text = if glob_result.paths.is_empty() { + format!("No files found matching pattern '{}'", pattern) + } else { + glob_result.paths.join("\n") + }; + + return Ok(vec![ToolResult::Result { + data: json!({ + "pattern": pattern, + "path": resolved_str, + "matches": glob_result.paths, + "match_count": glob_result.paths.len(), + "repo_phase": glob_result.repo_status.phase, + "rebuild_recommended": glob_result.repo_status.rebuild_recommended + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]); + } + } let resolved_str_for_rg = resolved_str.clone(); let pattern_for_rg = pattern.to_string(); let matches = tokio::task::spawn_blocking(move || { diff --git a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs index 364f6f0da..37d2e905d 100644 --- a/src/crates/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/grep_tool.rs @@ -1,9 +1,16 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::service::search::{ + get_global_workspace_search_service, workspace_search_runtime_available, + ContentSearchOutputMode, ContentSearchRequest, WorkspaceSearchHit, WorkspaceSearchLine, +}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; +use std::collections::HashSet; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; +use std::time::Instant; use tool_runtime::search::grep_search::{ grep_search, GrepOptions, GrepSearchResult, OutputMode, ProgressCallback, }; @@ -23,12 +30,31 @@ impl GrepTool { Self } + fn explicit_head_limit(input: &Value) -> Option> { + input + .get("head_limit") + .and_then(|v| v.as_u64()) + .map(|value| { + if value == 0 { + None + } else { + Some(value as usize) + } + }) + } + fn resolve_head_limit(input: &Value) -> Option { - match input.get("head_limit").and_then(|v| v.as_u64()) { - Some(0) => None, - Some(value) => Some(value as usize), - None => Some(DEFAULT_HEAD_LIMIT), - } + Self::explicit_head_limit(input).unwrap_or(Some(DEFAULT_HEAD_LIMIT)) + } + + fn backend_max_results( + input: &Value, + offset: usize, + _display_head_limit: Option, + ) -> Option { + Self::explicit_head_limit(input) + .flatten() + .map(|limit| limit.saturating_add(offset)) } fn shell_escape(value: &str) -> String { @@ -305,11 +331,195 @@ impl GrepTool { Ok(options) } + + fn build_workspace_search_request( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult<(ContentSearchRequest, String, bool, usize, Option)> { + let workspace_root = context + .workspace + .as_ref() + .map(|workspace| PathBuf::from(workspace.root_path_string())) + .ok_or_else(|| BitFunError::tool("Workspace is required for Grep".to_string()))?; + + let pattern = input + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("pattern is required".to_string()))?; + let search_path = input.get("path").and_then(|v| v.as_str()).unwrap_or("."); + let resolved_path = context.resolve_workspace_tool_path(search_path)?; + let resolved_path_buf = PathBuf::from(&resolved_path); + let output_mode = input + .get("output_mode") + .and_then(|v| v.as_str()) + .unwrap_or("files_with_matches") + .to_string(); + let show_line_numbers = input + .get("-n") + .and_then(|v| v.as_bool()) + .unwrap_or(output_mode == "content"); + let offset = Self::resolve_offset(input); + let head_limit = Self::resolve_head_limit(input); + let max_results = Self::backend_max_results(input, offset, head_limit); + let before_context = input.get("-B").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let after_context = input.get("-A").and_then(|v| v.as_u64()).unwrap_or(0) as usize; + let shared_context = input + .get("context") + .or_else(|| input.get("-C")) + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + let globs = Self::parse_glob_patterns(input.get("glob").and_then(|v| v.as_str())); + let file_types = input + .get("type") + .and_then(|v| v.as_str()) + .map(|value| vec![value.to_string()]) + .unwrap_or_default(); + let output_mode_enum = match output_mode.as_str() { + "content" => ContentSearchOutputMode::Content, + "count" => ContentSearchOutputMode::Count, + _ => ContentSearchOutputMode::FilesWithMatches, + }; + let request = ContentSearchRequest { + repo_root: workspace_root.clone(), + search_path: (resolved_path_buf != workspace_root).then_some(resolved_path_buf), + pattern: pattern.to_string(), + output_mode: output_mode_enum, + case_sensitive: !input.get("-i").and_then(|v| v.as_bool()).unwrap_or(false), + use_regex: true, + whole_word: false, + multiline: input + .get("multiline") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + before_context: if shared_context > 0 { + shared_context + } else { + before_context + }, + after_context: if shared_context > 0 { + shared_context + } else { + after_context + }, + max_results, + globs, + file_types, + exclude_file_types: Vec::new(), + }; + + Ok((request, output_mode, show_line_numbers, offset, head_limit)) + } + + fn format_workspace_search_output( + &self, + output_mode: &str, + show_line_numbers: bool, + offset: usize, + head_limit: Option, + result: &crate::service::search::ContentSearchResult, + display_base: Option<&str>, + ) -> (String, usize, usize) { + match output_mode { + "content" => { + let mut lines = + render_workspace_search_content_lines(&result.hits, show_line_numbers); + apply_offset_and_limit(&mut lines, offset, head_limit); + let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + let file_count = result + .hits + .iter() + .map(|hit| hit.path.as_str()) + .collect::>() + .len(); + (rendered, file_count, result.matched_occurrences) + } + "count" => { + let mut lines = result + .file_counts + .iter() + .map(|count| format!("{}:{}", count.path, count.matched_lines)) + .collect::>(); + lines.sort(); + let mut lines = lines.into_iter().collect::>(); + apply_offset_and_limit(&mut lines, offset, head_limit); + let rendered = Self::relativize_result_text(&lines.join("\n"), display_base); + (rendered, result.file_counts.len(), result.matched_lines) + } + _ => { + let mut files = result + .outcome + .results + .iter() + .map(|item| item.path.clone()) + .collect::>(); + files.sort(); + files.dedup(); + apply_offset_and_limit(&mut files, offset, head_limit); + let rendered = Self::relativize_result_text(&files.join("\n"), display_base); + let total_matches = files.len(); + (rendered, total_matches, total_matches) + } + } + } +} + +fn render_workspace_search_content_lines( + hits: &[WorkspaceSearchHit], + show_line_numbers: bool, +) -> Vec { + let mut lines = Vec::new(); + for hit in hits { + for line in &hit.lines { + match line { + WorkspaceSearchLine::Match { value } => { + let snippet = value.snippet.trim_end(); + if show_line_numbers { + lines.push(format!("{}:{}:{}", hit.path, value.location.line, snippet)); + } else { + lines.push(format!("{}:{}", hit.path, snippet)); + } + } + WorkspaceSearchLine::Context { value } => { + let snippet = value.snippet.trim_end(); + if show_line_numbers { + lines.push(format!("{}-{}:{}", hit.path, value.line_number, snippet)); + } else { + lines.push(format!("{}-{}", hit.path, snippet)); + } + } + WorkspaceSearchLine::ContextBreak => lines.push("--".to_string()), + } + } + } + lines +} + +fn apply_offset_and_limit(items: &mut Vec, offset: usize, head_limit: Option) { + if offset > 0 { + if offset >= items.len() { + items.clear(); + } else { + *items = items[offset..].to_vec(); + } + } + + if let Some(limit) = head_limit { + if items.len() > limit { + items.truncate(limit); + } + } } #[cfg(test)] mod tests { - use super::{GrepTool, DEFAULT_HEAD_LIMIT}; + use super::{render_workspace_search_content_lines, GrepTool, DEFAULT_HEAD_LIMIT}; + use crate::infrastructure::FileSearchOutcome; + use crate::service::search::{ + ContentSearchResult, WorkspaceSearchBackend, WorkspaceSearchHit, WorkspaceSearchLine, + WorkspaceSearchMatch, WorkspaceSearchMatchLocation, WorkspaceSearchRepoPhase, + WorkspaceSearchRepoStatus, + }; use serde_json::json; #[test] @@ -328,6 +538,22 @@ mod tests { ); } + #[test] + fn backend_max_results_only_uses_explicit_limit() { + assert_eq!( + GrepTool::backend_max_results(&json!({}), 0, Some(DEFAULT_HEAD_LIMIT)), + None + ); + assert_eq!( + GrepTool::backend_max_results(&json!({ "head_limit": 25 }), 3, Some(25)), + Some(28) + ); + assert_eq!( + GrepTool::backend_max_results(&json!({ "head_limit": 0 }), 7, None), + None + ); + } + #[test] fn relativizes_prefixed_result_lines() { let text = "/repo/src/main.rs:12:fn main()\n/repo/src/lib.rs:3:pub fn lib()"; @@ -338,6 +564,150 @@ mod tests { "src/main.rs:12:fn main()\nsrc/lib.rs:3:pub fn lib()" ); } + + #[test] + fn renders_workspace_search_context_lines_in_rg_style() { + let lines = render_workspace_search_content_lines( + &[WorkspaceSearchHit { + path: "/repo/src/main.rs".to_string(), + matches: vec![WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }], + lines: vec![ + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 10, + snippet: "let a = 1".to_string(), + }, + }, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 11, + snippet: "let b = 2".to_string(), + }, + }, + WorkspaceSearchLine::Match { + value: WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }, + }, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 13, + snippet: "cleanup()".to_string(), + }, + }, + WorkspaceSearchLine::ContextBreak, + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 20, + snippet: "return".to_string(), + }, + }, + ], + }], + true, + ); + + assert_eq!( + lines, + vec![ + "/repo/src/main.rs-10:let a = 1", + "/repo/src/main.rs-11:let b = 2", + "/repo/src/main.rs:12:panic!(\"x\")", + "/repo/src/main.rs-13:cleanup()", + "--", + "/repo/src/main.rs-20:return", + ] + ); + } + + #[test] + fn content_workspace_output_uses_hits_for_context_lines() { + let tool = GrepTool::new(); + let result = ContentSearchResult { + outcome: FileSearchOutcome { + results: Vec::new(), + truncated: false, + }, + file_counts: Vec::new(), + hits: vec![WorkspaceSearchHit { + path: "/repo/src/main.rs".to_string(), + matches: vec![WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }], + lines: vec![ + WorkspaceSearchLine::Context { + value: crate::service::search::WorkspaceSearchContextLine { + line_number: 11, + snippet: "let b = 2".to_string(), + }, + }, + WorkspaceSearchLine::Match { + value: WorkspaceSearchMatch { + location: WorkspaceSearchMatchLocation { + line: 12, + column: 5, + }, + snippet: "panic!(\"x\")".to_string(), + matched_text: "panic".to_string(), + }, + }, + ], + }], + backend: WorkspaceSearchBackend::Indexed, + repo_status: WorkspaceSearchRepoStatus { + repo_id: "repo".to_string(), + repo_path: "/repo".to_string(), + storage_root: "/repo/.bitfun/search/flashgrep-index".to_string(), + base_snapshot_root: "/repo/.bitfun/search/flashgrep-index/base-snapshot".to_string(), + workspace_overlay_root: "/repo/.bitfun/search/flashgrep-index/workspace-overlay" + .to_string(), + phase: WorkspaceSearchRepoPhase::Ready, + snapshot_key: None, + last_probe_unix_secs: None, + last_rebuild_unix_secs: None, + dirty_files: crate::service::search::WorkspaceSearchDirtyFiles { + modified: 0, + deleted: 0, + new: 0, + }, + rebuild_recommended: false, + active_task_id: None, + probe_healthy: true, + last_error: None, + overlay: None, + }, + candidate_docs: 1, + matched_lines: 1, + matched_occurrences: 1, + }; + + let (rendered, file_count, total_matches) = + tool.format_workspace_search_output("content", true, 0, None, &result, Some("/repo")); + + assert_eq!( + rendered, + "src/main.rs-11:let b = 2\nsrc/main.rs:12:panic!(\"x\")" + ); + assert_eq!(file_count, 1); + assert_eq!(total_matches, 1); + } } #[async_trait] @@ -402,7 +772,7 @@ Usage: } fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { - true + false } fn needs_permissions(&self, _input: Option<&Value>) -> bool { @@ -460,6 +830,70 @@ Usage: return self.call_remote(input, context).await; } + if workspace_search_runtime_available().await { + if let Some(search_service) = get_global_workspace_search_service() { + let (request, output_mode, show_line_numbers, offset, head_limit) = + self.build_workspace_search_request(input, context)?; + let pattern = request.pattern.clone(); + let search_mode = request.output_mode.search_mode(); + let path = request + .search_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| request.repo_root.to_string_lossy().to_string()); + let search_started_at = Instant::now(); + let search_result = search_service.search_content(request).await?; + let display_base = Self::display_base(context); + let (result_text, file_count, total_matches) = self.format_workspace_search_output( + &output_mode, + show_line_numbers, + offset, + head_limit, + &search_result, + display_base.as_deref(), + ); + let workspace_search_elapsed_ms = search_started_at.elapsed().as_millis(); + + log::info!( + "Grep tool workspace-search result: pattern={}, path={}, output_mode={}, search_mode={:?}, file_count={}, total_matches={}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, workspace_search_ms={}", + pattern, + path, + output_mode, + search_mode, + file_count, + total_matches, + search_result.backend, + search_result.repo_status.phase, + search_result.repo_status.rebuild_recommended, + search_result.repo_status.dirty_files.modified, + search_result.repo_status.dirty_files.deleted, + search_result.repo_status.dirty_files.new, + search_result.candidate_docs, + search_result.matched_lines, + search_result.matched_occurrences, + workspace_search_elapsed_ms, + ); + + return Ok(vec![ToolResult::Result { + data: json!({ + "pattern": pattern, + "path": path, + "output_mode": output_mode, + "file_count": file_count, + "total_matches": total_matches, + "backend": search_result.backend, + "repo_phase": search_result.repo_status.phase, + "rebuild_recommended": search_result.repo_status.rebuild_recommended, + "applied_limit": head_limit, + "applied_offset": if offset > 0 { Some(offset) } else { None:: }, + "result": result_text, + }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]); + } + } + let grep_options = self.build_grep_options(input, context)?; let pattern = grep_options.pattern.clone(); let path = resolved.logical_path.clone(); diff --git a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs index 24d7a0ef4..d3f37a308 100644 --- a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs @@ -13,6 +13,9 @@ use serde_json::{json, Value}; /// Mermaid interactive diagram tool pub struct MermaidInteractiveTool; +const LARGE_MERMAID_CODE_SOFT_LINE_LIMIT: usize = 220; +const LARGE_MERMAID_CODE_SOFT_BYTE_LIMIT: usize = 18 * 1024; + impl Default for MermaidInteractiveTool { fn default() -> Self { Self::new() @@ -287,6 +290,7 @@ Key Rules: - file_path must be ABSOLUTE path that exists in the workspace - line_number must be a positive integer (1, 2, 3, ...), pointing to meaningful code location - For abstract/conceptual nodes (like "Database", "External API"), omit from node_metadata entirely - they will be non-clickable +- Keep mermaid_code compact. The 220-line / 18KB guideline is a soft reliability threshold, not a hard cap. For large architecture maps, split the work into several focused diagrams by subsystem, flow, or layer instead of truncating important nodes. - Use style statements for colors: style NodeID fill:#color,stroke:#border,color:#text - Use highlights for execution state: {"executed": ["A"], "current": "B", "failed": ["E"]} @@ -303,7 +307,7 @@ Mermaid Syntax: "properties": { "mermaid_code": { "type": "string", - "description": "Mermaid diagram code. Use standard Mermaid syntax. Node IDs should match the keys in node_metadata for interactive features. Add style statements for custom colors." + "description": "Mermaid diagram code. Use standard Mermaid syntax. Node IDs should match the keys in node_metadata for interactive features. Add style statements for custom colors. The 220-line / 18KB guideline is a soft reliability threshold; for larger maps, split into focused diagrams by subsystem, flow, or layer." }, "title": { "type": "string", @@ -472,6 +476,28 @@ Mermaid Syntax: }; } + let line_count = mermaid_code.lines().count(); + let byte_count = mermaid_code.len(); + if line_count > LARGE_MERMAID_CODE_SOFT_LINE_LIMIT + || byte_count > LARGE_MERMAID_CODE_SOFT_BYTE_LIMIT + { + return ValidationResult { + result: true, + message: Some(format!( + "Large Mermaid code: {} lines, {} bytes. This is allowed when necessary, but prefer a staged diagramming approach: split large architecture maps into focused diagrams by subsystem, flow, or layer instead of one huge diagram payload.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_mermaid_code": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_MERMAID_CODE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_MERMAID_CODE_SOFT_BYTE_LIMIT + })), + }; + } + // Validate node_metadata (if provided) if let Some(node_metadata) = input.get("node_metadata") { if !self.validate_node_metadata(node_metadata) { diff --git a/src/crates/core/src/agentic/tools/implementations/skills/default_profiles.rs b/src/crates/core/src/agentic/tools/implementations/skills/default_profiles.rs index 39c890ce7..b597381e6 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/default_profiles.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/default_profiles.rs @@ -105,6 +105,8 @@ mod tests { dir_name: dir_name.to_string(), is_builtin: true, group_key: None, + is_shadowed: false, + shadowed_by_key: None, } } @@ -119,6 +121,8 @@ mod tests { dir_name: dir_name.to_string(), is_builtin: false, group_key: None, + is_shadowed: false, + shadowed_by_key: None, } } diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index ac5c6a7a7..b071edef3 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -96,6 +96,8 @@ impl SkillCandidate { dir_name: data.dir_name, is_builtin, group_key, + is_shadowed: false, + shadowed_by_key: None, }, priority, } @@ -182,6 +184,34 @@ fn resolve_visible_skills(candidates: Vec) -> Vec { .collect() } +/// Annotate each candidate with shadowing information. +/// For every skill that has a higher-priority (lower number) skill with the same name, +/// set `is_shadowed = true` and `shadowed_by_key` to the winner's key. +fn annotate_shadowed_skills(candidates: Vec) -> Vec { + let mut by_name: HashMap = HashMap::new(); + for candidate in &candidates { + match by_name.get(&candidate.info.name) { + Some(existing) if existing.priority <= candidate.priority => {} + _ => { + by_name.insert(candidate.info.name.clone(), candidate.clone()); + } + } + } + + candidates + .into_iter() + .map(|mut candidate| { + if let Some(winner) = by_name.get(&candidate.info.name) { + if winner.info.key != candidate.info.key { + candidate.info.is_shadowed = true; + candidate.info.shadowed_by_key = Some(winner.info.key.clone()); + } + } + candidate.info + }) + .collect() +} + /// Skill registry pub struct SkillRegistry { /// Cached raw user-level skills (no workspace-specific project skills). @@ -218,18 +248,6 @@ impl SkillRegistry { } } - let path_manager = get_path_manager_arc(); - let bitfun_skills = path_manager.user_skills_dir(); - if bitfun_skills.exists() && bitfun_skills.is_dir() { - entries.push(SkillRootEntry { - path: bitfun_skills, - level: SkillLocation::User, - slot: "bitfun", - priority, - }); - } - priority += 1; - if let Some(home) = dirs::home_dir() { for (parent, sub, slot) in USER_HOME_SKILL_SLOTS { let path = home.join(parent).join(sub); @@ -245,6 +263,21 @@ impl SkillRegistry { } } + // BitFun's own user skills dir sits between home slots and config slots. + // This lets other agent directories (e.g. ~/.claude/skills) take precedence + // while still keeping config-level overrides after BitFun defaults. + let path_manager = get_path_manager_arc(); + let bitfun_skills = path_manager.user_skills_dir(); + if bitfun_skills.exists() && bitfun_skills.is_dir() { + entries.push(SkillRootEntry { + path: bitfun_skills, + level: SkillLocation::User, + slot: "bitfun", + priority, + }); + } + priority += 1; + if let Some(config_dir) = dirs::config_dir() { for (parent, sub, slot) in USER_CONFIG_SKILL_SLOTS { let path = config_dir.join(parent).join(sub); @@ -495,13 +528,10 @@ impl SkillRegistry { } pub async fn refresh(&self) { - let skills = sort_skills( + let skills = sort_skills(annotate_shadowed_skills( self.scan_skill_candidates_for_workspace(None) - .await - .into_iter() - .map(|candidate| candidate.info) - .collect(), - ); + .await, + )); let mut cache = self.cache.write().await; *cache = skills; } @@ -520,13 +550,10 @@ impl SkillRegistry { &self, workspace_root: Option<&Path>, ) -> Vec { - sort_skills( + sort_skills(annotate_shadowed_skills( self.scan_skill_candidates_for_workspace(workspace_root) - .await - .into_iter() - .map(|candidate| candidate.info) - .collect(), - ) + .await, + )) } pub async fn get_all_skills_for_remote_workspace( @@ -534,13 +561,10 @@ impl SkillRegistry { fs: &dyn WorkspaceFileSystem, remote_root: &str, ) -> Vec { - sort_skills( + sort_skills(annotate_shadowed_skills( self.scan_skill_candidates_for_remote_workspace(fs, remote_root) - .await - .into_iter() - .map(|candidate| candidate.info) - .collect(), - ) + .await, + )) } pub async fn get_resolved_skills_for_workspace( diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/core/src/agentic/tools/implementations/skills/types.rs index 3a33ec416..a6580a7ed 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/types.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/types.rs @@ -47,6 +47,12 @@ pub struct SkillInfo { /// Optional logical group for built-in skills. #[serde(default)] pub group_key: Option, + /// True when this skill is shadowed by a higher-priority skill with the same name. + #[serde(default)] + pub is_shadowed: bool, + /// Key of the skill that shadows this one (if any). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shadowed_by_key: Option, } impl SkillInfo { diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 02d30b5d2..75acf8fcd 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -15,6 +15,9 @@ use std::path::Path; pub struct TaskTool; +const LARGE_TASK_PROMPT_SOFT_LINE_LIMIT: usize = 180; +const LARGE_TASK_PROMPT_SOFT_BYTE_LIMIT: usize = 16 * 1024; + impl Default for TaskTool { fn default() -> Self { Self::new() @@ -178,7 +181,7 @@ impl Tool for TaskTool { }, "prompt": { "type": "string", - "description": "The task for the agent to perform" + "description": "The task for the agent to perform. Keep it scoped and concise. The 180-line / 16KB guideline is a soft reliability threshold, not a hard cap. For large delegations, split into multiple Task calls with clear ownership, and pass file paths, symbols, constraints, and exact questions instead of pasting large file contents." }, "subagent_type": { "type": "string", @@ -231,10 +234,39 @@ impl Tool for TaskTool { input: &Value, _context: Option<&ToolUseContext>, ) -> ValidationResult { - InputValidator::new(input) + let validation = InputValidator::new(input) .validate_required("prompt") .validate_required("subagent_type") - .finish() + .finish(); + if !validation.result { + return validation; + } + + if let Some(prompt) = input.get("prompt").and_then(|value| value.as_str()) { + let line_count = prompt.lines().count(); + let byte_count = prompt.len(); + if line_count > LARGE_TASK_PROMPT_SOFT_LINE_LIMIT + || byte_count > LARGE_TASK_PROMPT_SOFT_BYTE_LIMIT + { + return ValidationResult { + result: true, + message: Some(format!( + "Large Task prompt: {} lines, {} bytes. This is allowed when necessary, but prefer staged delegation: split large work into multiple Task calls with clear ownership, and pass file paths, symbols, constraints, and exact questions instead of large pasted context.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_task_prompt": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_TASK_PROMPT_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_TASK_PROMPT_SOFT_BYTE_LIMIT + })), + }; + } + } + + validation } fn render_tool_use_message(&self, input: &Value, options: &ToolRenderOptions) -> String { @@ -494,13 +526,11 @@ mod tests { let schema = TaskTool::new().input_schema(); assert_eq!(schema["properties"]["model_id"]["type"], "string"); - assert!( - !schema["required"] - .as_array() - .unwrap() - .iter() - .any(|value| value.as_str() == Some("model_id")) - ); + assert!(!schema["required"] + .as_array() + .unwrap() + .iter() + .any(|value| value.as_str() == Some("model_id"))); } #[test] diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 9fb29df8f..c16791fcb 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -10,8 +10,8 @@ pub mod image_context; pub mod implementations; pub mod input_validator; pub mod pipeline; -pub mod restrictions; pub mod registry; +pub mod restrictions; pub mod user_input_manager; pub mod workspace_paths; @@ -19,8 +19,8 @@ pub use framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; pub use pipeline::*; -pub use restrictions::{ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions}; pub use registry::{ create_tool_registry, get_all_registered_tool_names, get_all_registered_tools, get_all_tools, get_readonly_registered_tool_names, get_readonly_tools, }; +pub use restrictions::{ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions}; diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index b809ce09c..1f1592112 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -164,6 +164,7 @@ impl ToolStateManager { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), params: task.tool_call.arguments.clone(), + timeout_seconds: task.options.timeout_secs, }, ToolExecutionState::Streaming { diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index ac65cfc06..39f6a9b7b 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -17,7 +17,7 @@ use futures::future::join_all; use log::{debug, error, info, warn}; use std::collections::HashMap; use std::sync::Arc; -use std::time::Instant; +use std::time::{Instant, SystemTime}; use tokio::sync::{oneshot, RwLock as TokioRwLock}; use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; @@ -197,6 +197,61 @@ fn convert_to_framework_result(model_result: &ModelToolResult) -> FrameworkToolR } } +fn elapsed_ms_since(time: SystemTime) -> u64 { + time.elapsed() + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +fn build_error_execution_result( + task_id: &str, + task: Option, + error: &BitFunError, +) -> ToolExecutionResult { + let (tool_id, tool_name, execution_time_ms) = if let Some(task) = task { + ( + task.tool_call.tool_id, + task.tool_call.tool_name, + elapsed_ms_since(task.created_at), + ) + } else { + warn!("Task not found in state manager: {}", task_id); + (task_id.to_string(), "unknown".to_string(), 0) + }; + let error_message = error.to_string(); + + ToolExecutionResult { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + result: ModelToolResult { + tool_id, + tool_name, + result: serde_json::json!({ + "error": error_message, + "message": format!("Tool execution failed: {}", error_message) + }), + result_for_assistant: Some(format!("Tool execution failed: {}", error_message)), + is_error: true, + duration_ms: Some(execution_time_ms), + image_attachments: None, + }, + execution_time_ms, + } +} + +fn should_retry_tool_error(error: &BitFunError) -> bool { + matches!( + error, + BitFunError::Timeout(_) + | BitFunError::Io(_) + | BitFunError::Http(_) + | BitFunError::Service(_) + | BitFunError::MCPError(_) + | BitFunError::ProcessError(_) + | BitFunError::Other(_) + ) +} + /// Confirmation response type #[derive(Debug, Clone)] pub enum ConfirmationResponse { @@ -251,6 +306,10 @@ impl ToolPipeline { } info!("Executing tools: count={}", tool_calls.len()); + let tool_names: Vec = tool_calls + .iter() + .map(|tool_call| tool_call.tool_name.clone()) + .collect(); // Determine concurrency safety for each tool call let concurrency_flags: Vec = { @@ -265,6 +324,7 @@ impl ToolPipeline { }) .collect() }; + let concurrency_safe_count = concurrency_flags.iter().filter(|&&flag| flag).count(); // Create tasks for all tool calls let mut task_ids = Vec::with_capacity(tool_calls.len()); @@ -275,12 +335,26 @@ impl ToolPipeline { } if !options.allow_parallel { - debug!("Parallel execution disabled by options, running all tools sequentially"); + debug!( + "Tool execution plan: total_tools={}, batches=1, concurrency_safe={}, non_concurrency_safe={}, allow_parallel=false, tools={}", + task_ids.len(), + concurrency_safe_count, + task_ids.len().saturating_sub(concurrency_safe_count), + tool_names.join(", ") + ); return self.execute_sequential(task_ids).await; } // Partition into batches of consecutive same-safety tool calls let batches = Self::partition_tool_batches(&task_ids, &concurrency_flags); + debug!( + "Tool execution plan: total_tools={}, batches={}, concurrency_safe={}, non_concurrency_safe={}, allow_parallel=true, tools={}", + task_ids.len(), + batches.len(), + concurrency_safe_count, + task_ids.len().saturating_sub(concurrency_safe_count), + tool_names.join(", ") + ); if batches.len() == 1 { let batch = &batches[0]; @@ -359,35 +433,12 @@ impl ToolPipeline { Ok(r) => all_results.push(r), Err(e) => { error!("Tool execution failed: error={}", e); - let task_id = &task_ids[idx]; - let (tool_id, tool_name) = - if let Some(task) = self.state_manager.get_task(task_id) { - ( - task.tool_call.tool_id.clone(), - task.tool_call.tool_name.clone(), - ) - } else { - warn!("Task not found in state manager: {}", task_id); - (task_id.clone(), "unknown".to_string()) - }; - let error_result = ToolExecutionResult { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), - result: ModelToolResult { - tool_id, - tool_name, - result: serde_json::json!({ - "error": e.to_string(), - "message": format!("Tool execution failed: {}", e) - }), - result_for_assistant: Some(format!("Tool execution failed: {}", e)), - is_error: true, - duration_ms: None, - image_attachments: None, - }, - execution_time_ms: 0, - }; + let error_result = build_error_execution_result( + task_id, + self.state_manager.get_task(task_id), + &e, + ); all_results.push(error_result); } } @@ -408,34 +459,11 @@ impl ToolPipeline { Ok(result) => results.push(result), Err(e) => { error!("Tool execution failed: error={}", e); - - let (tool_id, tool_name) = - if let Some(task) = self.state_manager.get_task(&task_id) { - ( - task.tool_call.tool_id.clone(), - task.tool_call.tool_name.clone(), - ) - } else { - warn!("Task not found in state manager: {}", task_id); - (task_id.clone(), "unknown".to_string()) - }; - let error_result = ToolExecutionResult { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), - result: ModelToolResult { - tool_id, - tool_name, - result: serde_json::json!({ - "error": e.to_string(), - "message": format!("Tool execution failed: {}", e) - }), - result_for_assistant: Some(format!("Tool execution failed: {}", e)), - is_error: true, - duration_ms: None, - image_attachments: None, - }, - execution_time_ms: 0, - }; + let error_result = build_error_execution_result( + &task_id, + self.state_manager.get_task(&task_id), + &e, + ); results.push(error_result); } } @@ -459,10 +487,12 @@ impl ToolPipeline { let tool_name = task.tool_call.tool_name.clone(); let tool_args = task.tool_call.arguments.clone(); let tool_is_error = task.tool_call.is_error; + let queue_wait_ms = elapsed_ms_since(task.created_at); + let mut confirmation_wait_ms = 0; debug!( - "Tool task details: tool_name={}, tool_id={}", - tool_name, tool_id + "Tool task details: tool_name={}, tool_id={}, queue_wait_ms={}", + tool_name, tool_id, queue_wait_ms ); if tool_name.is_empty() || tool_is_error { @@ -532,13 +562,6 @@ impl ToolPipeline { return Err(err); } - // Create cancellation token - let cancellation_token = CancellationToken::new(); - self.cancellation_tokens - .insert(tool_id.clone(), cancellation_token.clone()); - - debug!("Executing tool: tool_name={}", tool_name); - let tool = { let registry = self.tool_registry.read().await; registry @@ -553,6 +576,40 @@ impl ToolPipeline { })? }; + let cancellation_token = CancellationToken::new(); + let tool_context = self.build_tool_use_context(&task, cancellation_token.clone()); + let validation = tool.validate_input(&tool_args, Some(&tool_context)).await; + if !validation.result { + let error_msg = validation + .message + .unwrap_or_else(|| format!("Invalid input for tool '{}'", tool_name)); + self.state_manager + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable: false, + }, + ) + .await; + return Err(BitFunError::Validation(error_msg)); + } + if let Some(message) = validation + .message + .filter(|message| !message.trim().is_empty()) + { + warn!( + "Tool input validation warning: tool_name={}, warning={}", + tool_name, message + ); + } + + // Register cancellation only after deterministic validation and registry lookup succeed. + self.cancellation_tokens + .insert(tool_id.clone(), cancellation_token.clone()); + + debug!("Executing tool: tool_name={}", tool_name); + let is_streaming = tool.supports_streaming(); let needs_confirmation = @@ -583,6 +640,7 @@ impl ToolPipeline { .await; debug!("Waiting for confirmation: tool_name={}", tool_name); + let confirmation_started_at = Instant::now(); let confirmation_result = match task.options.confirmation_timeout_secs { Some(timeout_secs) => { @@ -601,6 +659,7 @@ impl ToolPipeline { Some(rx.await) } }; + confirmation_wait_ms = elapsed_ms_u64(confirmation_started_at); match confirmation_result { Some(Ok(ConfirmationResponse::Confirmed)) => { @@ -622,6 +681,8 @@ impl ToolPipeline { ))); } Some(Err(_)) => { + self.confirmation_channels.remove(&tool_id); + // Channel closed self.state_manager .update_state( @@ -635,6 +696,8 @@ impl ToolPipeline { return Err(BitFunError::service("Confirmation channel closed")); } None => { + self.confirmation_channels.remove(&tool_id); + self.state_manager .update_state( &tool_id, @@ -655,6 +718,8 @@ impl ToolPipeline { self.confirmation_channels.remove(&tool_id); } + let preflight_ms = elapsed_ms_u64(start_time).saturating_sub(confirmation_wait_ms); + if cancellation_token.is_cancelled() { self.state_manager .update_state( @@ -693,15 +758,19 @@ impl ToolPipeline { .await; } + let execution_started_at = Instant::now(); let result = self .execute_with_retry(&task, cancellation_token.clone(), tool) .await; + let execution_ms = elapsed_ms_u64(execution_started_at); self.cancellation_tokens.remove(&tool_id); match result { Ok(tool_result) => { let duration_ms = elapsed_ms_u64(start_time); + let mut tool_result = tool_result; + tool_result.duration_ms = Some(duration_ms); self.state_manager .update_state( @@ -714,8 +783,14 @@ impl ToolPipeline { .await; info!( - "Tool completed: tool_name={}, duration_ms={}", - tool_name, duration_ms + "Tool completed: tool_name={}, duration_ms={}, queue_wait_ms={}, preflight_ms={}, confirmation_wait_ms={}, execution_ms={}, streaming={}", + tool_name, + duration_ms, + queue_wait_ms, + preflight_ms, + confirmation_wait_ms, + execution_ms, + is_streaming ); Ok(ToolExecutionResult { @@ -740,8 +815,14 @@ impl ToolPipeline { .await; info!( - "Tool cancelled during execution: tool_name={}, reason={}", - tool_name, reason + "Tool cancelled during execution: tool_name={}, reason={}, duration_ms={}, queue_wait_ms={}, preflight_ms={}, confirmation_wait_ms={}, execution_ms={}", + tool_name, + reason, + elapsed_ms_u64(start_time), + queue_wait_ms, + preflight_ms, + confirmation_wait_ms, + execution_ms ); return Err(e); @@ -760,7 +841,16 @@ impl ToolPipeline { ) .await; - error!("Tool failed: tool_name={}, error={}", tool_name, error_msg); + error!( + "Tool failed: tool_name={}, error={}, duration_ms={}, queue_wait_ms={}, preflight_ms={}, confirmation_wait_ms={}, execution_ms={}", + tool_name, + error_msg, + elapsed_ms_u64(start_time), + queue_wait_ms, + preflight_ms, + confirmation_wait_ms, + execution_ms + ); Err(e) } @@ -794,7 +884,7 @@ impl ToolPipeline { match result { Ok(r) => return Ok(r), Err(e) => { - if attempts >= max_attempts { + if attempts >= max_attempts || !should_retry_tool_error(&e) { return Err(e); } @@ -824,8 +914,48 @@ impl ToolPipeline { )); } - // Build tool context (pass all resource IDs) - let tool_context = ToolUseContext { + let tool_context = self.build_tool_use_context(task, cancellation_token); + + let execution_future = tool.call(&task.tool_call.arguments, &tool_context); + + let tool_results = match task.options.timeout_secs { + Some(timeout_secs) => { + let timeout_duration = Duration::from_secs(timeout_secs); + let result = timeout(timeout_duration, execution_future) + .await + .map_err(|_| { + BitFunError::Timeout(format!( + "Tool execution timeout: {}", + task.tool_call.tool_name + )) + })?; + result? + } + None => execution_future.await?, + }; + + if tool.supports_streaming() && tool_results.len() > 1 { + self.handle_streaming_results(task, &tool_results).await?; + } + + tool_results + .into_iter() + .last() + .map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name)) + .ok_or_else(|| { + BitFunError::Tool(format!( + "Tool did not return result: {}", + task.tool_call.tool_name + )) + }) + } + + fn build_tool_use_context( + &self, + task: &ToolTask, + cancellation_token: CancellationToken, + ) -> ToolUseContext { + ToolUseContext { tool_call_id: Some(task.tool_call.tool_id.clone()), agent_type: Some(task.context.agent_type.clone()), session_id: Some(task.context.session_id.clone()), @@ -867,40 +997,7 @@ impl ToolPipeline { cancellation_token: Some(cancellation_token), runtime_tool_restrictions: task.context.runtime_tool_restrictions.clone(), workspace_services: task.context.workspace_services.clone(), - }; - - let execution_future = tool.call(&task.tool_call.arguments, &tool_context); - - let tool_results = match task.options.timeout_secs { - Some(timeout_secs) => { - let timeout_duration = Duration::from_secs(timeout_secs); - let result = timeout(timeout_duration, execution_future) - .await - .map_err(|_| { - BitFunError::Timeout(format!( - "Tool execution timeout: {}", - task.tool_call.tool_name - )) - })?; - result? - } - None => execution_future.await?, - }; - - if tool.supports_streaming() && tool_results.len() > 1 { - self.handle_streaming_results(task, &tool_results).await?; } - - tool_results - .into_iter() - .last() - .map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name)) - .ok_or_else(|| { - BitFunError::Tool(format!( - "Tool did not return result: {}", - task.tool_call.tool_name - )) - }) } /// Handle streaming results diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 525e7ef16..fbf856d03 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -71,17 +71,24 @@ impl ToolRegistry { /// Remove all tools from the MCP server pub fn unregister_mcp_server_tools(&mut self, server_id: &str) { let prefix = format!("mcp__{}__", server_id); + self.unregister_tools_by_prefix(&prefix); + } + + /// Remove all tools whose registry name starts with the given prefix. + pub fn unregister_tools_by_prefix(&mut self, prefix: &str) -> usize { let to_remove: Vec = self .tools .keys() - .filter(|k| k.starts_with(&prefix)) + .filter(|k| k.starts_with(prefix)) .cloned() .collect(); + let count = to_remove.len(); for key in to_remove { - info!("Unregistering MCP tool: tool_name={}", key); + info!("Unregistering dynamic tool: tool_name={}", key); self.tools.shift_remove(&key); } + count } /// Register all tools diff --git a/src/crates/core/src/agentic/tools/restrictions.rs b/src/crates/core/src/agentic/tools/restrictions.rs index 63f1582dc..a8bb66f1c 100644 --- a/src/crates/core/src/agentic/tools/restrictions.rs +++ b/src/crates/core/src/agentic/tools/restrictions.rs @@ -195,10 +195,7 @@ mod tests { #[test] fn denied_tool_names_override_allow_list() { let restrictions = ToolRuntimeRestrictions { - allowed_tool_names: ["Write", "Edit"] - .into_iter() - .map(str::to_string) - .collect(), + allowed_tool_names: ["Write", "Edit"].into_iter().map(str::to_string).collect(), denied_tool_names: ["Write"].into_iter().map(str::to_string).collect(), path_policy: ToolPathPolicy::default(), }; @@ -221,7 +218,8 @@ mod tests { #[test] fn local_path_containment_handles_missing_children() { - let root = std::env::temp_dir().join(format!("bitfun-restrictions-{}", uuid::Uuid::new_v4())); + let root = + std::env::temp_dir().join(format!("bitfun-restrictions-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(root.join("allowed")).expect("create temp root"); let allowed_child = root.join("allowed").join("nested").join("file.txt"); diff --git a/src/crates/core/src/miniapp/host_dispatch.rs b/src/crates/core/src/miniapp/host_dispatch.rs index 0c65352aa..10281a0cc 100644 --- a/src/crates/core/src/miniapp/host_dispatch.rs +++ b/src/crates/core/src/miniapp/host_dispatch.rs @@ -29,7 +29,6 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use serde_json::{json, Value}; use std::path::{Path, PathBuf}; use std::time::Duration; -use tokio::process::Command; /// Namespaces handled by the host-side dispatch (no Worker required). const HOST_NAMESPACES: &[&str] = &["fs", "shell", "os", "net"]; @@ -377,13 +376,13 @@ async fn dispatch_shell( #[cfg(target_os = "windows")] let mut cmd = { - let mut c = Command::new("cmd"); + let mut c = crate::util::process_manager::create_tokio_command("cmd"); c.args(["/C", &command]); c }; #[cfg(not(target_os = "windows"))] let mut cmd = { - let mut c = Command::new("sh"); + let mut c = crate::util::process_manager::create_tokio_command("sh"); c.args(["-c", &command]); c }; diff --git a/src/crates/core/src/miniapp/js_worker.rs b/src/crates/core/src/miniapp/js_worker.rs index 4e7cd2507..95eddd3a5 100644 --- a/src/crates/core/src/miniapp/js_worker.rs +++ b/src/crates/core/src/miniapp/js_worker.rs @@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; +use tokio::process::{Child, ChildStdin}; use tokio::sync::{oneshot, Mutex}; type JsWorkerResponse = Result; @@ -36,7 +36,7 @@ impl JsWorker { ) -> Result { let exe = runtime.path.to_string_lossy(); let host = worker_host_path.to_string_lossy(); - let mut child = Command::new(&*exe) + let mut child = crate::util::process_manager::create_tokio_command(&*exe) .arg(&*host) .arg(policy_json) .current_dir(app_dir) diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index ea11f6551..e5d0836b7 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -7,7 +7,6 @@ use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; -use tokio::process::Command; use tokio::sync::Mutex; const MAX_WORKERS: usize = 5; @@ -276,7 +275,7 @@ impl JsWorkerPool { } }; - let output = Command::new(cmd) + let output = crate::util::process_manager::create_tokio_command(cmd) .args(args) .current_dir(&app_dir) .output() diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs index ea8c4281c..c9d06b13a 100644 --- a/src/crates/core/src/miniapp/runtime_detect.rs +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -13,7 +13,6 @@ //! picked up regardless of the active version. use std::path::{Path, PathBuf}; -use std::process::Command; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuntimeKind { @@ -116,7 +115,9 @@ fn is_executable(p: &Path) -> bool { } fn get_version(executable: &std::path::Path) -> Result { - let out = Command::new(executable).arg("--version").output()?; + let out = crate::util::process_manager::create_command(executable) + .arg("--version") + .output()?; if out.status.success() { let v = String::from_utf8_lossy(&out.stdout); Ok(v.trim().to_string()) diff --git a/src/crates/core/src/service/config/manager.rs b/src/crates/core/src/service/config/manager.rs index 846e4aa4b..677847227 100644 --- a/src/crates/core/src/service/config/manager.rs +++ b/src/crates/core/src/service/config/manager.rs @@ -506,6 +506,7 @@ impl ConfigManager { path: &str, old_config: &GlobalConfig, ) -> BitFunResult<()> { + self.check_and_broadcast_app_change(path).await; self.check_and_broadcast_debug_mode_change(old_config).await; self.check_and_broadcast_log_level_change(old_config).await; @@ -514,6 +515,14 @@ impl ConfigManager { .await } + /// Detects and broadcasts app-scope configuration changes. + async fn check_and_broadcast_app_change(&self, path: &str) { + if path == "app" || path.starts_with("app.") { + use super::global::{ConfigUpdateEvent, GlobalConfigManager}; + GlobalConfigManager::broadcast_update(ConfigUpdateEvent::AppUpdated).await; + } + } + /// Detects and broadcasts debug-mode configuration changes. async fn check_and_broadcast_debug_mode_change(&self, old_config: &GlobalConfig) { let old_debug = &old_config.ai.debug_mode_config; diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 8c7bb1b02..ac82c7111 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -44,6 +44,9 @@ pub struct GlobalConfig { /// MCP server configuration (stored uniformly; supports both JSON and structured formats). #[serde(skip_serializing_if = "Option::is_none")] pub mcp_servers: Option, + /// ACP client configuration (stored as `{ "acpClients": { ... } }`). + #[serde(skip_serializing_if = "Option::is_none")] + pub acp_clients: Option, /// Theme system configuration. #[serde(skip_serializing_if = "Option::is_none")] pub themes: Option, @@ -111,6 +114,27 @@ pub struct AIExperienceConfig { pub enable_visual_mode: bool, /// Whether to show the pixel Agent companion in the collapsed chat input. pub enable_agent_companion: bool, + /// Where to show the Agent companion: "input" or "desktop". + pub agent_companion_display_mode: String, + /// Optional Petdex-compatible companion package selected by the user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_companion_pet: Option, + /// Whether to enable flashgrep-backed accelerated workspace search. + pub enable_workspace_search: bool, +} + +/// User-selected Agent companion pet package. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentCompanionPetSelection { + pub id: String, + pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub source: String, + pub package_path: String, + pub spritesheet_path: String, + pub spritesheet_mime_type: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -500,6 +524,10 @@ pub struct AIConfig { /// Allow Computer use (desktop automation) when the desktop host is available (all session modes). #[serde(default)] pub computer_use_enabled: bool, + + /// Maximum number of rounds per dialog turn before soft-pausing. + #[serde(default = "default_max_rounds")] + pub max_rounds: usize, } impl AIConfig { @@ -643,6 +671,12 @@ fn default_subagent_max_concurrency() -> usize { 5 } +pub const DEFAULT_MAX_ROUNDS: usize = 200; + +fn default_max_rounds() -> usize { + DEFAULT_MAX_ROUNDS +} + impl Default for ModeConfig { fn default() -> Self { Self { @@ -1175,6 +1209,7 @@ impl Default for GlobalConfig { workspace: WorkspaceConfig::default(), ai: AIConfig::default(), mcp_servers: None, + acp_clients: None, themes: Some(ThemesConfig::default()), font: None, version: "1.0.0".to_string(), @@ -1240,6 +1275,9 @@ impl Default for AIExperienceConfig { enable_welcome_panel_ai_analysis: false, enable_visual_mode: false, enable_agent_companion: true, + agent_companion_display_mode: "desktop".to_string(), + agent_companion_pet: None, + enable_workspace_search: false, } } } @@ -1444,6 +1482,7 @@ impl Default for AIConfig { skip_tool_confirmation: true, debug_mode_config: DebugModeConfig::default(), computer_use_enabled: false, + max_rounds: default_max_rounds(), } } } @@ -1648,7 +1687,7 @@ impl AIModelConfig { #[cfg(test)] mod tests { - use super::{AIConfig, AIModelConfig, ReasoningMode}; + use super::{AIConfig, AIExperienceConfig, AIModelConfig, ReasoningMode}; #[test] fn deserializes_compatibility_thinking_flag_into_reasoning_mode() { @@ -1668,6 +1707,46 @@ mod tests { assert!(config.enable_thinking_process); } + #[test] + fn preserves_selected_agent_companion_pet() { + let config: AIExperienceConfig = serde_json::from_value(serde_json::json!({ + "enable_session_title_generation": true, + "enable_welcome_panel_ai_analysis": false, + "enable_visual_mode": false, + "enable_agent_companion": true, + "agent_companion_display_mode": "desktop", + "agent_companion_pet": { + "id": "boxcat", + "displayName": "Boxcat", + "description": "A tiny cat tucked inside a cardboard box for cozy coding sessions.", + "source": "preset", + "packagePath": "/agent-companion-pets/boxcat", + "spritesheetPath": "/agent-companion-pets/boxcat/spritesheet.webp", + "spritesheetMimeType": "image/webp" + } + })) + .expect("AI experience config with selected companion pet should deserialize"); + + let pet = config + .agent_companion_pet + .as_ref() + .expect("selected companion pet should be retained"); + assert_eq!(pet.id, "boxcat"); + assert_eq!(pet.display_name, "Boxcat"); + assert_eq!(pet.package_path, "/agent-companion-pets/boxcat"); + assert_eq!(config.agent_companion_display_mode, "desktop"); + + let serialized = serde_json::to_value(&config).expect("config should serialize"); + assert_eq!( + serialized["agent_companion_pet"]["displayName"], + "Boxcat" + ); + assert_eq!( + serialized["agent_companion_pet"]["spritesheetPath"], + "/agent-companion-pets/boxcat/spritesheet.webp" + ); + } + #[test] fn deserializes_compatibility_false_thinking_flag_into_default_reasoning_mode() { let config: AIModelConfig = serde_json::from_value(serde_json::json!({ diff --git a/src/crates/core/src/service/git/git_types.rs b/src/crates/core/src/service/git/git_types.rs index daf2cd09a..ab848af8c 100644 --- a/src/crates/core/src/service/git/git_types.rs +++ b/src/crates/core/src/service/git/git_types.rs @@ -248,3 +248,11 @@ pub enum GitError { #[error("Git2 error: {0}")] Git2Error(#[from] git2::Error), } + +/// Raw result of executing a git command, preserving exit code and both streams. +#[derive(Debug, Clone)] +pub struct GitCommandOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} \ No newline at end of file diff --git a/src/crates/core/src/service/git/git_utils.rs b/src/crates/core/src/service/git/git_utils.rs index d6bcacaa4..963e02052 100644 --- a/src/crates/core/src/service/git/git_utils.rs +++ b/src/crates/core/src/service/git/git_utils.rs @@ -1,7 +1,7 @@ /** * Git utility functions */ -use super::git_types::{GitError, GitFileStatus}; +use super::git_types::{GitCommandOutput, GitError, GitFileStatus}; use git2::{Repository, Status, StatusOptions}; use std::path::Path; @@ -194,8 +194,14 @@ pub fn get_file_statuses(repo: &Repository) -> Result, GitErr } } -/// Executes a Git command. -pub async fn execute_git_command(repo_path: &str, args: &[&str]) -> Result { +/// Executes a Git command and returns the raw output including exit code. +/// +/// Git diff returns exit code 1 when there are differences (not an error). +/// Callers that need to distinguish this case should inspect `exit_code`. +pub async fn execute_git_command_raw( + repo_path: &str, + args: &[&str], +) -> Result { let output = crate::util::process_manager::create_tokio_command("git") .current_dir(repo_path) .args(args) @@ -203,27 +209,64 @@ pub async fn execute_git_command(repo_path: &str, args: &[&str]) -> Result Result { + let result = execute_git_command_raw(repo_path, args).await?; + + if result.exit_code == 0 { + Ok(result.stdout) } else { - let error = String::from_utf8_lossy(&output.stderr); - Err(GitError::CommandFailed(error.to_string())) + let error = if result.stderr.is_empty() { + result.stdout + } else { + result.stderr + }; + Err(GitError::CommandFailed(error)) } } -/// Executes a Git command synchronously. -pub fn execute_git_command_sync(repo_path: &str, args: &[&str]) -> Result { +/// Executes a Git command synchronously and returns the raw output including exit code. +pub fn execute_git_command_sync_raw( + repo_path: &str, + args: &[&str], +) -> Result { let output = crate::util::process_manager::create_command("git") .current_dir(repo_path) .args(args) .output() .map_err(|e| GitError::CommandFailed(format!("Failed to execute git command: {}", e)))?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) + Ok(GitCommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + }) +} + +/// Executes a Git command synchronously. +pub fn execute_git_command_sync(repo_path: &str, args: &[&str]) -> Result { + let result = execute_git_command_sync_raw(repo_path, args)?; + + if result.exit_code == 0 { + Ok(result.stdout) } else { - let error = String::from_utf8_lossy(&output.stderr); - Err(GitError::CommandFailed(error.to_string())) + let error = if result.stderr.is_empty() { + result.stdout + } else { + result.stderr + }; + Err(GitError::CommandFailed(error)) } } diff --git a/src/crates/core/src/service/i18n/model_copy.rs b/src/crates/core/src/service/i18n/model_copy.rs index ce0692210..0eaf65a17 100644 --- a/src/crates/core/src/service/i18n/model_copy.rs +++ b/src/crates/core/src/service/i18n/model_copy.rs @@ -16,7 +16,7 @@ pub struct CodeReviewCopy { } const CODE_REVIEW_ZH_CN: CodeReviewCopy = CodeReviewCopy { - description: "提交代码审查结果。完成审查分析后必须调用本工具提交结构化审查报告。所有用户可见的文本字段必须使用简体中文。", + description: "提交代码审核结果。完成审核分析后必须调用本工具提交结构化审核报告。所有用户可见的文本字段必须使用简体中文。", overall_assessment: "总体评价(2-3 句,使用简体中文)", confidence_note: "上下文局限说明(可选,使用简体中文)", issue_title: "问题标题(简体中文)", @@ -26,7 +26,7 @@ const CODE_REVIEW_ZH_CN: CodeReviewCopy = CodeReviewCopy { }; const CODE_REVIEW_ZH_TW: CodeReviewCopy = CodeReviewCopy { - description: "提交程式碼審查結果。完成審查分析後必須呼叫本工具提交結構化審查報告。所有使用者可見的文字欄位必須使用繁體中文。", + description: "提交程式碼審核結果。完成審核分析後必須呼叫本工具提交結構化審核報告。所有使用者可見的文字欄位必須使用繁體中文。", overall_assessment: "整體評價(2-3 句,使用繁體中文)", confidence_note: "上下文限制說明(可選,使用繁體中文)", issue_title: "問題標題(繁體中文)", diff --git a/src/crates/core/src/service/lsp/workspace_manager.rs b/src/crates/core/src/service/lsp/workspace_manager.rs index 26191a09f..87b38faae 100644 --- a/src/crates/core/src/service/lsp/workspace_manager.rs +++ b/src/crates/core/src/service/lsp/workspace_manager.rs @@ -432,7 +432,9 @@ impl WorkspaceLspManager { let is_related = (language == "c" && lang == "cpp") || (language == "cpp" && lang == "c") || (language == "javascript" && lang == "typescript") - || (language == "typescript" && lang == "javascript"); + || (language == "typescript" && lang == "javascript") + || (language == "javascriptreact" && lang == "javascript") + || (language == "typescriptreact" && lang == "typescript"); if is_related { return Some(lang.clone()); @@ -458,7 +460,9 @@ impl WorkspaceLspManager { let is_related = (language == "c" && lang == "cpp") || (language == "cpp" && lang == "c") || (language == "javascript" && lang == "typescript") - || (language == "typescript" && lang == "javascript"); + || (language == "typescript" && lang == "javascript") + || (language == "javascriptreact" && lang == "javascript") + || (language == "typescriptreact" && lang == "typescript"); if is_related { return lang.clone(); diff --git a/src/crates/core/src/service/mcp/server/manager/lifecycle.rs b/src/crates/core/src/service/mcp/server/manager/lifecycle.rs index bcac98e4a..1d5949751 100644 --- a/src/crates/core/src/service/mcp/server/manager/lifecycle.rs +++ b/src/crates/core/src/service/mcp/server/manager/lifecycle.rs @@ -1,6 +1,21 @@ use super::*; impl MCPServerManager { + async fn runtime_server_config(&self, server_id: &str) -> BitFunResult { + if let Some(config) = self.config_service.get_server_config(server_id).await? { + return Ok(config); + } + + self.ephemeral_configs + .read() + .await + .get(server_id) + .cloned() + .ok_or_else(|| { + BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) + }) + } + /// Initializes all servers. pub async fn initialize_all(&self) -> BitFunResult<()> { info!("Initializing all MCP servers"); @@ -134,12 +149,7 @@ impl MCPServerManager { return Ok(()); } - let Some(config) = self.config_service.get_server_config(server_id).await? else { - return Err(BitFunError::NotFound(format!( - "MCP server config not found: {}", - server_id - ))); - }; + let config = self.runtime_server_config(server_id).await?; if !config.enabled { return Ok(()); @@ -155,12 +165,11 @@ impl MCPServerManager { info!("Starting MCP server: id={}", server_id); let config = self - .config_service - .get_server_config(server_id) - .await? - .ok_or_else(|| { + .runtime_server_config(server_id) + .await + .map_err(|error| { error!("MCP server config not found: id={}", server_id); - BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) + error })?; if !config.enabled { @@ -325,13 +334,7 @@ impl MCPServerManager { pub async fn restart_server(&self, server_id: &str) -> BitFunResult<()> { info!("Restarting MCP server: id={}", server_id); - let config = self - .config_service - .get_server_config(server_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("MCP server config not found: {}", server_id)) - })?; + let config = self.runtime_server_config(server_id).await?; match config.server_type { super::super::MCPServerType::Local => { @@ -427,6 +430,58 @@ impl MCPServerManager { Ok(()) } + /// Adds a runtime-only MCP server without saving it to user or project config. + pub async fn add_ephemeral_server(&self, config: MCPServerConfig) -> BitFunResult<()> { + config.validate()?; + + let server_id = config.id.clone(); + if self.registry.contains(&server_id).await { + let _ = self.remove_ephemeral_server(&server_id).await; + } + + self.ephemeral_configs + .write() + .await + .insert(server_id.clone(), config.clone()); + self.registry.register(&config).await?; + + if config.enabled && config.auto_start { + if let Err(error) = self.start_server(&server_id).await { + let _ = self.remove_ephemeral_server(&server_id).await; + return Err(error); + } + } + + Ok(()) + } + + /// Removes a runtime-only MCP server and its registered tools without touching persisted config. + pub async fn remove_ephemeral_server(&self, server_id: &str) -> BitFunResult<()> { + info!("Removing ephemeral MCP server: id={}", server_id); + + let _ = self.stop_server(server_id).await; + self.stop_connection_event_listener(server_id).await; + + match self.registry.unregister(server_id).await { + Ok(_) => { + info!("Unregistered ephemeral MCP server: id={}", server_id); + } + Err(e) => { + warn!( + "Ephemeral MCP server was not registered, skipping unregister: id={} error={}", + server_id, e + ); + } + } + + self.ephemeral_configs.write().await.remove(server_id); + self.clear_reconnect_state(server_id).await; + self.resource_catalog_cache.write().await.remove(server_id); + self.prompt_catalog_cache.write().await.remove(server_id); + + Ok(()) + } + /// Removes a server. pub async fn remove_server(&self, server_id: &str) -> BitFunResult<()> { info!("Removing MCP server: id={}", server_id); diff --git a/src/crates/core/src/service/mcp/server/manager/mod.rs b/src/crates/core/src/service/mcp/server/manager/mod.rs index c093429fb..b2ca77bef 100644 --- a/src/crates/core/src/service/mcp/server/manager/mod.rs +++ b/src/crates/core/src/service/mcp/server/manager/mod.rs @@ -106,6 +106,7 @@ pub struct MCPServerManager { prompt_catalog_cache: Arc>>>, pending_interactions: Arc>>, oauth_sessions: Arc>>>, + ephemeral_configs: Arc>>, } impl MCPServerManager { @@ -123,6 +124,7 @@ impl MCPServerManager { prompt_catalog_cache: Arc::new(tokio::sync::RwLock::new(HashMap::new())), pending_interactions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), oauth_sessions: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + ephemeral_configs: Arc::new(tokio::sync::RwLock::new(HashMap::new())), } } } diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 5d315c647..9b005c0a6 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -20,6 +20,7 @@ pub mod project_context; // Project context management pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) pub mod runtime; // Managed runtime and capability management +pub mod search; // Workspace search via managed flashgrep daemon pub mod session; // Session persistence pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution @@ -54,6 +55,16 @@ pub use lsp::LspManager; pub use mcp::MCPService; pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; +pub use search::{ + get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, + ContentSearchResult, GlobSearchRequest, GlobSearchResult, IndexTaskHandle, + WorkspaceIndexStatus, WorkspaceSearchBackend, WorkspaceSearchContextLine, + WorkspaceSearchDirtyFiles, WorkspaceSearchFileCount, WorkspaceSearchHit, WorkspaceSearchLine, + WorkspaceSearchMatch, WorkspaceSearchMatchLocation, WorkspaceSearchOverlayStatus, + WorkspaceSearchRepoPhase, WorkspaceSearchRepoStatus, WorkspaceSearchService, + WorkspaceSearchTaskKind, WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, + WorkspaceSearchTaskStatus, +}; pub use snapshot::SnapshotService; pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, diff --git a/src/crates/core/src/service/search/flashgrep/client.rs b/src/crates/core/src/service/search/flashgrep/client.rs new file mode 100644 index 000000000..724a43b08 --- /dev/null +++ b/src/crates/core/src/service/search/flashgrep/client.rs @@ -0,0 +1,759 @@ +use std::{ + collections::HashMap, + ffi::OsString, + process::Stdio, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, +}; + +use serde::Serialize; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader, BufWriter}, + process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}, + sync::{oneshot, Mutex}, + time::{sleep, timeout}, +}; + +use super::{ + error::{AppError, Result}, + protocol::{ + ClientCapabilities, ClientInfo, GlobParams, InitializeParams, RepoRef, Request, + RequestEnvelope, Response, ResponseEnvelope, SearchParams, ServerMessage, TaskRef, + }, + types::{ + GlobOutcome, GlobRequest, OpenRepoParams, RepoStatus, SearchOutcome, SearchRequest, + TaskStatus, + }, +}; + +const JSONRPC_VERSION: &str = "2.0"; +const CLIENT_NAME: &str = "bitfun-workspace-search"; +const REPO_CLOSE_TIMEOUT: Duration = Duration::from_secs(2); +const SHUTDOWN_REQUEST_TIMEOUT: Duration = Duration::from_secs(2); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); + +type PendingResponseSender = oneshot::Sender>; +type PendingResponses = HashMap; + +#[derive(Debug, Clone)] +pub(crate) struct ManagedClient { + daemon_program: Option, + start_timeout: Duration, + retry_interval: Duration, + shutting_down: Arc, + state: Arc>, + start_guard: Arc>, +} + +#[derive(Debug)] +pub(crate) struct RepoSession { + repo_id: String, + client: ManagedClient, +} + +#[derive(Debug, Default)] +struct ManagedClientState { + daemon: Option>, +} + +#[derive(Debug)] +struct AsyncDaemonClient { + child: Mutex>, + writer: Mutex>, + shared: Arc, + next_id: AtomicU64, + reader_task: Mutex>>, + stderr_task: Mutex>>, +} + +#[derive(Debug, Default)] +struct DaemonShared { + pending: Mutex, + closed: AtomicBool, +} + +impl Default for ManagedClient { + fn default() -> Self { + Self { + daemon_program: None, + start_timeout: Duration::from_secs(10), + retry_interval: Duration::from_millis(100), + shutting_down: Arc::new(AtomicBool::new(false)), + state: Arc::new(Mutex::new(ManagedClientState::default())), + start_guard: Arc::new(Mutex::new(())), + } + } +} + +impl ManagedClient { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn with_daemon_program(mut self, program: impl Into) -> Self { + self.daemon_program = Some(program.into()); + self + } + + pub(crate) fn with_start_timeout(mut self, timeout: Duration) -> Self { + self.start_timeout = timeout; + self + } + + pub(crate) fn with_retry_interval(mut self, interval: Duration) -> Self { + self.retry_interval = interval; + self + } + + pub(crate) async fn open_repo(&self, params: OpenRepoParams) -> Result { + match self + .send_request_with_restart(Request::OpenRepo { params }) + .await? + { + Response::RepoOpened { repo_id, .. } => Ok(RepoSession { + repo_id, + client: self.clone(), + }), + other => unexpected_response("open_repo", other), + } + } + + pub(crate) async fn shutdown_daemon(&self) -> Result<()> { + self.shutting_down.store(true, Ordering::Relaxed); + let daemon = self.state.lock().await.daemon.take(); + if let Some(daemon) = daemon { + daemon.shutdown().await?; + } + Ok(()) + } + + pub(crate) async fn stop_daemon(&self) -> Result<()> { + let daemon = self.state.lock().await.daemon.take(); + if let Some(daemon) = daemon { + daemon.shutdown().await?; + } + Ok(()) + } + + async fn send_request_with_restart(&self, request: Request) -> Result { + self.send_request_with_restart_timeout(request, None).await + } + + async fn send_request_with_restart_timeout( + &self, + request: Request, + timeout: Option, + ) -> Result { + if self.is_shutting_down() { + return Err(AppError::Protocol( + "flashgrep stdio backend is shutting down".into(), + )); + } + + let daemon = self.get_or_start_daemon().await?; + match daemon + .send_request_with_timeout(request.clone(), timeout) + .await + { + Ok(response) => Ok(response), + Err(error) + if !self.is_shutting_down() && should_restart_daemon(&error, daemon.as_ref()) => + { + self.clear_daemon_if_current(&daemon).await; + if let Err(shutdown_error) = daemon.shutdown().await { + log::debug!( + "Flashgrep stdio daemon shutdown after transport error failed: {}", + shutdown_error + ); + } + let restarted = self.get_or_start_daemon().await?; + restarted.send_request_with_timeout(request, timeout).await + } + Err(error) => Err(error), + } + } + + async fn get_or_start_daemon(&self) -> Result> { + if self.is_shutting_down() { + return Err(AppError::Protocol( + "flashgrep stdio backend is shutting down".into(), + )); + } + + if let Some(daemon) = self.current_daemon().await { + return Ok(daemon); + } + + let _start_guard = self.start_guard.lock().await; + if self.is_shutting_down() { + return Err(AppError::Protocol( + "flashgrep stdio backend is shutting down".into(), + )); + } + if let Some(daemon) = self.current_daemon().await { + return Ok(daemon); + } + + let deadline = Instant::now() + self.start_timeout; + loop { + match AsyncDaemonClient::spawn(self.daemon_program.clone()).await { + Ok(daemon) => { + let daemon = Arc::new(daemon); + self.state.lock().await.daemon = Some(daemon.clone()); + return Ok(daemon); + } + Err(error) if Instant::now() < deadline => { + sleep(self.retry_interval).await; + let _ = error; + } + Err(error) => return Err(error), + } + } + } + + async fn current_daemon(&self) -> Option> { + let mut state = self.state.lock().await; + match state.daemon.clone() { + Some(daemon) if !daemon.is_closed() => Some(daemon), + Some(_) => { + state.daemon = None; + None + } + None => None, + } + } + + async fn clear_daemon_if_current(&self, current: &Arc) { + let mut state = self.state.lock().await; + if state + .daemon + .as_ref() + .is_some_and(|daemon| Arc::ptr_eq(daemon, current)) + { + state.daemon = None; + } + } + + fn is_shutting_down(&self) -> bool { + self.shutting_down.load(Ordering::Relaxed) + } +} + +impl RepoSession { + pub(crate) async fn status(&self) -> Result { + self.send_repo_request( + "get_repo_status", + Request::GetRepoStatus { + params: self.repo_ref(), + }, + |response| match response { + Response::RepoStatus { status } => Ok(status), + other => unexpected_response("get_repo_status", other), + }, + None, + ) + .await + } + + pub(crate) async fn search(&self, request: SearchRequest) -> Result { + self.send_repo_request( + "search", + Request::Search { + params: SearchParams { + repo_id: self.repo_id.clone(), + query: request.query, + scope: request.scope, + consistency: request.consistency, + allow_scan_fallback: request.allow_scan_fallback, + }, + }, + |response| match response { + Response::SearchCompleted { + backend, + status, + results, + .. + } => Ok(SearchOutcome { + backend, + status, + results, + }), + other => unexpected_response("search", other), + }, + None, + ) + .await + } + + pub(crate) async fn glob(&self, request: GlobRequest) -> Result { + self.send_repo_request( + "glob", + Request::Glob { + params: GlobParams { + repo_id: self.repo_id.clone(), + scope: request.scope, + }, + }, + |response| match response { + Response::GlobCompleted { status, paths, .. } => Ok(GlobOutcome { status, paths }), + other => unexpected_response("glob", other), + }, + None, + ) + .await + } + + pub(crate) async fn index_build(&self) -> Result { + self.send_repo_request( + "base_snapshot/build", + Request::BaseSnapshotBuild { + params: self.repo_ref(), + }, + |response| match response { + Response::TaskStarted { task } => Ok(task), + other => unexpected_response("base_snapshot/build", other), + }, + None, + ) + .await + } + + pub(crate) async fn index_rebuild(&self) -> Result { + self.send_repo_request( + "base_snapshot/rebuild", + Request::BaseSnapshotRebuild { + params: self.repo_ref(), + }, + |response| match response { + Response::TaskStarted { task } => Ok(task), + other => unexpected_response("base_snapshot/rebuild", other), + }, + None, + ) + .await + } + + pub(crate) async fn task_status(&self, task_id: impl Into) -> Result { + self.send_repo_request( + "task/status", + Request::TaskStatus { + params: TaskRef { + task_id: task_id.into(), + }, + }, + |response| match response { + Response::TaskStatus { task } => Ok(task), + other => unexpected_response("task/status", other), + }, + None, + ) + .await + } + + pub(crate) async fn close(&self) -> Result<()> { + self.send_repo_request( + "close_repo", + Request::CloseRepo { + params: self.repo_ref(), + }, + |response| match response { + Response::RepoClosed { .. } => Ok(()), + other => unexpected_response("close_repo", other), + }, + Some(REPO_CLOSE_TIMEOUT), + ) + .await + } + + fn repo_ref(&self) -> RepoRef { + RepoRef { + repo_id: self.repo_id.clone(), + } + } + + async fn send_repo_request( + &self, + _method: &'static str, + request: Request, + decode: impl FnOnce(Response) -> Result, + timeout: Option, + ) -> Result { + let response = self + .client + .send_request_with_restart_timeout(request, timeout) + .await?; + decode(response) + } +} + +impl AsyncDaemonClient { + async fn spawn(daemon_program: Option) -> Result { + let program = daemon_program + .or_else(|| std::env::var_os("FLASHGREP_DAEMON_BIN")) + .unwrap_or_else(|| OsString::from("flashgrep")); + + let mut command = Command::new(program); + command + .arg("serve") + .arg("--stdio") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = command.spawn()?; + let stdin = child.stdin.take().ok_or_else(|| { + AppError::Protocol("flashgrep stdio backend did not provide stdin".into()) + })?; + let stdout = child.stdout.take().ok_or_else(|| { + AppError::Protocol("flashgrep stdio backend did not provide stdout".into()) + })?; + let stderr = child.stderr.take(); + + let client = Self { + child: Mutex::new(Some(child)), + writer: Mutex::new(BufWriter::new(stdin)), + shared: Arc::new(DaemonShared::default()), + next_id: AtomicU64::new(1), + reader_task: Mutex::new(None), + stderr_task: Mutex::new(None), + }; + + client.spawn_reader_task(stdout).await; + client.spawn_stderr_task(stderr).await; + client.initialize().await?; + Ok(client) + } + + fn is_closed(&self) -> bool { + self.shared.closed.load(Ordering::Relaxed) + } + + async fn initialize(&self) -> Result<()> { + match self + .send_request_with_timeout( + Request::Initialize { + params: InitializeParams { + client_info: Some(ClientInfo { + name: CLIENT_NAME.to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + capabilities: ClientCapabilities::default(), + }, + }, + None, + ) + .await? + { + Response::InitializeResult { .. } => self.send_notification(Request::Initialized).await, + other => unexpected_response("initialize", other), + } + } + + async fn send_request_with_timeout( + &self, + request: Request, + request_timeout: Option, + ) -> Result { + if self.is_closed() { + return Err(AppError::Protocol( + "flashgrep stdio backend is not running".into(), + )); + } + + let request_name = request_name(&request); + let request_id = self.next_id.fetch_add(1, Ordering::Relaxed); + let envelope = RequestEnvelope { + jsonrpc: JSONRPC_VERSION.to_string(), + id: Some(request_id), + request, + }; + let (sender, receiver) = oneshot::channel(); + self.shared.pending.lock().await.insert(request_id, sender); + + if let Err(error) = self.write_envelope(&envelope).await { + self.shared.pending.lock().await.remove(&request_id); + return Err(error); + } + + let response = match request_timeout { + Some(duration) => match timeout(duration, receiver).await { + Ok(result) => result.map_err(|_| { + AppError::Protocol( + "flashgrep stdio backend closed without sending a response".into(), + ) + })??, + Err(_) => { + self.shared.pending.lock().await.remove(&request_id); + return Err(AppError::Protocol(format!( + "flashgrep stdio backend request timed out: {request_name}" + ))); + } + }, + None => receiver.await.map_err(|_| { + AppError::Protocol( + "flashgrep stdio backend closed without sending a response".into(), + ) + })??, + }; + decode_response(request_id, response) + } + + async fn send_notification(&self, request: Request) -> Result<()> { + let envelope = RequestEnvelope { + jsonrpc: JSONRPC_VERSION.to_string(), + id: None, + request, + }; + self.write_envelope(&envelope).await + } + + async fn write_envelope(&self, envelope: &RequestEnvelope) -> Result<()> { + let mut writer = self.writer.lock().await; + write_content_length_message(&mut writer, envelope).await + } + + async fn shutdown(&self) -> Result<()> { + let shutdown_result = if self.is_closed() { + Ok(()) + } else { + self.send_request_with_timeout(Request::Shutdown, Some(SHUTDOWN_REQUEST_TIMEOUT)) + .await + .map(|_| ()) + }; + + self.mark_closed(); + self.reject_pending("flashgrep stdio backend is shutting down") + .await; + + let wait_result = self.wait_for_child_exit().await; + self.stop_background_tasks().await; + + shutdown_result?; + wait_result + } + + fn mark_closed(&self) { + self.shared.closed.store(true, Ordering::Relaxed); + } + + async fn wait_for_child_exit(&self) -> Result<()> { + let mut child = self.child.lock().await.take(); + let Some(child) = child.as_mut() else { + return Ok(()); + }; + + match timeout(SHUTDOWN_TIMEOUT, child.wait()).await { + Ok(wait_result) => { + wait_result?; + Ok(()) + } + Err(_) => { + child.kill().await?; + child.wait().await?; + Ok(()) + } + } + } + + async fn stop_background_tasks(&self) { + if let Some(handle) = self.reader_task.lock().await.take() { + handle.abort(); + let _ = handle.await; + } + if let Some(handle) = self.stderr_task.lock().await.take() { + handle.abort(); + let _ = handle.await; + } + } + + async fn spawn_reader_task(&self, stdout: ChildStdout) { + let shared = self.shared.clone(); + let handle = tokio::spawn(async move { + let mut reader = BufReader::new(stdout); + let result = reader_loop(&mut reader, &shared).await; + shared.closed.store(true, Ordering::Relaxed); + match result { + Ok(()) => { + reject_pending_requests( + &shared.pending, + "flashgrep stdio backend closed its stdout pipe", + ) + .await; + } + Err(error) => { + reject_pending_requests( + &shared.pending, + format!("flashgrep stdio backend reader failed: {error}"), + ) + .await; + } + } + }); + + *self.reader_task.lock().await = Some(handle); + } + + async fn spawn_stderr_task(&self, stderr: Option) { + let Some(stderr) = stderr else { + return; + }; + + let handle = tokio::spawn(async move { + let mut reader = BufReader::new(stderr); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => break, + Ok(_) => log::debug!("flashgrep stdio daemon stderr: {}", line.trim_end()), + Err(error) => { + log::debug!("flashgrep stdio daemon stderr read failed: {}", error); + break; + } + } + } + }); + + *self.stderr_task.lock().await = Some(handle); + } + + async fn reject_pending(&self, message: impl Into) { + reject_pending_requests(&self.shared.pending, message.into()).await; + } +} + +async fn reader_loop( + reader: &mut BufReader, + shared: &Arc, +) -> Result<()> { + while let Some(message) = read_content_length_message(reader).await? { + match message { + ServerMessage::Response(response) => { + let Some(request_id) = response.id else { + continue; + }; + if let Some(sender) = shared.pending.lock().await.remove(&request_id) { + let _ = sender.send(Ok(response)); + } + } + ServerMessage::Notification(_) => {} + } + } + Ok(()) +} + +async fn reject_pending_requests(pending: &Mutex, message: impl Into) { + let message = message.into(); + let mut pending = pending.lock().await; + if pending.is_empty() { + return; + } + + for (_, sender) in pending.drain() { + let _ = sender.send(Err(AppError::Protocol(message.clone()))); + } +} + +async fn read_content_length_message( + reader: &mut BufReader, +) -> Result> { + let mut content_length = None; + + loop { + let mut line = String::new(); + let read = reader.read_line(&mut line).await?; + if read == 0 { + return Ok(None); + } + if line == "\r\n" || line == "\n" { + break; + } + + let trimmed = line.trim_end_matches(['\r', '\n']); + let Some((name, value)) = trimmed.split_once(':') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("Content-Length") { + let length = value.trim().parse::().map_err(|error| { + AppError::Protocol(format!("invalid Content-Length header: {error}")) + })?; + content_length = Some(length); + } + } + + let content_length = + content_length.ok_or_else(|| AppError::Protocol("missing Content-Length header".into()))?; + let mut body = vec![0u8; content_length]; + reader.read_exact(&mut body).await?; + serde_json::from_slice(&body) + .map_err(|error| AppError::Protocol(format!("failed to decode daemon message: {error}"))) +} + +async fn write_content_length_message( + writer: &mut BufWriter, + message: &impl Serialize, +) -> Result<()> { + let body = serde_json::to_vec(message) + .map_err(|error| AppError::Protocol(format!("failed to encode request: {error}")))?; + writer + .write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()) + .await?; + writer.write_all(&body).await?; + writer.flush().await?; + Ok(()) +} + +fn request_name(request: &Request) -> &'static str { + match request { + Request::Initialize { .. } => "initialize", + Request::Initialized => "initialized", + Request::Ping => "ping", + Request::BaseSnapshotBuild { .. } => "base_snapshot/build", + Request::BaseSnapshotRebuild { .. } => "base_snapshot/rebuild", + Request::TaskStatus { .. } => "task/status", + Request::OpenRepo { .. } => "open_repo", + Request::GetRepoStatus { .. } => "get_repo_status", + Request::Search { .. } => "search", + Request::Glob { .. } => "glob", + Request::CloseRepo { .. } => "close_repo", + Request::Shutdown => "shutdown", + } +} + +fn decode_response(request_id: u64, response: ResponseEnvelope) -> Result { + if response.id != Some(request_id) { + return Err(AppError::Protocol(format!( + "daemon response id mismatch: expected {request_id:?}, got {:?}", + response.id + ))); + } + + if response.jsonrpc != JSONRPC_VERSION { + return Err(AppError::Protocol(format!( + "unsupported daemon jsonrpc version: {}", + response.jsonrpc + ))); + } + + if let Some(error) = response.error { + return Err(AppError::Protocol(error.message)); + } + + response + .result + .ok_or_else(|| AppError::Protocol("daemon response missing result".into())) +} + +fn should_restart_daemon(error: &AppError, daemon: &AsyncDaemonClient) -> bool { + daemon.is_closed() || matches!(error, AppError::Io(_)) +} + +fn unexpected_response(method: &str, response: Response) -> Result { + Err(AppError::Protocol(format!( + "unexpected {method} response: {response:?}" + ))) +} diff --git a/src/crates/core/src/service/search/flashgrep/error.rs b/src/crates/core/src/service/search/flashgrep/error.rs new file mode 100644 index 000000000..edd5c0f2f --- /dev/null +++ b/src/crates/core/src/service/search/flashgrep/error.rs @@ -0,0 +1,13 @@ +use std::io; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("io error: {0}")] + Io(#[from] io::Error), + #[error("protocol error: {0}")] + Protocol(String), +} + +pub type Result = std::result::Result; diff --git a/src/crates/core/src/service/search/flashgrep/mod.rs b/src/crates/core/src/service/search/flashgrep/mod.rs new file mode 100644 index 000000000..a506a56e1 --- /dev/null +++ b/src/crates/core/src/service/search/flashgrep/mod.rs @@ -0,0 +1,13 @@ +mod client; +pub mod error; +mod protocol; +mod types; + +pub(crate) use client::{ManagedClient, RepoSession}; +pub(crate) use protocol::{FileMatch, MatchLocation, SearchHit, SearchLine}; +pub(crate) use types::{ + ConsistencyMode, DirtyFileStats, FileCount, GlobRequest, OpenRepoParams, PathScope, QuerySpec, + RefreshPolicyConfig, RepoConfig, RepoPhase, RepoStatus, SearchBackend, SearchModeConfig, + SearchRequest, SearchResults, TaskKind, TaskPhase, TaskState, TaskStatus, + WorkspaceOverlayStatus, +}; diff --git a/src/crates/core/src/service/search/flashgrep/protocol.rs b/src/crates/core/src/service/search/flashgrep/protocol.rs new file mode 100644 index 000000000..8aa398b8b --- /dev/null +++ b/src/crates/core/src/service/search/flashgrep/protocol.rs @@ -0,0 +1,557 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +fn default_jsonrpc_version() -> String { + "2.0".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RequestEnvelope { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(flatten)] + pub request: Request, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "method", rename_all = "snake_case")] +pub(crate) enum Request { + Initialize { + params: InitializeParams, + }, + Initialized, + Ping, + #[serde(rename = "base_snapshot/build")] + BaseSnapshotBuild { + params: RepoRef, + }, + #[serde(rename = "base_snapshot/rebuild")] + BaseSnapshotRebuild { + params: RepoRef, + }, + #[serde(rename = "task/status")] + TaskStatus { + params: TaskRef, + }, + OpenRepo { + params: OpenRepoParams, + }, + GetRepoStatus { + params: RepoRef, + }, + Search { + params: SearchParams, + }, + Glob { + params: GlobParams, + }, + CloseRepo { + params: RepoRef, + }, + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct InitializeParams { + #[serde(default)] + pub client_info: Option, + #[serde(default)] + pub capabilities: ClientCapabilities, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ClientInfo { + pub name: String, + #[serde(default)] + pub version: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct ClientCapabilities { + #[serde(default)] + pub progress: bool, + #[serde(default)] + pub status_notifications: bool, + #[serde(default)] + pub task_notifications: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RepoRef { + pub repo_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct TaskRef { + pub task_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct OpenRepoParams { + pub repo_path: PathBuf, + #[serde(default)] + pub storage_root: Option, + #[serde(default)] + pub config: RepoConfig, + #[serde(default)] + pub refresh: RefreshPolicyConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SearchParams { + pub repo_id: String, + pub query: QuerySpec, + #[serde(default)] + pub scope: PathScope, + #[serde(default)] + pub consistency: ConsistencyMode, + #[serde(default)] + pub allow_scan_fallback: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct GlobParams { + pub repo_id: String, + #[serde(default)] + pub scope: PathScope, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct QuerySpec { + pub pattern: String, + #[serde(default)] + pub patterns: Vec, + #[serde(default)] + pub case_insensitive: bool, + #[serde(default)] + pub multiline: bool, + #[serde(default)] + pub dot_matches_new_line: bool, + #[serde(default)] + pub fixed_strings: bool, + #[serde(default)] + pub word_regexp: bool, + #[serde(default)] + pub line_regexp: bool, + #[serde(default)] + pub before_context: usize, + #[serde(default)] + pub after_context: usize, + #[serde(default = "default_top_k_tokens")] + pub top_k_tokens: usize, + #[serde(default)] + pub max_count: Option, + #[serde(default)] + pub global_max_results: Option, + #[serde(default)] + pub search_mode: SearchModeConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct PathScope { + #[serde(default)] + pub roots: Vec, + #[serde(default)] + pub globs: Vec, + #[serde(default)] + pub iglobs: Vec, + #[serde(default)] + pub type_add: Vec, + #[serde(default)] + pub type_clear: Vec, + #[serde(default)] + pub types: Vec, + #[serde(default)] + pub type_not: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RepoConfig { + #[serde(default)] + pub tokenizer: TokenizerModeConfig, + #[serde(default)] + pub corpus_mode: CorpusModeConfig, + #[serde(default)] + pub include_hidden: bool, + #[serde(default = "default_max_file_size")] + pub max_file_size: u64, + #[serde(default = "default_min_sparse_len")] + pub min_sparse_len: usize, + #[serde(default = "default_max_sparse_len")] + pub max_sparse_len: usize, +} + +impl Default for RepoConfig { + fn default() -> Self { + Self { + tokenizer: TokenizerModeConfig::default(), + corpus_mode: CorpusModeConfig::default(), + include_hidden: false, + max_file_size: default_max_file_size(), + min_sparse_len: default_min_sparse_len(), + max_sparse_len: default_max_sparse_len(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RefreshPolicyConfig { + #[serde(default = "default_rebuild_dirty_threshold")] + pub rebuild_dirty_threshold: usize, + #[serde(default = "default_overlay_auto_checkpoint_max_uncommitted_ops")] + pub overlay_auto_checkpoint_max_uncommitted_ops: u64, + #[serde(default = "default_overlay_merge_min_delay_ms")] + pub overlay_merge_min_delay_ms: u64, + #[serde(default = "default_overlay_merge_retry_delay_ms")] + pub overlay_merge_retry_delay_ms: u64, +} + +impl Default for RefreshPolicyConfig { + fn default() -> Self { + Self { + rebuild_dirty_threshold: default_rebuild_dirty_threshold(), + overlay_auto_checkpoint_max_uncommitted_ops: + default_overlay_auto_checkpoint_max_uncommitted_ops(), + overlay_merge_min_delay_ms: default_overlay_merge_min_delay_ms(), + overlay_merge_retry_delay_ms: default_overlay_merge_retry_delay_ms(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub(crate) enum TokenizerModeConfig { + Trigram, + #[default] + SparseNgram, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub(crate) enum CorpusModeConfig { + #[default] + RespectIgnore, + NoIgnore, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SearchModeConfig { + CountOnly, + CountMatches, + FirstHitOnly, + #[default] + MaterializeMatches, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ConsistencyMode { + SnapshotOnly, + #[default] + WorkspaceEventual, + WorkspaceStrict, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ResponseEnvelope { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct NotificationEnvelope { + #[serde(default = "default_jsonrpc_version")] + pub jsonrpc: String, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum ServerMessage { + Response(ResponseEnvelope), + Notification(NotificationEnvelope), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ErrorResponse { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum Response { + InitializeResult { + protocol_version: u32, + server_info: ServerInfo, + capabilities: ServerCapabilities, + search: SearchProtocolCapabilities, + }, + InitializedAck, + Pong { + now_unix_secs: u64, + }, + RepoOpened { + repo_id: String, + status: RepoStatus, + }, + RepoStatus { + status: RepoStatus, + }, + TaskStarted { + task: TaskStatus, + }, + TaskStatus { + task: TaskStatus, + }, + SearchCompleted { + repo_id: String, + backend: SearchBackend, + #[serde(default, skip_serializing_if = "Option::is_none")] + consistency_applied: Option, + status: RepoStatus, + results: SearchResults, + }, + GlobCompleted { + repo_id: String, + status: RepoStatus, + paths: Vec, + }, + RepoClosed { + repo_id: String, + }, + ShutdownAck, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ServerCapabilities { + pub workspace_open: bool, + pub workspace_ensure: bool, + pub workspace_list: bool, + pub workspace_refresh: bool, + pub base_snapshot_build: bool, + pub base_snapshot_rebuild: bool, + pub task_status: bool, + pub task_cancel: bool, + pub search_query: bool, + pub glob_query: bool, + pub progress_notifications: bool, + pub status_notifications: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SearchProtocolCapabilities { + #[serde(default)] + pub consistency_modes: Vec, + pub search_modes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct RepoStatus { + pub repo_id: String, + pub repo_path: String, + pub storage_root: String, + pub base_snapshot_root: String, + pub workspace_overlay_root: String, + pub phase: RepoPhase, + pub snapshot_key: Option, + pub last_probe_unix_secs: Option, + pub last_rebuild_unix_secs: Option, + pub dirty_files: DirtyFileStats, + pub rebuild_recommended: bool, + pub active_task_id: Option, + pub probe_healthy: bool, + pub last_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overlay: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RepoPhase { + Opening, + MissingBaseSnapshot, + BuildingBaseSnapshot, + ReadyClean, + ReadyDirty, + RebuildingBaseSnapshot, + Degraded, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct DirtyFileStats { + pub modified: usize, + pub deleted: usize, + pub new: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct WorkspaceOverlayStatus { + pub committed_seq_no: u64, + pub last_seq_no: u64, + pub uncommitted_ops: u64, + pub pending_docs: usize, + pub active_segments: usize, + pub active_delete_segments: usize, + pub merge_requested: bool, + pub merge_running: bool, + pub merge_attempts: u64, + pub merge_completed: u64, + pub merge_failed: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_merge_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct TaskStatus { + pub task_id: String, + pub workspace_id: String, + pub kind: TaskKind, + pub state: TaskState, + pub phase: Option, + pub message: String, + pub processed: usize, + pub total: Option, + pub started_unix_secs: u64, + pub updated_unix_secs: u64, + pub finished_unix_secs: Option, + pub cancellable: bool, + pub error: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum TaskKind { + BuildBaseSnapshot, + RebuildBaseSnapshot, + RefreshWorkspace, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum TaskState { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum TaskPhase { + Scanning, + Tokenizing, + Writing, + Finalizing, + RefreshingOverlay, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SearchBackend { + IndexedSnapshot, + IndexedClean, + IndexedWorkspaceView, + RgFallback, + ScanFallback, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SearchResults { + pub candidate_docs: usize, + pub matched_lines: usize, + pub matched_occurrences: usize, + #[serde(default)] + pub file_counts: Vec, + #[serde(default)] + pub hits: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct FileCount { + pub path: String, + pub matched_lines: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct SearchHit { + pub path: String, + pub matches: Vec, + pub lines: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct FileMatch { + pub location: MatchLocation, + pub snippet: String, + pub matched_text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct MatchLocation { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum SearchLine { + Match { value: FileMatch }, + Context { line_number: usize, snippet: String }, + ContextBreak, +} + +fn default_top_k_tokens() -> usize { + 6 +} + +fn default_max_file_size() -> u64 { + 50 * 1024 * 1024 +} + +fn default_min_sparse_len() -> usize { + 3 +} + +fn default_max_sparse_len() -> usize { + 8 +} + +fn default_rebuild_dirty_threshold() -> usize { + 256 +} + +fn default_overlay_auto_checkpoint_max_uncommitted_ops() -> u64 { + 1_024 +} + +fn default_overlay_merge_min_delay_ms() -> u64 { + 2_000 +} + +fn default_overlay_merge_retry_delay_ms() -> u64 { + 10_000 +} diff --git a/src/crates/core/src/service/search/flashgrep/types.rs b/src/crates/core/src/service/search/flashgrep/types.rs new file mode 100644 index 000000000..9e577ef14 --- /dev/null +++ b/src/crates/core/src/service/search/flashgrep/types.rs @@ -0,0 +1,68 @@ +pub(crate) use super::protocol::{ + ConsistencyMode, DirtyFileStats, FileCount, OpenRepoParams, PathScope, QuerySpec, + RefreshPolicyConfig, RepoConfig, RepoPhase, RepoStatus, SearchBackend, SearchModeConfig, + SearchResults, TaskKind, TaskPhase, TaskState, TaskStatus, WorkspaceOverlayStatus, +}; + +#[derive(Debug, Clone)] +pub(crate) struct SearchRequest { + pub query: QuerySpec, + pub scope: PathScope, + pub consistency: ConsistencyMode, + pub allow_scan_fallback: bool, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct GlobRequest { + pub scope: PathScope, +} + +#[derive(Debug, Clone)] +pub(crate) struct SearchOutcome { + pub backend: SearchBackend, + pub status: RepoStatus, + pub results: SearchResults, +} + +#[derive(Debug, Clone)] +pub(crate) struct GlobOutcome { + pub status: RepoStatus, + pub paths: Vec, +} + +impl SearchRequest { + pub(crate) fn new(query: QuerySpec) -> Self { + Self { + query, + scope: PathScope::default(), + consistency: ConsistencyMode::WorkspaceEventual, + allow_scan_fallback: false, + } + } + + pub(crate) fn with_scope(mut self, scope: PathScope) -> Self { + self.scope = scope; + self + } + + pub(crate) fn with_consistency(mut self, consistency: ConsistencyMode) -> Self { + self.consistency = consistency; + self + } + + pub(crate) fn with_scan_fallback(mut self, allow_scan_fallback: bool) -> Self { + self.allow_scan_fallback = allow_scan_fallback; + self + } +} + +impl GlobRequest { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn with_scope(mut self, scope: PathScope) -> Self { + self.scope = scope; + self + } +} diff --git a/src/crates/core/src/service/search/mod.rs b/src/crates/core/src/service/search/mod.rs new file mode 100644 index 000000000..a467f21a8 --- /dev/null +++ b/src/crates/core/src/service/search/mod.rs @@ -0,0 +1,20 @@ +pub(crate) mod flashgrep; +pub mod service; +pub mod types; + +pub use service::{ + get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, + set_global_workspace_search_service, workspace_search_daemon_available, + workspace_search_daemon_binary_name, workspace_search_daemon_binary_names, + workspace_search_daemon_missing_hint, workspace_search_feature_enabled, + workspace_search_runtime_available, WorkspaceSearchService, +}; +pub use types::{ + ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, + GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchBackend, + WorkspaceSearchContextLine, WorkspaceSearchDirtyFiles, WorkspaceSearchFileCount, + WorkspaceSearchHit, WorkspaceSearchLine, WorkspaceSearchMatch, WorkspaceSearchMatchLocation, + WorkspaceSearchOverlayStatus, WorkspaceSearchRepoPhase, WorkspaceSearchRepoStatus, + WorkspaceSearchTaskKind, WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, + WorkspaceSearchTaskStatus, +}; diff --git a/src/crates/core/src/service/search/service.rs b/src/crates/core/src/service/search/service.rs new file mode 100644 index 000000000..d5d45c880 --- /dev/null +++ b/src/crates/core/src/service/search/service.rs @@ -0,0 +1,828 @@ +use crate::infrastructure::{FileSearchOutcome, FileSearchResult, SearchMatchType}; +use crate::service::config::{get_global_config_service, types::WorkspaceConfig}; +use crate::service::search::flashgrep::{ + ConsistencyMode, GlobRequest, ManagedClient, OpenRepoParams, PathScope, QuerySpec, + RefreshPolicyConfig, RepoConfig, RepoSession, SearchRequest, SearchResults, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, OnceLock, +}; +use std::time::{Duration, Instant}; +use tokio::sync::{Mutex, RwLock}; + +use super::types::{ + ContentSearchOutputMode, ContentSearchRequest, ContentSearchResult, GlobSearchRequest, + GlobSearchResult, IndexTaskHandle, WorkspaceIndexStatus, WorkspaceSearchFileCount, + WorkspaceSearchHit, +}; + +static GLOBAL_WORKSPACE_SEARCH_SERVICE: OnceLock> = OnceLock::new(); + +const DEFAULT_TOP_K_TOKENS: usize = 6; +const DEFAULT_SESSION_IDLE_GRACE: Duration = Duration::from_secs(45); + +#[derive(Debug, Clone)] +struct SessionEntry { + session: Arc, + activity_epoch: Arc, +} + +pub struct WorkspaceSearchService { + client: ManagedClient, + sessions: RwLock>, + open_guards: Mutex>>>, + session_idle_grace: Duration, +} + +impl WorkspaceSearchService { + pub fn new() -> Self { + let mut client = ManagedClient::new() + .with_start_timeout(Duration::from_secs(10)) + .with_retry_interval(Duration::from_millis(100)); + let program = resolve_daemon_program(); + if let Some(program) = program { + log::info!( + "WorkspaceSearchService daemon configured: program={}", + PathBuf::from(&program).display() + ); + client = client.with_daemon_program(program); + } else { + log::info!("WorkspaceSearchService daemon configured: program=flashgrep"); + } + + Self { + client, + sessions: RwLock::new(HashMap::new()), + open_guards: Mutex::new(HashMap::new()), + session_idle_grace: DEFAULT_SESSION_IDLE_GRACE, + } + } + + pub async fn open_repo( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + self.index_status_for_session(session).await + } + + pub async fn get_index_status( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + self.index_status_for_session(session).await + } + + pub async fn build_index(&self, repo_root: impl AsRef) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + let task = session + .index_build() + .await + .map_err(map_flashgrep_error("Failed to start index build"))?; + let repo_status = session + .status() + .await + .map_err(map_flashgrep_error("Failed to fetch repository status"))?; + Ok(IndexTaskHandle { + task: task.into(), + repo_status: repo_status.into(), + }) + } + + pub async fn rebuild_index( + &self, + repo_root: impl AsRef, + ) -> BitFunResult { + let session = self.get_or_open_session(repo_root.as_ref()).await?; + let task = session + .index_rebuild() + .await + .map_err(map_flashgrep_error("Failed to start index rebuild"))?; + let repo_status = session + .status() + .await + .map_err(map_flashgrep_error("Failed to fetch repository status"))?; + Ok(IndexTaskHandle { + task: task.into(), + repo_status: repo_status.into(), + }) + } + + pub async fn search_content( + &self, + request: ContentSearchRequest, + ) -> BitFunResult { + let started_at = Instant::now(); + let pattern_for_log = abbreviate_pattern_for_log(&request.pattern); + let repo_root = normalize_repo_root(&request.repo_root)?; + let normalized_at = Instant::now(); + let scope = build_scope( + &repo_root, + request.search_path.as_deref(), + request.globs, + request.file_types, + request.exclude_file_types, + )?; + let scope_built_at = Instant::now(); + let scope_roots_count = scope.roots.len(); + let scope_globs_count = scope.globs.len(); + let scope_types_count = scope.types.len(); + let max_results = request.max_results.filter(|limit| *limit > 0); + let query = QuerySpec { + pattern: request.pattern, + patterns: Vec::new(), + case_insensitive: !request.case_sensitive, + multiline: request.multiline, + dot_matches_new_line: request.multiline, + fixed_strings: !request.use_regex, + word_regexp: request.whole_word, + line_regexp: false, + before_context: request.before_context, + after_context: request.after_context, + top_k_tokens: DEFAULT_TOP_K_TOKENS, + max_count: None, + global_max_results: max_results, + search_mode: request.output_mode.search_mode(), + }; + + let session = self.get_or_open_session(&repo_root).await?; + let session_ready_at = Instant::now(); + let search = session + .search( + SearchRequest::new(query) + .with_scope(scope) + .with_consistency(ConsistencyMode::WorkspaceEventual) + .with_scan_fallback(true), + ) + .await + .map_err(map_flashgrep_error("Content search failed"))?; + let search_completed_at = Instant::now(); + + let mut results = convert_search_results(&search.results, request.output_mode); + let converted_at = Instant::now(); + let truncated = max_results + .map(|limit| results.len() >= limit) + .unwrap_or(false); + if let Some(limit) = max_results { + results.truncate(limit); + } + + let result = ContentSearchResult { + outcome: FileSearchOutcome { results, truncated }, + file_counts: search + .results + .file_counts + .clone() + .into_iter() + .map(WorkspaceSearchFileCount::from) + .collect(), + hits: search + .results + .hits + .clone() + .into_iter() + .map(WorkspaceSearchHit::from) + .collect(), + backend: search.backend.into(), + repo_status: search.status.into(), + candidate_docs: search.results.candidate_docs, + matched_lines: search.results.matched_lines, + matched_occurrences: search.results.matched_occurrences, + }; + + log::info!( + "Workspace content search completed: repo_root={}, pattern={}, output_mode={:?}, search_mode={:?}, scope_roots={}, globs={}, file_types={}, max_results={:?}, backend={:?}, repo_phase={:?}, rebuild_recommended={}, dirty_modified={}, dirty_deleted={}, dirty_new={}, candidate_docs={}, matched_lines={}, matched_occurrences={}, returned_results={}, truncated={}, normalize_ms={}, build_scope_ms={}, session_ms={}, search_ms={}, convert_ms={}, total_ms={}", + repo_root.display(), + pattern_for_log, + request.output_mode, + request.output_mode.search_mode(), + scope_roots_count, + scope_globs_count, + scope_types_count, + max_results, + result.backend, + result.repo_status.phase, + result.repo_status.rebuild_recommended, + result.repo_status.dirty_files.modified, + result.repo_status.dirty_files.deleted, + result.repo_status.dirty_files.new, + result.candidate_docs, + result.matched_lines, + result.matched_occurrences, + result.outcome.results.len(), + result.outcome.truncated, + normalized_at.duration_since(started_at).as_millis(), + scope_built_at.duration_since(normalized_at).as_millis(), + session_ready_at.duration_since(scope_built_at).as_millis(), + search_completed_at.duration_since(session_ready_at).as_millis(), + converted_at.duration_since(search_completed_at).as_millis(), + converted_at.duration_since(started_at).as_millis(), + ); + + Ok(result) + } + + pub async fn glob(&self, request: GlobSearchRequest) -> BitFunResult { + let repo_root = normalize_repo_root(&request.repo_root)?; + let scope = build_scope( + &repo_root, + request.search_path.as_deref(), + vec![request.pattern], + vec![], + vec![], + )?; + let session = self.get_or_open_session(&repo_root).await?; + let mut outcome = session + .glob(GlobRequest::new().with_scope(scope)) + .await + .map_err(map_flashgrep_error("Glob search failed"))?; + outcome.paths.sort(); + if request.limit > 0 { + outcome.paths.truncate(request.limit); + } else { + outcome.paths.clear(); + } + + Ok(GlobSearchResult { + paths: outcome.paths, + repo_status: outcome.status.into(), + }) + } + + pub fn schedule_repo_release(self: &Arc, repo_root: impl AsRef) { + let Ok(repo_root) = normalize_repo_root(repo_root.as_ref()) else { + return; + }; + let service = Arc::clone(self); + tokio::spawn(async move { + service.release_repo_after_grace(repo_root).await; + }); + } + + pub async fn shutdown_all_daemons(&self) { + let released_sessions = self.sessions.write().await.drain().count(); + self.open_guards.lock().await.clear(); + if released_sessions > 0 { + log::info!( + "Workspace search shutdown releasing sessions via daemon shutdown: count={}", + released_sessions + ); + } + if let Err(error) = self.client.shutdown_daemon().await { + log::debug!("Workspace search daemon shutdown skipped: {}", error); + } + } + + pub async fn stop_all_daemons(&self) { + let released_sessions = self.sessions.write().await.drain().count(); + self.open_guards.lock().await.clear(); + if released_sessions > 0 { + log::info!( + "Workspace search stop releasing sessions via daemon stop: count={}", + released_sessions + ); + } + if let Err(error) = self.client.stop_daemon().await { + log::debug!("Workspace search daemon stop skipped: {}", error); + } + } + + async fn get_or_open_session(&self, repo_root: &Path) -> BitFunResult> { + let repo_root = normalize_repo_root(repo_root)?; + let repo_guard = { + let mut guards = self.open_guards.lock().await; + guards + .entry(repo_root.clone()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + }; + let _repo_guard = repo_guard.lock().await; + + if let Some(existing) = self.sessions.read().await.get(&repo_root).cloned() { + existing.activity_epoch.fetch_add(1, Ordering::Relaxed); + if existing.session.status().await.is_ok() { + return Ok(existing.session); + } + log::warn!( + "Workspace search session became unhealthy, reopening repository session: path={}", + repo_root.display() + ); + self.sessions.write().await.remove(&repo_root); + if let Err(error) = existing.session.close().await { + log::debug!( + "Workspace search repo close after unhealthy session failed: path={}, error={}", + repo_root.display(), + error + ); + } + } + + let repo_config = repo_config_for_workspace_search().await; + let params = OpenRepoParams { + repo_path: repo_root.clone(), + storage_root: Some(default_storage_root(&repo_root)), + config: repo_config, + refresh: RefreshPolicyConfig::default(), + }; + + let entry = + SessionEntry { + session: Arc::new(self.client.open_repo(params).await.map_err( + map_flashgrep_error("Failed to open flashgrep repository session"), + )?), + activity_epoch: Arc::new(AtomicU64::new(1)), + }; + + let mut sessions = self.sessions.write().await; + Ok(sessions + .entry(repo_root) + .or_insert_with(|| entry.clone()) + .session + .clone()) + } + + async fn index_status_for_session( + &self, + session: Arc, + ) -> BitFunResult { + let repo_status = session + .status() + .await + .map_err(map_flashgrep_error("Failed to fetch repository status"))?; + let active_task = match repo_status.active_task_id.clone() { + Some(task_id) => match session.task_status(task_id).await { + Ok(task) => Some(task), + Err(error) => { + log::warn!("Failed to fetch active flashgrep task status: {}", error); + None + } + }, + None => None, + }; + + Ok(WorkspaceIndexStatus { + repo_status: repo_status.into(), + active_task: active_task.map(Into::into), + }) + } + + async fn release_repo_after_grace(self: Arc, repo_root: PathBuf) { + let Some(expected_epoch) = self + .sessions + .read() + .await + .get(&repo_root) + .map(|entry| entry.activity_epoch.load(Ordering::Relaxed)) + else { + return; + }; + + tokio::time::sleep(self.session_idle_grace).await; + + let entry = { + let mut sessions = self.sessions.write().await; + let Some(entry) = sessions.get(&repo_root) else { + return; + }; + if entry.activity_epoch.load(Ordering::Relaxed) != expected_epoch { + return; + } + sessions.remove(&repo_root) + }; + + if let Some(entry) = entry { + log::info!( + "Releasing idle workspace search repository session: path={}", + repo_root.display() + ); + if let Err(error) = entry.session.close().await { + log::warn!( + "Failed to release idle workspace search repository session: path={}, error={}", + repo_root.display(), + error + ); + } + self.open_guards.lock().await.remove(&repo_root); + } + } +} + +impl Default for WorkspaceSearchService { + fn default() -> Self { + Self::new() + } +} + +pub fn set_global_workspace_search_service(service: Arc) { + let _ = GLOBAL_WORKSPACE_SEARCH_SERVICE.set(service); +} + +pub fn get_global_workspace_search_service() -> Option> { + GLOBAL_WORKSPACE_SEARCH_SERVICE.get().cloned() +} + +pub fn workspace_search_daemon_binary_names() -> &'static [&'static str] { + if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + &["flashgrep-x86_64-pc-windows-msvc.exe"] + } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) { + &["flashgrep-aarch64-pc-windows-msvc.exe"] + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + &["flashgrep-x86_64-apple-darwin"] + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + &["flashgrep-aarch64-apple-darwin"] + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + &["flashgrep-x86_64-unknown-linux-gnu"] + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + &["flashgrep-aarch64-unknown-linux-gnu"] + } else if cfg!(windows) { + &["flashgrep.exe"] + } else { + &["flashgrep"] + } +} + +pub fn workspace_search_daemon_binary_name() -> &'static str { + workspace_search_daemon_binary_names() + .first() + .copied() + .unwrap_or("flashgrep") +} + +pub fn workspace_search_daemon_missing_hint() -> String { + let bundled_paths = workspace_search_daemon_binary_names() + .iter() + .map(|name| format!("flashgrep/{name}")) + .collect::>() + .join(", "); + format!( + "workspace search daemon binary is missing; expected one of bundled resources [{}] or a valid FLASHGREP_DAEMON_BIN override", + bundled_paths + ) +} + +pub fn workspace_search_daemon_available() -> bool { + resolve_workspace_search_daemon_program_path().is_some() +} + +pub async fn workspace_search_feature_enabled() -> bool { + match get_global_config_service().await { + Ok(config_service) => config_service + .get_config::(Some("app.ai_experience.enable_workspace_search")) + .await + .unwrap_or(false), + Err(_) => false, + } +} + +pub async fn workspace_search_runtime_available() -> bool { + workspace_search_feature_enabled().await && workspace_search_daemon_available() +} + +pub fn resolve_workspace_search_daemon_program_path() -> Option { + if let Some(program) = std::env::var_os("FLASHGREP_DAEMON_BIN") { + let path = PathBuf::from(program); + if path.exists() { + return Some(path); + } + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir.join("../../.."); + let binary_names = workspace_search_daemon_binary_names(); + let profile = std::env::var("PROFILE").ok(); + + for candidate in daemon_binary_candidates(&workspace_root, binary_names, profile.as_deref()) { + if candidate.exists() { + return Some(candidate); + } + } + + which::which("flashgrep").ok() +} + +fn resolve_daemon_program() -> Option { + resolve_workspace_search_daemon_program_path().map(PathBuf::into_os_string) +} + +fn daemon_binary_candidates( + workspace_root: &Path, + binary_names: &[&str], + current_profile: Option<&str>, +) -> Vec { + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_candidate = |path: PathBuf| { + if seen.insert(path.clone()) { + candidates.push(path); + } + }; + + if let Ok(current_exe) = std::env::current_exe() { + if let Some(parent) = current_exe.parent() { + for binary_name in binary_names { + push_candidate(parent.join(binary_name)); + } + push_exe_relative_bundle_candidates(&mut push_candidate, parent, binary_names); + } + } + + for profile in current_profile + .into_iter() + .chain(["debug", "release", "release-fast"]) + { + for binary_name in binary_names { + push_candidate( + workspace_root + .join("target") + .join(profile) + .join(binary_name), + ); + } + } + + candidates +} + +fn push_exe_relative_bundle_candidates( + push_candidate: &mut impl FnMut(PathBuf), + exe_dir: &Path, + binary_names: &[&str], +) { + if cfg!(target_os = "macos") { + for binary_name in binary_names { + push_candidate(exe_dir.join("../Resources/flashgrep").join(binary_name)); + } + } + + for binary_name in binary_names { + push_candidate(exe_dir.join("flashgrep").join(binary_name)); + push_candidate(exe_dir.join("resources/flashgrep").join(binary_name)); + } + + if cfg!(target_os = "linux") { + for binary_name in binary_names { + push_candidate(exe_dir.join("../lib/bitfun/flashgrep").join(binary_name)); + push_candidate(exe_dir.join("../share/bitfun/flashgrep").join(binary_name)); + push_candidate( + exe_dir + .join("../share/com.bitfun.desktop/flashgrep") + .join(binary_name), + ); + } + } +} + +fn default_storage_root(repo_root: &Path) -> PathBuf { + repo_root + .join(".bitfun") + .join("search") + .join("flashgrep-index") +} + +async fn repo_config_for_workspace_search() -> RepoConfig { + let max_file_size = match get_global_config_service().await { + Ok(config_service) => match config_service + .get_config::(Some("workspace")) + .await + { + Ok(workspace_config) => workspace_config.max_file_size, + Err(error) => { + log::warn!( + "Failed to read workspace config for flashgrep repo open, using default max_file_size: {}", + error + ); + WorkspaceConfig::default().max_file_size + } + }, + Err(error) => { + log::warn!( + "Global config service unavailable for flashgrep repo open, using default max_file_size: {}", + error + ); + WorkspaceConfig::default().max_file_size + } + }; + + RepoConfig { + max_file_size, + ..RepoConfig::default() + } +} + +fn abbreviate_pattern_for_log(pattern: &str) -> String { + const MAX_CHARS: usize = 120; + let mut chars = pattern.chars(); + let abbreviated: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { + format!("{}...", abbreviated) + } else { + abbreviated + } +} + +fn normalize_repo_root(repo_root: &Path) -> BitFunResult { + if !repo_root.exists() { + return Err(BitFunError::service(format!( + "Search root does not exist: {}", + repo_root.display() + ))); + } + if !repo_root.is_dir() { + return Err(BitFunError::service(format!( + "Search root is not a directory: {}", + repo_root.display() + ))); + } + + dunce::canonicalize(repo_root).map_err(|error| { + BitFunError::service(format!( + "Failed to normalize search root {}: {}", + repo_root.display(), + error + )) + }) +} + +fn build_scope( + repo_root: &Path, + search_path: Option<&Path>, + globs: Vec, + file_types: Vec, + exclude_file_types: Vec, +) -> BitFunResult { + let roots = match search_path { + Some(path) => { + let normalized = normalize_scope_path(repo_root, path)?; + if normalized == repo_root { + Vec::new() + } else { + vec![normalized] + } + } + None => Vec::new(), + }; + + Ok(PathScope { + roots, + globs, + iglobs: Vec::new(), + type_add: Vec::new(), + type_clear: Vec::new(), + types: file_types, + type_not: exclude_file_types, + }) +} + +fn normalize_scope_path(repo_root: &Path, search_path: &Path) -> BitFunResult { + let normalized = dunce::canonicalize(search_path).map_err(|error| { + BitFunError::service(format!( + "Failed to normalize search path {}: {}", + search_path.display(), + error + )) + })?; + if !normalized.starts_with(repo_root) { + return Err(BitFunError::service(format!( + "Search path is outside workspace root: {}", + normalized.display() + ))); + } + Ok(normalized) +} + +fn convert_search_results( + search_results: &SearchResults, + output_mode: ContentSearchOutputMode, +) -> Vec { + match output_mode { + ContentSearchOutputMode::Content => convert_hits_to_file_search_results(search_results), + ContentSearchOutputMode::Count => convert_file_counts_to_search_results(search_results), + ContentSearchOutputMode::FilesWithMatches => { + convert_hits_to_file_only_results(search_results) + } + } +} + +fn convert_file_counts_to_search_results(search_results: &SearchResults) -> Vec { + search_results + .file_counts + .iter() + .map(|count| FileSearchResult { + path: count.path.clone(), + name: Path::new(&count.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&count.path) + .to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: None, + matched_content: Some(count.matched_lines.to_string()), + preview_before: None, + preview_inside: None, + preview_after: None, + }) + .collect() +} + +fn convert_hits_to_file_search_results(search_results: &SearchResults) -> Vec { + let mut file_results = Vec::new(); + for hit in &search_results.hits { + let name = Path::new(&hit.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&hit.path) + .to_string(); + + let mut lines = BTreeMap::new(); + for file_match in &hit.matches { + lines + .entry(file_match.location.line) + .or_insert_with(|| file_match.clone()); + } + + for (_, file_match) in lines { + let (preview_before, preview_inside, preview_after) = + split_preview(&file_match.snippet, &file_match.matched_text); + file_results.push(FileSearchResult { + path: hit.path.clone(), + name: name.clone(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: Some(file_match.location.line), + matched_content: Some(file_match.snippet), + preview_before, + preview_inside, + preview_after, + }); + } + } + file_results +} + +fn convert_hits_to_file_only_results(search_results: &SearchResults) -> Vec { + search_results + .hits + .iter() + .map(|hit| FileSearchResult { + path: hit.path.clone(), + name: Path::new(&hit.path) + .file_name() + .and_then(|file_name| file_name.to_str()) + .unwrap_or(&hit.path) + .to_string(), + is_directory: false, + match_type: SearchMatchType::Content, + line_number: None, + matched_content: None, + preview_before: None, + preview_inside: None, + preview_after: None, + }) + .collect() +} + +fn split_preview( + snippet: &str, + matched_text: &str, +) -> (Option, Option, Option) { + if matched_text.is_empty() { + return (None, Some(snippet.to_string()), None); + } + + if let Some(offset) = snippet.find(matched_text) { + let before = snippet[..offset].to_string(); + let inside = matched_text.to_string(); + let after = snippet[offset + matched_text.len()..].to_string(); + return ( + (!before.is_empty()).then_some(before), + Some(inside), + (!after.is_empty()).then_some(after), + ); + } + + (None, Some(snippet.to_string()), None) +} + +fn map_flashgrep_error( + prefix: &'static str, +) -> impl Fn(crate::service::search::flashgrep::error::AppError) -> BitFunError { + move |error| { + let detail = match &error { + crate::service::search::flashgrep::error::AppError::Io(io_error) + if io_error.kind() == std::io::ErrorKind::NotFound => + { + format!("{error}. {}", workspace_search_daemon_missing_hint()) + } + _ => error.to_string(), + }; + BitFunError::service(format!("{prefix}: {detail}")) + } +} diff --git a/src/crates/core/src/service/search/types.rs b/src/crates/core/src/service/search/types.rs new file mode 100644 index 000000000..77e734a66 --- /dev/null +++ b/src/crates/core/src/service/search/types.rs @@ -0,0 +1,436 @@ +use crate::infrastructure::FileSearchOutcome; +use crate::service::search::flashgrep::{ + DirtyFileStats as FlashgrepDirtyFileStats, FileCount as FlashgrepFileCount, + FileMatch as FlashgrepFileMatch, MatchLocation as FlashgrepMatchLocation, + RepoPhase as FlashgrepRepoPhase, RepoStatus as FlashgrepRepoStatus, + SearchBackend as FlashgrepSearchBackend, SearchHit as FlashgrepSearchHit, + SearchLine as FlashgrepSearchLine, SearchModeConfig, TaskKind as FlashgrepTaskKind, + TaskPhase as FlashgrepTaskPhase, TaskState as FlashgrepTaskState, + TaskStatus as FlashgrepTaskStatus, WorkspaceOverlayStatus as FlashgrepWorkspaceOverlayStatus, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContentSearchOutputMode { + Content, + FilesWithMatches, + Count, +} + +impl ContentSearchOutputMode { + pub(crate) fn search_mode(self) -> SearchModeConfig { + match self { + Self::Content => SearchModeConfig::MaterializeMatches, + Self::Count => SearchModeConfig::CountOnly, + Self::FilesWithMatches => SearchModeConfig::FirstHitOnly, + } + } +} + +#[derive(Debug, Clone)] +pub struct ContentSearchRequest { + pub repo_root: PathBuf, + pub search_path: Option, + pub pattern: String, + pub output_mode: ContentSearchOutputMode, + pub case_sensitive: bool, + pub use_regex: bool, + pub whole_word: bool, + pub multiline: bool, + pub before_context: usize, + pub after_context: usize, + pub max_results: Option, + pub globs: Vec, + pub file_types: Vec, + pub exclude_file_types: Vec, +} + +#[derive(Debug, Clone)] +pub struct GlobSearchRequest { + pub repo_root: PathBuf, + pub search_path: Option, + pub pattern: String, + pub limit: usize, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchBackend { + Indexed, + IndexedWorkspace, + TextFallback, + ScanFallback, +} + +impl From for WorkspaceSearchBackend { + fn from(value: FlashgrepSearchBackend) -> Self { + match value { + FlashgrepSearchBackend::IndexedSnapshot | FlashgrepSearchBackend::IndexedClean => { + Self::Indexed + } + FlashgrepSearchBackend::IndexedWorkspaceView => Self::IndexedWorkspace, + FlashgrepSearchBackend::RgFallback => Self::TextFallback, + FlashgrepSearchBackend::ScanFallback => Self::ScanFallback, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchRepoPhase { + Preparing, + NeedsIndex, + Building, + Ready, + TrackingChanges, + Refreshing, + Limited, +} + +impl From for WorkspaceSearchRepoPhase { + fn from(value: FlashgrepRepoPhase) -> Self { + match value { + FlashgrepRepoPhase::Opening => Self::Preparing, + FlashgrepRepoPhase::MissingBaseSnapshot => Self::NeedsIndex, + FlashgrepRepoPhase::BuildingBaseSnapshot => Self::Building, + FlashgrepRepoPhase::ReadyClean => Self::Ready, + FlashgrepRepoPhase::ReadyDirty => Self::TrackingChanges, + FlashgrepRepoPhase::RebuildingBaseSnapshot => Self::Refreshing, + FlashgrepRepoPhase::Degraded => Self::Limited, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskKind { + Build, + Rebuild, + Refresh, +} + +impl From for WorkspaceSearchTaskKind { + fn from(value: FlashgrepTaskKind) -> Self { + match value { + FlashgrepTaskKind::BuildBaseSnapshot => Self::Build, + FlashgrepTaskKind::RebuildBaseSnapshot => Self::Rebuild, + FlashgrepTaskKind::RefreshWorkspace => Self::Refresh, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskState { + Queued, + Running, + Completed, + Failed, + Cancelled, +} + +impl From for WorkspaceSearchTaskState { + fn from(value: FlashgrepTaskState) -> Self { + match value { + FlashgrepTaskState::Queued => Self::Queued, + FlashgrepTaskState::Running => Self::Running, + FlashgrepTaskState::Completed => Self::Completed, + FlashgrepTaskState::Failed => Self::Failed, + FlashgrepTaskState::Cancelled => Self::Cancelled, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceSearchTaskPhase { + Discovering, + Processing, + Persisting, + Finalizing, + Refreshing, +} + +impl From for WorkspaceSearchTaskPhase { + fn from(value: FlashgrepTaskPhase) -> Self { + match value { + FlashgrepTaskPhase::Scanning => Self::Discovering, + FlashgrepTaskPhase::Tokenizing => Self::Processing, + FlashgrepTaskPhase::Writing => Self::Persisting, + FlashgrepTaskPhase::Finalizing => Self::Finalizing, + FlashgrepTaskPhase::RefreshingOverlay => Self::Refreshing, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchDirtyFiles { + pub modified: usize, + pub deleted: usize, + pub new: usize, +} + +impl From for WorkspaceSearchDirtyFiles { + fn from(value: FlashgrepDirtyFileStats) -> Self { + Self { + modified: value.modified, + deleted: value.deleted, + new: value.new, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchOverlayStatus { + pub committed_seq_no: u64, + pub last_seq_no: u64, + pub uncommitted_ops: u64, + pub pending_docs: usize, + pub active_segments: usize, + pub active_delete_segments: usize, + pub merge_requested: bool, + pub merge_running: bool, + pub merge_attempts: u64, + pub merge_completed: u64, + pub merge_failed: u64, + pub last_merge_error: Option, +} + +impl From for WorkspaceSearchOverlayStatus { + fn from(value: FlashgrepWorkspaceOverlayStatus) -> Self { + Self { + committed_seq_no: value.committed_seq_no, + last_seq_no: value.last_seq_no, + uncommitted_ops: value.uncommitted_ops, + pending_docs: value.pending_docs, + active_segments: value.active_segments, + active_delete_segments: value.active_delete_segments, + merge_requested: value.merge_requested, + merge_running: value.merge_running, + merge_attempts: value.merge_attempts, + merge_completed: value.merge_completed, + merge_failed: value.merge_failed, + last_merge_error: value.last_merge_error, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchRepoStatus { + pub repo_id: String, + pub repo_path: String, + pub storage_root: String, + pub base_snapshot_root: String, + pub workspace_overlay_root: String, + pub phase: WorkspaceSearchRepoPhase, + pub snapshot_key: Option, + pub last_probe_unix_secs: Option, + pub last_rebuild_unix_secs: Option, + pub dirty_files: WorkspaceSearchDirtyFiles, + pub rebuild_recommended: bool, + pub active_task_id: Option, + pub probe_healthy: bool, + pub last_error: Option, + pub overlay: Option, +} + +impl From for WorkspaceSearchRepoStatus { + fn from(value: FlashgrepRepoStatus) -> Self { + Self { + repo_id: value.repo_id, + repo_path: value.repo_path, + storage_root: value.storage_root, + base_snapshot_root: value.base_snapshot_root, + workspace_overlay_root: value.workspace_overlay_root, + phase: value.phase.into(), + snapshot_key: value.snapshot_key, + last_probe_unix_secs: value.last_probe_unix_secs, + last_rebuild_unix_secs: value.last_rebuild_unix_secs, + dirty_files: value.dirty_files.into(), + rebuild_recommended: value.rebuild_recommended, + active_task_id: value.active_task_id, + probe_healthy: value.probe_healthy, + last_error: value.last_error, + overlay: value.overlay.map(Into::into), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchTaskStatus { + pub task_id: String, + pub workspace_id: String, + pub kind: WorkspaceSearchTaskKind, + pub state: WorkspaceSearchTaskState, + pub phase: Option, + pub message: String, + pub processed: usize, + pub total: Option, + pub started_unix_secs: u64, + pub updated_unix_secs: u64, + pub finished_unix_secs: Option, + pub cancellable: bool, + pub error: Option, +} + +impl From for WorkspaceSearchTaskStatus { + fn from(value: FlashgrepTaskStatus) -> Self { + Self { + task_id: value.task_id, + workspace_id: value.workspace_id, + kind: value.kind.into(), + state: value.state.into(), + phase: value.phase.map(Into::into), + message: value.message, + processed: value.processed, + total: value.total, + started_unix_secs: value.started_unix_secs, + updated_unix_secs: value.updated_unix_secs, + finished_unix_secs: value.finished_unix_secs, + cancellable: value.cancellable, + error: value.error, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchFileCount { + pub path: String, + pub matched_lines: usize, +} + +impl From for WorkspaceSearchFileCount { + fn from(value: FlashgrepFileCount) -> Self { + Self { + path: value.path, + matched_lines: value.matched_lines, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchMatchLocation { + pub line: usize, + pub column: usize, +} + +impl From for WorkspaceSearchMatchLocation { + fn from(value: FlashgrepMatchLocation) -> Self { + Self { + line: value.line, + column: value.column, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchMatch { + pub location: WorkspaceSearchMatchLocation, + pub snippet: String, + pub matched_text: String, +} + +impl From for WorkspaceSearchMatch { + fn from(value: FlashgrepFileMatch) -> Self { + Self { + location: value.location.into(), + snippet: value.snippet, + matched_text: value.matched_text, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchContextLine { + pub line_number: usize, + pub snippet: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkspaceSearchLine { + Match { value: WorkspaceSearchMatch }, + Context { value: WorkspaceSearchContextLine }, + ContextBreak, +} + +impl From for WorkspaceSearchLine { + fn from(value: FlashgrepSearchLine) -> Self { + match value { + FlashgrepSearchLine::Match { value } => Self::Match { + value: value.into(), + }, + FlashgrepSearchLine::Context { + line_number, + snippet, + } => Self::Context { + value: WorkspaceSearchContextLine { + line_number, + snippet, + }, + }, + FlashgrepSearchLine::ContextBreak => Self::ContextBreak, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceSearchHit { + pub path: String, + pub matches: Vec, + pub lines: Vec, +} + +impl From for WorkspaceSearchHit { + fn from(value: FlashgrepSearchHit) -> Self { + Self { + path: value.path, + matches: value.matches.into_iter().map(Into::into).collect(), + lines: value.lines.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceIndexStatus { + pub repo_status: WorkspaceSearchRepoStatus, + pub active_task: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContentSearchResult { + pub outcome: FileSearchOutcome, + pub file_counts: Vec, + pub hits: Vec, + pub backend: WorkspaceSearchBackend, + pub repo_status: WorkspaceSearchRepoStatus, + pub candidate_docs: usize, + pub matched_lines: usize, + pub matched_occurrences: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GlobSearchResult { + pub paths: Vec, + pub repo_status: WorkspaceSearchRepoStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexTaskHandle { + pub task: WorkspaceSearchTaskStatus, + pub repo_status: WorkspaceSearchRepoStatus, +} diff --git a/src/crates/core/src/service/session/types.rs b/src/crates/core/src/service/session/types.rs index e08d98eb4..0387f4ced 100644 --- a/src/crates/core/src/service/session/types.rs +++ b/src/crates/core/src/service/session/types.rs @@ -90,6 +90,30 @@ pub struct SessionMetadata { alias = "workspace_hostname" )] pub workspace_hostname: Option, + + /// Unread completion status for the session. + /// 'completed' → green dot, 'error' → red dot. + /// Cleared after the user switches to the session and the content renders. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "unread_completion", + alias = "unreadCompletion" + )] + pub unread_completion: Option, + + /// High-priority attention status for the session. + /// Set when the session requires user action while not the active session. + /// 'ask_user' → pending AskUserQuestion waiting for answer. + /// 'tool_confirm' → pending tool confirmations. + /// Takes precedence over unread_completion in the UI. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "needs_user_attention", + alias = "needsUserAttention" + )] + pub needs_user_attention: Option, } /// Session status @@ -504,6 +528,8 @@ impl SessionMetadata { todos: None, workspace_path: None, workspace_hostname: None, + unread_completion: None, + needs_user_attention: None, } } diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 1573ba38e..dccd2e6d7 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -147,6 +147,18 @@ impl SnapshotManager { })) } + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &str, + ) -> SnapshotResult { + let snapshot_service = self.snapshot_service.read().await; + let file_path = std::path::Path::new(file_path); + snapshot_service + .get_session_file_diff_stats(session_id, file_path) + .await + } + pub async fn get_operation_summary( &self, session_id: &str, @@ -531,6 +543,7 @@ impl WrappedTool { let snapshot_service = snapshot_manager.get_snapshot_service(); let snapshot_service = snapshot_service.read().await; + let intercept_started_at = std::time::Instant::now(); let operation_id = snapshot_service .intercept_file_modification( &session_id, @@ -543,6 +556,7 @@ impl WrappedTool { ) .await .map_err(|e| crate::util::errors::BitFunError::Tool(e.to_string()))?; + let intercept_ms = crate::util::elapsed_ms_u64(intercept_started_at); debug!( "Recorded file modification operation: operation_id={}", @@ -551,18 +565,26 @@ impl WrappedTool { let start_time = std::time::Instant::now(); let results = self.original_tool.call(input, context).await?; - let duration_ms = crate::util::elapsed_ms_u64(start_time); + let tool_call_ms = crate::util::elapsed_ms_u64(start_time); + let complete_started_at = std::time::Instant::now(); snapshot_service - .complete_file_modification(&session_id, &operation_id, duration_ms) + .complete_file_modification(&session_id, &operation_id, tool_call_ms) .await .map_err(|e| crate::util::errors::BitFunError::Tool(e.to_string()))?; + let complete_ms = crate::util::elapsed_ms_u64(complete_started_at); + let total_ms = intercept_ms + .saturating_add(tool_call_ms) + .saturating_add(complete_ms); debug!( - "File modification tool completed: tool_name={}, operation_id={}, duration_ms={}, file_path={}", + "File modification tool completed: tool_name={}, operation_id={}, total_ms={}, intercept_ms={}, tool_call_ms={}, complete_ms={}, file_path={}", self.name(), operation_id, - duration_ms, + total_ms, + intercept_ms, + tool_call_ms, + complete_ms, file_path.display() ); Ok(results) diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 4a9ecf834..722c74e85 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -168,6 +168,18 @@ impl SnapshotService { .await } + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult { + self.ensure_initialized().await?; + let snapshot_core = self.snapshot_core.read().await; + snapshot_core + .get_session_file_diff_stats(session_id, file_path) + .await + } + pub async fn get_operation_summary( &self, session_id: &str, diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index c324d938a..ab8f662e3 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -1,6 +1,7 @@ use crate::service::snapshot::snapshot_system::FileSnapshotSystem; use crate::service::snapshot::types::{ - DiffSummary, FileOperation, OperationType, SnapshotError, SnapshotResult, ToolContext, + DiffSummary, FileOperation, OperationType, SessionFileDiffStats, SnapshotError, SnapshotResult, + ToolContext, }; use crate::service::workspace_runtime::WorkspaceRuntimeContext; use log::{debug, info, warn}; @@ -48,6 +49,9 @@ struct SessionHistory { last_updated: SystemTime, } +/// Per-side size budget: above this we avoid loading baseline/disk texts for UI badge stats. +const SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES: u64 = 512 * 1024; + impl SessionHistory { fn new(session_id: String) -> Self { let now = SystemTime::now(); @@ -570,6 +574,99 @@ impl SnapshotCore { Ok((before, after, mapped_anchor)) } + /// Line insert/delete counts versus session baseline vs workspace, without returning file bodies. + /// Large files skip full reads and aggregate per-operation diff summaries (`approximate: true`). + pub async fn get_session_file_diff_stats( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult { + let Some(session) = self.sessions.get(session_id) else { + return Err(SnapshotError::SessionNotFound(session_id.to_string())); + }; + + let file_created = session_file_created_in_session(session, file_path); + + let workspace_bytes = if file_path.exists() { + tokio::fs::metadata(file_path) + .await + .map(|m| m.len()) + .unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1)) + } else { + 0 + }; + + let before_bytes = if file_created { + 0 + } else { + let before_snapshot_id = if let Some(baseline_id) = self + .snapshot_system + .get_baseline_snapshot_id(file_path) + .await + { + Some(baseline_id) + } else { + session + .all_operations_iter() + .find(|op| op.file_path == file_path) + .and_then(|op| op.before_snapshot_id.clone()) + }; + + match before_snapshot_id { + None => 0, + Some(id) => self + .snapshot_system + .get_snapshot_recorded_size_bytes(&id) + .await + .unwrap_or(SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES.saturating_add(1)), + } + }; + + let too_large = workspace_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES + || before_bytes > SESSION_FILE_DIFF_STATS_MAX_SOURCE_BYTES; + + let path_exists = file_path.exists(); + + if too_large { + let agg = aggregate_operations_diff_summary_for_file(session, file_path); + let change_kind = change_kind_for_aggregate_path(file_created, path_exists); + debug!( + "get_session_file_diff_stats: approximate session_id={} file_path={:?} workspace_bytes={} before_bytes={} lines_added={} lines_removed={}", + session_id, + file_path, + workspace_bytes, + before_bytes, + agg.lines_added, + agg.lines_removed + ); + return Ok(SessionFileDiffStats { + file_path: file_path.to_string_lossy().to_string(), + lines_added: agg.lines_added, + lines_removed: agg.lines_removed, + approximate: true, + change_kind: change_kind.to_string(), + }); + } + + let (before, after) = self.get_file_diff(file_path, session_id).await?; + let summary = compute_diff_summary(&before, &after); + let change_kind = change_kind_from_diff_content(file_created, &before, &after); + debug!( + "get_session_file_diff_stats: exact session_id={} file_path={:?} lines_added={} lines_removed={}", + session_id, + file_path, + summary.lines_added, + summary.lines_removed + ); + Ok(SessionFileDiffStats { + file_path: file_path.to_string_lossy().to_string(), + lines_added: summary.lines_added, + lines_removed: summary.lines_removed, + approximate: false, + change_kind: change_kind.to_string(), + }) + } + pub fn get_file_change_history(&self, file_path: &Path) -> Vec { let mut entries = Vec::new(); for session in self.sessions.values() { @@ -903,6 +1000,53 @@ impl SnapshotCore { } } +fn session_file_created_in_session(session: &SessionHistory, file_path: &Path) -> bool { + session + .all_operations_iter() + .find(|op| op.file_path == file_path) + .map(|op| op.before_snapshot_id.is_none()) + .unwrap_or(false) +} + +fn aggregate_operations_diff_summary_for_file( + session: &SessionHistory, + file_path: &Path, +) -> DiffSummary { + let mut out = DiffSummary::default(); + for op in session.all_operations_iter() { + if op.file_path.as_path() == file_path { + out.lines_added += op.diff_summary.lines_added; + out.lines_removed += op.diff_summary.lines_removed; + out.lines_modified += op.diff_summary.lines_modified; + } + } + out +} + +fn change_kind_for_aggregate_path(file_created_in_session: bool, path_exists: bool) -> &'static str { + if file_created_in_session { + "create" + } else if !path_exists { + "delete" + } else { + "modify" + } +} + +fn change_kind_from_diff_content( + file_created_in_session: bool, + before: &str, + after: &str, +) -> &'static str { + if file_created_in_session { + return "create"; + } + if !before.is_empty() && after.is_empty() { + return "delete"; + } + "modify" +} + fn sanitize_id(id: &str) -> String { id.chars() .map(|c| { diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index c4732b079..c23eb1812 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -364,7 +364,7 @@ impl FileSnapshotSystem { compressed_content: match optimized_content { OptimizedContent::Raw(data) => data, OptimizedContent::Compressed(data) => data, - OptimizedContent::Reference(_) => unreachable!(), + OptimizedContent::Reference(_) => Vec::new(), }, timestamp: SystemTime::now(), metadata, @@ -435,7 +435,8 @@ impl FileSnapshotSystem { fn optimize_content(&self, content: &[u8]) -> OptimizedContent { if self.dedup_enabled { let hash = self.calculate_content_hash(content); - if self.hash_to_path.contains_key(&hash) { + let content_path = self.get_content_path(&hash); + if self.hash_to_path.contains_key(&hash) && content_path.exists() { return OptimizedContent::Reference(hash); } } @@ -525,6 +526,12 @@ impl FileSnapshotSystem { None } + /// Recorded logical size (bytes) from snapshot metadata, without loading file contents. + pub async fn get_snapshot_recorded_size_bytes(&self, snapshot_id: &str) -> SnapshotResult { + let snapshot = self.load_snapshot_from_disk(snapshot_id).await?; + Ok(snapshot.metadata.size) + } + /// Loads snapshot metadata from disk (without using in-memory cache). async fn load_snapshot_from_disk(&self, snapshot_id: &str) -> SnapshotResult { debug!( @@ -837,3 +844,60 @@ impl FileSnapshotSystem { self.get_baseline_snapshot_id(file_path).await.is_some() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::workspace_runtime::{WorkspaceRuntimeContext, WorkspaceRuntimeTarget}; + + fn test_runtime_context() -> WorkspaceRuntimeContext { + let runtime_root = + std::env::temp_dir().join(format!("bitfun_snapshot_test_{}", Uuid::new_v4())); + WorkspaceRuntimeContext::new( + WorkspaceRuntimeTarget::LocalWorkspace { + workspace_root: runtime_root.join("workspace"), + }, + runtime_root, + ) + } + + fn create_runtime_dirs(context: &WorkspaceRuntimeContext) { + for directory in context.required_directories() { + fs::create_dir_all(directory).expect("create runtime directory"); + } + } + + #[tokio::test] + async fn create_snapshot_reuses_empty_baseline_content_without_panicking() { + let context = test_runtime_context(); + create_runtime_dirs(&context); + + let file_path = context.runtime_root.join("workspace").join("empty.txt"); + fs::create_dir_all(file_path.parent().expect("file has parent")).expect("create parent"); + + let mut snapshot_system = FileSnapshotSystem::new(context.clone()); + snapshot_system + .initialize() + .await + .expect("initialize snapshots"); + snapshot_system + .create_empty_baseline(&file_path) + .await + .expect("create empty baseline"); + + fs::write(&file_path, []).expect("write empty file"); + + let snapshot_id = snapshot_system + .create_snapshot(&file_path) + .await + .expect("create snapshot"); + let restored = snapshot_system + .restore_snapshot_content(&snapshot_id) + .await + .expect("restore snapshot content"); + + assert!(restored.is_empty()); + + fs::remove_dir_all(&context.runtime_root).expect("cleanup runtime root"); + } +} diff --git a/src/crates/core/src/service/snapshot/types.rs b/src/crates/core/src/service/snapshot/types.rs index 833a00a28..f24d48c86 100644 --- a/src/crates/core/src/service/snapshot/types.rs +++ b/src/crates/core/src/service/snapshot/types.rs @@ -62,6 +62,19 @@ pub struct DiffSummary { pub lines_modified: usize, } +/// Line-level diff stats for a session file (badge / toolbars), without full file contents. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionFileDiffStats { + pub file_path: String, + pub lines_added: usize, + pub lines_removed: usize, + /// True when stats were derived from per-operation summaries instead of a full baseline vs disk diff. + pub approximate: bool, + /// `create`, `modify`, or `delete` for UI mapping. + pub change_kind: String, +} + /// File modification status #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum FileModificationStatus { diff --git a/src/crates/core/tests/common/stream_test_harness.rs b/src/crates/core/tests/common/stream_test_harness.rs index 35c49ae1a..690e1fb42 100644 --- a/src/crates/core/tests/common/stream_test_harness.rs +++ b/src/crates/core/tests/common/stream_test_harness.rs @@ -33,6 +33,7 @@ pub struct StreamFixtureRunOptions { pub server_options: FixtureSseServerOptions, pub openai_inline_think_in_text: bool, pub anthropic_inline_think_in_text: bool, + pub log_raw_sse: bool, } impl Default for StreamFixtureRunOptions { @@ -41,6 +42,7 @@ impl Default for StreamFixtureRunOptions { server_options: FixtureSseServerOptions::default(), openai_inline_think_in_text: false, anthropic_inline_think_in_text: false, + log_raw_sse: false, } } } @@ -79,6 +81,23 @@ pub async fn run_stream_fixture_with_options( let (tx_event, rx_event) = mpsc::unbounded_channel::>(); let (tx_raw_sse, rx_raw_sse) = mpsc::unbounded_channel::(); + let raw_sse_rx_for_processor = if options.log_raw_sse { + let (tx_raw_sse_for_processor, rx_raw_sse_for_processor) = + mpsc::unbounded_channel::(); + let mut rx_raw_sse = rx_raw_sse; + let fixture_label = fixture_relative_path.to_string(); + tokio::spawn(async move { + while let Some(raw_sse) = rx_raw_sse.recv().await { + println!("[stream-fixture raw sse][{}] {}", fixture_label, raw_sse); + if tx_raw_sse_for_processor.send(raw_sse).is_err() { + break; + } + } + }); + Some(rx_raw_sse_for_processor) + } else { + Some(rx_raw_sse) + }; match provider { StreamFixtureProvider::OpenAi => { @@ -126,7 +145,7 @@ pub async fn run_stream_fixture_with_options( .process_stream( unified_stream, None, - Some(rx_raw_sse), + raw_sse_rx_for_processor, "session_fixture".to_string(), "turn_fixture".to_string(), "round_fixture".to_string(), diff --git a/src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse b/src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse index d59769e91..8102b724f 100644 --- a/src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse +++ b/src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse @@ -30,3 +30,4 @@ data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence": event: message_stop data: {"type":"message_stop"} + diff --git a/src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse b/src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse index d5f1dbd92..cd178a8cc 100644 --- a/src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse +++ b/src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse @@ -15,3 +15,4 @@ data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence": event: message_stop data: {"type":"message_stop"} + diff --git a/src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse b/src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse index c03345fd8..3abdc2d27 100644 --- a/src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse +++ b/src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse @@ -7,3 +7,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":802,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":803,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"type":"function","function":{"arguments":"{\"y\":2}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":5,"total_tokens":10}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse b/src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse index 0812e4436..f110b697a 100644 --- a/src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse +++ b/src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse @@ -25,3 +25,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":207,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":208,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":2,"id":"call_3","type":"function","function":{"arguments":"}}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":7,"total_tokens":12}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse b/src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse index 02c0bb246..f4e73b30f 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse @@ -5,3 +5,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":501,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":502,"model":"gpt-test","choices":[],"usage":{"prompt_tokens":3,"completion_tokens":6,"total_tokens":9}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse b/src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse index 420907b71..f04700115 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse @@ -3,3 +3,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":123,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":124,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"name":null,"arguments":"1}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":1,"completion_tokens":6,"total_tokens":7}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse b/src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse index 259f1557d..c1835ca07 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse @@ -5,3 +5,4 @@ data: {"id":"chatcmpl_azure_001","object":"chat.completion.chunk","created":1711 data: {"id":"chatcmpl_azure_001","object":"chat.completion.chunk","created":1711357600,"model":"mistral-large","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\"hello\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse b/src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse index ba5835d81..d5801616e 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse @@ -11,3 +11,4 @@ data: {"id":"chatcmpl_tail_001","object":"chat.completion.chunk","created":17331 data: {"id":"chatcmpl_tail_001","object":"chat.completion.chunk","created":1733162246,"model":"meta/llama-3.1-8b-instruct:fp8","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"finish_reason":"tool_calls","stop_reason":128008}],"usage":{"prompt_tokens":226,"completion_tokens":20,"total_tokens":246}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse b/src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse index 105f20b1d..aecb1b5b2 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse @@ -1,3 +1,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":600,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_orphan","type":"function","function":{}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":4,"completion_tokens":5,"total_tokens":9}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse b/src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse index 42139edce..5043baa06 100644 --- a/src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse +++ b/src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse @@ -3,3 +3,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":400,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":401,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":null,"type":"function","function":{"name":"tool_a","arguments":"{\"city\":\"Beijing\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":3,"completion_tokens":6,"total_tokens":9}} data: [DONE] + diff --git a/src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse b/src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse index 94baa54d9..4183e9e17 100644 --- a/src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse +++ b/src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse @@ -5,3 +5,4 @@ data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":701,"mode data: {"id":"chatcmpl_test","object":"chat.completion.chunk","created":702,"model":"gpt-test","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_2","type":"function","function":{"name":"tool_two","arguments":"{\"y\":2}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":6,"total_tokens":11}} data: [DONE] + diff --git a/src/crates/core/tests/stream_processor_anthropic.rs b/src/crates/core/tests/stream_processor_anthropic.rs index 6de8f8f56..4f207f8dc 100644 --- a/src/crates/core/tests/stream_processor_anthropic.rs +++ b/src/crates/core/tests/stream_processor_anthropic.rs @@ -84,10 +84,7 @@ async fn anthropic_extended_thinking_sse_produces_reasoning_and_text() { result.usage.as_ref().map(|usage| usage.total_token_count), Some(25) ); - assert_eq!( - result.thinking_signature.as_deref(), - Some("sig_abc123") - ); + assert_eq!(result.thinking_signature.as_deref(), Some("sig_abc123")); let thinking_chunks: Vec<(&str, bool)> = output .events diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index 074c3c17b..94745785a 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -138,6 +138,17 @@ pub enum AgenticEvent { total_tools: usize, duration_ms: u64, subagent_parent_info: Option, + /// When set, the turn finished but the last model round was a partial + /// recovery (stream aborted mid-way). Contains a human-readable reason. + #[serde(skip_serializing_if = "Option::is_none")] + partial_recovery_reason: Option, + /// Whether the turn completed successfully (false for loop_detected or + /// max_rounds). + #[serde(skip_serializing_if = "Option::is_none")] + success: Option, + /// Why the turn finished: "complete", "loop_detected", or "max_rounds". + #[serde(skip_serializing_if = "Option::is_none")] + finish_reason: Option, }, DialogTurnCancelled { @@ -291,6 +302,8 @@ pub enum ToolEventData { tool_id: String, tool_name: String, params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_seconds: Option, }, Progress { tool_id: String, diff --git a/src/crates/transport/Cargo.toml b/src/crates/transport/Cargo.toml index c7cff370a..77c158e27 100644 --- a/src/crates/transport/Cargo.toml +++ b/src/crates/transport/Cargo.toml @@ -30,4 +30,3 @@ default = [] tauri-adapter = ["tauri"] cli-adapter = [] websocket-adapter = [] - diff --git a/src/crates/transport/src/adapters/cli.rs b/src/crates/transport/src/adapters/cli.rs index 6fd53bd2b..b9e0b71e3 100644 --- a/src/crates/transport/src/adapters/cli.rs +++ b/src/crates/transport/src/adapters/cli.rs @@ -30,6 +30,8 @@ pub enum CliEvent { DialogTurnCompleted { session_id: String, turn_id: String, + success: Option, + finish_reason: Option, }, /// Generic event (for LSP, file watch, etc.) Generic { @@ -93,10 +95,14 @@ impl TransportAdapter for CliTransportAdapter { AgenticEvent::DialogTurnCompleted { session_id, turn_id, + success, + finish_reason, .. } => CliEvent::DialogTurnCompleted { session_id, turn_id, + success, + finish_reason, }, _ => return Ok(()), }; diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index 0acdf181c..c5214484e 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -194,6 +194,9 @@ impl TransportAdapter for TauriTransportAdapter { session_id, turn_id, subagent_parent_info, + partial_recovery_reason, + success, + finish_reason, .. } => { self.app_handle.emit( @@ -202,6 +205,9 @@ impl TransportAdapter for TauriTransportAdapter { "sessionId": session_id, "turnId": turn_id, "subagentParentInfo": subagent_parent_info, + "partialRecoveryReason": partial_recovery_reason, + "success": success, + "finishReason": finish_reason, }), )?; } diff --git a/src/crates/transport/src/adapters/websocket.rs b/src/crates/transport/src/adapters/websocket.rs index b6f26df6a..a18dbcc5c 100644 --- a/src/crates/transport/src/adapters/websocket.rs +++ b/src/crates/transport/src/adapters/websocket.rs @@ -164,6 +164,9 @@ impl TransportAdapter for WebSocketTransportAdapter { session_id, turn_id, subagent_parent_info, + partial_recovery_reason, + success, + finish_reason, .. } => { json!({ @@ -171,6 +174,9 @@ impl TransportAdapter for WebSocketTransportAdapter { "sessionId": session_id, "turnId": turn_id, "subagentParentInfo": subagent_parent_info, + "partialRecoveryReason": partial_recovery_reason, + "success": success, + "finishReason": finish_reason, }) } _ => return Ok(()), diff --git a/src/mobile-web/package-lock.json b/src/mobile-web/package-lock.json index 3ab5bc9e2..44dd02e91 100644 --- a/src/mobile-web/package-lock.json +++ b/src/mobile-web/package-lock.json @@ -1,12 +1,12 @@ { "name": "bitfun-mobile-web", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bitfun-mobile-web", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@noble/ciphers": "^2.1.1", "@noble/curves": "^2.0.1", diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 9721f8b64..0df2a501b 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -1,6 +1,6 @@ { "name": "bitfun-mobile-web", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "scripts": { diff --git a/src/web-ui/AGENTS-CN.md b/src/web-ui/AGENTS-CN.md index 9d587b194..dac9ff60d 100644 --- a/src/web-ui/AGENTS-CN.md +++ b/src/web-ui/AGENTS-CN.md @@ -27,13 +27,11 @@ - 不要在 UI 组件里直接调用 Tauri API;应通过 adapter / infrastructure 层访问 - 新增前端基础设施前,先复用已有的 theme、i18n、component-library 和 Zustand stores - 遵循 `src/web-ui/LOGGING.md`:仅英文、无 emoji、结构化日志 -- 共享前端改动后的桌面人工验证,优先使用 `pnpm run desktop:preview:debug` 而不是 `pnpm run desktop:dev`;只有在要验证的就是 Tauri 启动 / dev 流程本身时,才切回完整桌面链路 ## 命令 ```bash pnpm --dir src/web-ui dev -pnpm run desktop:preview:debug pnpm --dir src/web-ui run lint pnpm --dir src/web-ui run type-check pnpm --dir src/web-ui run test:run diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index a24208ccf..189f74836 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -27,13 +27,11 @@ Most changes start in: - Do not call Tauri APIs directly from UI components; go through the adapter / infrastructure layer - Reuse existing theme, i18n, component-library, and Zustand stores before adding new frontend primitives - Follow `src/web-ui/LOGGING.md`: English only, no emojis, structured logs -- For quick manual desktop verification of shared frontend changes, prefer `pnpm run desktop:preview:debug` over `pnpm run desktop:dev`; switch back to the full desktop flow only when the Tauri startup/dev pipeline itself is part of what you are validating ## Commands ```bash pnpm --dir src/web-ui dev -pnpm run desktop:preview:debug pnpm --dir src/web-ui run lint pnpm --dir src/web-ui run type-check pnpm --dir src/web-ui run test:run diff --git a/src/web-ui/package.json b/src/web-ui/package.json index bd0b8c0f9..071cef576 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@bitfun/web-ui", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "BitFun Web UI - 支持 Desktop 和 Server 两种部署方式", "type": "module", diff --git a/src/web-ui/public/agent-companion-pets/boxcat/pet.json b/src/web-ui/public/agent-companion-pets/boxcat/pet.json new file mode 100644 index 000000000..c410db22f --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/boxcat/pet.json @@ -0,0 +1,6 @@ +{ + "id": "boxcat", + "displayName": "Boxcat", + "description": "A tiny cat tucked inside a cardboard box for cozy coding sessions.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/boxcat/spritesheet.webp b/src/web-ui/public/agent-companion-pets/boxcat/spritesheet.webp new file mode 100644 index 000000000..0a824d93a Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/boxcat/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/capy/pet.json b/src/web-ui/public/agent-companion-pets/capy/pet.json new file mode 100644 index 000000000..a7f87fc46 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/capy/pet.json @@ -0,0 +1,6 @@ +{ + "id": "capy", + "displayName": "Capy", + "description": "An original emotionally stable capybara with a tiny orange on its head.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp b/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp new file mode 100644 index 000000000..e8885c4b4 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/capy/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/elaina-2/pet.json b/src/web-ui/public/agent-companion-pets/elaina-2/pet.json new file mode 100644 index 000000000..4fdec2974 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/elaina-2/pet.json @@ -0,0 +1,6 @@ +{ + "id": "elaina", + "displayName": "Elaina", + "description": "A cute pixel-art Codex pet inspired by Elaina, the tiny traveling witch with a bright hat and gentle broom-side charm.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/elaina-2/spritesheet.webp b/src/web-ui/public/agent-companion-pets/elaina-2/spritesheet.webp new file mode 100644 index 000000000..1a16d020d Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/elaina-2/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/gugugaga/pet.json b/src/web-ui/public/agent-companion-pets/gugugaga/pet.json new file mode 100644 index 000000000..7cb84a2d6 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/gugugaga/pet.json @@ -0,0 +1,6 @@ +{ + "id": "gugugaga", + "displayName": "\u5495\u5495\u560e\u560e", + "description": "A cheerful chibi girl in a black penguin suit with a simple silver collar pendant.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/gugugaga/spritesheet.webp b/src/web-ui/public/agent-companion-pets/gugugaga/spritesheet.webp new file mode 100644 index 000000000..d7644d1a5 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/gugugaga/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/hachiware/pet.json b/src/web-ui/public/agent-companion-pets/hachiware/pet.json new file mode 100644 index 000000000..f7ee97506 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/hachiware/pet.json @@ -0,0 +1,6 @@ +{ + "id": "hachiware", + "displayName": "Hachiware", + "description": "A tiny Hachiware-inspired desktop pet with white and blue cat markings, bright eyes, and cheerful expressions.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp b/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp new file mode 100644 index 000000000..9e29a5ca4 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/hachiware/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/ikun/pet.json b/src/web-ui/public/agent-companion-pets/ikun/pet.json new file mode 100644 index 000000000..87129b9f0 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/ikun/pet.json @@ -0,0 +1,6 @@ +{ + "id": "ikun", + "displayName": "IKUN", + "description": "A hoodie chick with hot path stage energy.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/ikun/spritesheet.webp b/src/web-ui/public/agent-companion-pets/ikun/spritesheet.webp new file mode 100644 index 000000000..9cc6a52e7 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/ikun/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/jiyi/pet.json b/src/web-ui/public/agent-companion-pets/jiyi/pet.json new file mode 100644 index 000000000..d620a1b98 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/jiyi/pet.json @@ -0,0 +1,6 @@ +{ + "id": "jiyi", + "displayName": "\u5409\u4f0a", + "description": "A round white chibi bear with dark chocolate outlines, pink cheeks, tiny limbs, curled ears, and a small pink bear pouch.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp b/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp new file mode 100644 index 000000000..b8ff29308 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/jiyi/spritesheet.webp differ diff --git a/src/web-ui/public/agent-companion-pets/panda-pix/pet.json b/src/web-ui/public/agent-companion-pets/panda-pix/pet.json new file mode 100644 index 000000000..63dff68b8 --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/panda-pix/pet.json @@ -0,0 +1,6 @@ +{ + "id": "panda-pix", + "displayName": "Panda", + "description": "Codux bundled pet atlas.", + "spritesheetPath": "spritesheet.png" +} diff --git a/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png b/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png new file mode 100644 index 000000000..03b1004c0 Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/panda-pix/spritesheet.png differ diff --git a/src/web-ui/public/agent-companion-pets/usagi/pet.json b/src/web-ui/public/agent-companion-pets/usagi/pet.json new file mode 100644 index 000000000..f41bb5bbe --- /dev/null +++ b/src/web-ui/public/agent-companion-pets/usagi/pet.json @@ -0,0 +1,6 @@ +{ + "id": "usagi", + "displayName": "Usagi", + "description": "A tiny cream rabbit companion based on the provided Usagi reference.", + "spritesheetPath": "spritesheet.webp" +} diff --git a/src/web-ui/public/agent-companion-pets/usagi/spritesheet.webp b/src/web-ui/public/agent-companion-pets/usagi/spritesheet.webp new file mode 100644 index 000000000..58a41ab9c Binary files /dev/null and b/src/web-ui/public/agent-companion-pets/usagi/spritesheet.webp differ diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index d4c7afe17..a3bddf86a 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -10,9 +10,15 @@ import { NotificationContainer, NotificationCenter } from '../shared/notificatio import { AnnouncementProvider } from '../shared/announcement-system'; import { ConfirmDialogRenderer } from '../component-library'; import { createLogger } from '@/shared/utils/logger'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { syncAgentCompanionDesktopWindow } from '@/infrastructure/config/services/AgentCompanionWindowService'; +import { buildAgentCompanionActivity, subscribeAgentCompanionActivity } from '@/flow_chat/utils/agentCompanionActivity'; +import { emitAgentCompanionActivity } from '@/flow_chat/services/AgentCompanionActivityBridge'; +import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import SplashScreen from './components/SplashScreen/SplashScreen'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; +import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; // Toolbar Mode import { ToolbarModeProvider } from '../flow_chat'; @@ -140,11 +146,83 @@ function App() { } }; + const initACPClients = async () => { + try { + const { ACPClientAPI } = await import('../infrastructure/api/service-api/ACPClientAPI'); + await ACPClientAPI.initializeClients(); + log.debug('ACP clients initialized'); + const requirementProbes = await ACPClientAPI.probeClientRequirements({ force: true }); + log.debug('ACP client requirements probed', { count: requirementProbes.length }); + } catch (error) { + log.error('Failed to initialize ACP clients', error); + } + }; + initIdeControl(); initMCPServers(); + initACPClients(); }, []); + useEffect(() => { + const emitCurrentAgentCompanionActivity = () => { + void emitAgentCompanionActivity(buildAgentCompanionActivity()); + }; + + void aiExperienceConfigService.getSettingsAsync().then(async settings => { + await syncAgentCompanionDesktopWindow(settings); + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + }); + return aiExperienceConfigService.addChangeListener(settings => { + void syncAgentCompanionDesktopWindow(settings).then(() => { + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + }); + }); + }, []); + + useEffect(() => subscribeAgentCompanionActivity(activity => { + void emitAgentCompanionActivity(activity); + }), []); + + useEffect(() => { + let unlisten: (() => void) | null = null; + void import('@tauri-apps/api/event') + .then(({ listen }) => listen<{ sessionId?: string }>( + 'agent-companion://open-session', + async event => { + const sessionId = event.payload?.sessionId; + if (!sessionId) return; + + const flowChatStore = FlowChatStore.getInstance(); + if (flowChatStore.getState().sessions.has(sessionId)) { + flowChatStore.switchSession(sessionId); + } + + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('show_main_window'); + } catch (error) { + log.warn('Failed to show main window from Agent companion bubble', { + sessionId, + error, + }); + } + }, + )) + .then(removeListener => { + unlisten = removeListener; + }) + .catch(error => { + log.warn('Failed to listen for Agent companion session open events', error); + }); + + return () => { + unlisten?.(); + }; + }, []); + // Observe AI initialization state useEffect(() => { if (aiError) { @@ -189,6 +267,9 @@ function App() { // Top SceneBar: Mod+Alt+1..9 / Mod+Alt+PageUp/PageDown useGlobalSceneShortcuts(); + // Debug inspector shortcuts (desktop devtools only) + useDebugInspector(); + // Unified layout via a single AppLayout return ( diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss new file mode 100644 index 000000000..959a58574 --- /dev/null +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.scss @@ -0,0 +1,157 @@ +.bitfun-agent-companion-window-root, +.bitfun-agent-companion-window-body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent !important; +} + +.bitfun-agent-companion-window-body #root { + width: 100vw; + height: 100vh; + background: transparent; +} + +.bitfun-agent-companion-window { + width: 100vw; + height: 100vh; + position: relative; + background: transparent; + user-select: none; + + &__dock { + position: absolute; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-end; + gap: var(--bitfun-agent-companion-gap, 8px); + width: max-content; + max-height: 100vh; + pointer-events: none; + } + + &__pet { + width: min(96px, 100vw); + height: min(96px, 100vh); + max-width: 96px; + max-height: 96px; + transform-origin: center bottom; + } + + &__pet-hitbox { + position: relative; + flex-shrink: 0; + width: min(96px, 100vw); + height: min(96px, 100vh); + display: grid; + place-items: center; + cursor: grab; + pointer-events: auto; + + &:active { + cursor: grabbing; + } + } + + &__bubbles { + position: relative; + flex-shrink: 0; + width: max-content; + max-width: 252px; + max-height: 100vh; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-thumb { + border-radius: 999px; + background: rgba(15, 23, 42, 0.18); + } + } + + &__bubble { + appearance: none; + text-align: left; + font: inherit; + width: max-content; + max-width: 100%; + min-width: 132px; + box-sizing: border-box; + padding: 7px 10px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 10px 10px 2px 10px; + background: rgba(255, 255, 255, 0.92); + color: #1f2937; + backdrop-filter: blur(10px); + cursor: pointer; + + &:hover { + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid rgba(59, 130, 246, 0.7); + outline-offset: 2px; + } + } + + &__bubble-title, + &__bubble-status { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__bubble-title { + font-size: 11px; + line-height: 1.25; + font-weight: 650; + } + + &__bubble-status { + margin-top: 2px; + font-size: 10px; + line-height: 1.25; + color: rgba(31, 41, 55, 0.7); + } + + &__bubble--attention, + &__bubble--waiting { + border-color: rgba(217, 119, 6, 0.22); + background: rgba(255, 251, 235, 0.94); + } + + &__bubble--completed { + border-color: rgba(22, 163, 74, 0.18); + background: rgba(240, 253, 244, 0.94); + } + + &__bubble--error, + &__bubble--interrupted { + border-color: rgba(220, 38, 38, 0.18); + background: rgba(254, 242, 242, 0.94); + } + + .bitfun-chat-input-pixel-pet { + --bitfun-petdex-width: min(88px, 91.4vw); + --bitfun-petdex-height: min(96px, 100vh); + --bitfun-petdex-margin-top: 0; + + pointer-events: none; + } +} diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx new file mode 100644 index 000000000..45c068bd6 --- /dev/null +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -0,0 +1,219 @@ +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { listen } from '@tauri-apps/api/event'; +import { getCurrentWindow } from '@tauri-apps/api/window'; +import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; +import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; +import type { AgentCompanionActivityPayload, AgentCompanionTaskStatus } from '@/flow_chat/utils/agentCompanionActivity'; +import { createLogger } from '@/shared/utils/logger'; +import './AgentCompanionDesktopPet.scss'; + +const log = createLogger('AgentCompanionDesktopPet'); +const PET_SIZE = 96; +const WINDOW_MAX_WIDTH = 360; +const WINDOW_MAX_HEIGHT = 240; +const WINDOW_HORIZONTAL_GAP = 8; +const MAX_VISIBLE_BUBBLES = 2; +const BUBBLE_GAP = 6; +const BUBBLE_MIN_WIDTH = 132; +const BUBBLE_MAX_WIDTH = 252; +const WINDOW_EDGE_BUFFER = 4; + +export const AgentCompanionDesktopPet: React.FC = () => { + const { t } = useTranslation('flow-chat'); + const [pet, setPet] = useState( + () => aiExperienceConfigService.getSettings().agent_companion_pet ?? null, + ); + const [mood, setMood] = useState('rest'); + const [tasks, setTasks] = useState([]); + const [isHoveringPet, setIsHoveringPet] = useState(false); + const [isDraggingPet, setIsDraggingPet] = useState(false); + const dockRef = useRef(null); + const bubblesRef = useRef(null); + const displayTasks = [...tasks].reverse(); + + useEffect(() => { + document.documentElement.classList.add('bitfun-agent-companion-window-root'); + document.body.classList.add('bitfun-agent-companion-window-body'); + + const applySettings = (settings: AIExperienceSettings) => { + setPet(settings.agent_companion_pet ?? null); + }; + + void aiExperienceConfigService.getSettingsAsync().then(settings => { + applySettings(settings); + }); + + let removeTauriListener: (() => void) | null = null; + void listen('agent-companion://settings-updated', event => { + applySettings(event.payload); + }).then(unlisten => { + removeTauriListener = unlisten; + }).catch(error => { + log.warn('Failed to listen for Agent companion settings updates', error); + }); + + let removeActivityListener: (() => void) | null = null; + void listen('agent-companion://activity-updated', event => { + setMood(event.payload.mood); + setTasks(event.payload.tasks); + }).then(unlisten => { + removeActivityListener = unlisten; + }).catch(error => { + log.warn('Failed to listen for Agent companion activity updates', error); + }); + + return () => { + removeTauriListener?.(); + removeActivityListener?.(); + document.documentElement.classList.remove('bitfun-agent-companion-window-root'); + document.body.classList.remove('bitfun-agent-companion-window-body'); + }; + }, []); + + useLayoutEffect(() => { + const bubbleCount = tasks.length; + const bubbleElements = Array.from(bubblesRef.current?.children ?? []) + .slice(0, MAX_VISIBLE_BUBBLES); + const visibleBubbleHeight = bubbleElements.reduce( + (sum, child) => sum + child.getBoundingClientRect().height, + 0, + ) + Math.max(0, bubbleElements.length - 1) * BUBBLE_GAP; + const measuredBubbleHeight = bubblesRef.current?.scrollHeight ?? 0; + const targetBubbleHeight = bubbleCount > MAX_VISIBLE_BUBBLES + ? visibleBubbleHeight + : measuredBubbleHeight; + const nextHeight = bubbleCount > 0 + ? Math.max(PET_SIZE, Math.min(WINDOW_MAX_HEIGHT, targetBubbleHeight)) + : PET_SIZE; + const measuredBubbleWidth = bubbleCount > 0 + ? Math.min( + BUBBLE_MAX_WIDTH, + Math.max( + BUBBLE_MIN_WIDTH, + bubblesRef.current?.scrollWidth ?? 0, + bubblesRef.current?.getBoundingClientRect().width ?? 0, + ...Array.from(bubblesRef.current?.children ?? []).map(child => { + const element = child as HTMLElement; + return Math.max(element.scrollWidth, element.getBoundingClientRect().width); + }), + ), + ) + : 0; + const measuredDockWidth = bubbleCount > 0 + ? measuredBubbleWidth + WINDOW_HORIZONTAL_GAP + PET_SIZE + WINDOW_EDGE_BUFFER + : Math.max( + PET_SIZE, + dockRef.current?.scrollWidth ?? 0, + dockRef.current?.getBoundingClientRect().width ?? 0, + ); + const nextWidth = Math.max( + PET_SIZE, + Math.min(WINDOW_MAX_WIDTH, Math.ceil(measuredDockWidth)), + ); + + void import('@tauri-apps/api/core') + .then(({ invoke }) => invoke('resize_agent_companion_desktop_pet', { + width: nextWidth, + height: nextHeight, + })) + .catch(error => { + log.warn('Failed to resize Agent companion window', error); + }); + }, [tasks]); + + const startDrag = (event: React.PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + setIsDraggingPet(true); + void getCurrentWindow().startDragging() + .catch(error => { + log.warn('Failed to start Agent companion window drag', error); + }) + .finally(() => { + setIsDraggingPet(false); + }); + }; + + const displayMood: ChatInputPixelPetMood = isDraggingPet + ? 'dragging' + : isHoveringPet + ? 'hover' + : mood; + + const openTaskSession = async (task: AgentCompanionTaskStatus) => { + try { + const [{ invoke }, { emit }] = await Promise.all([ + import('@tauri-apps/api/core'), + import('@tauri-apps/api/event'), + ]); + await emit('agent-companion://open-session', { sessionId: task.sessionId }); + await invoke('show_main_window'); + } catch (error) { + log.warn('Failed to open Agent companion task session', { + sessionId: task.sessionId, + error, + }); + } + }; + + const dockVars = { + '--bitfun-agent-companion-pet-size': `${PET_SIZE}px`, + '--bitfun-agent-companion-gap': `${WINDOW_HORIZONTAL_GAP}px`, + } as React.CSSProperties; + + return ( +
+
+ {tasks.length > 0 && ( +
event.stopPropagation()} + > + {displayTasks.map(task => ( + + ))} +
+ )} +
setIsHoveringPet(true)} + onPointerLeave={() => setIsHoveringPet(false)} + onPointerDown={startDrag} + > + +
+
+
+ ); +}; + +export default AgentCompanionDesktopPet; diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index fddbf89a8..110115931 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -616,7 +616,7 @@ const MainNav: React.FC = ({ aria-expanded={workspaceMenuOpen} onClick={toggleWorkspaceMenu} > - + diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index de056dcf3..aa4e03007 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -31,22 +31,30 @@ $_section-header-height: 24px; width: 100%; flex: 1; min-height: 0; + min-width: 0; + max-width: 100%; display: flex; flex-direction: column; background: var(--color-bg-primary); - overflow: hidden; + overflow: visible; user-select: none; + --bitfun-nav-row-action-size: 20px; + --bitfun-nav-row-action-icon-size: 13px; + --bitfun-nav-row-action-offset: 4px; + --bitfun-nav-row-action-gap: 4px; // ── Container ────────────────────────────── &__content { flex: 1 1 auto; min-height: 0; + min-width: 0; + max-width: 100%; display: flex; flex-direction: column; // Dynamic clip-path origin — set via JS from anchor element measurement. // Fallback: ~20% from top (roughly the "Files" item in a typical layout). position: relative; - overflow: hidden; + overflow: visible; --clip-origin-top: 20%; --clip-origin-bottom: 80%; @@ -62,6 +70,8 @@ $_section-header-height: 24px; // ── MainNav layer ── &--main { flex: 1 1 auto; + min-width: 0; + max-width: 100%; transition: opacity 0.2s $easing-standard; // Non-split scenes: hide MainNav instantly when scene nav opens @@ -113,6 +123,8 @@ $_section-header-height: 24px; &__scene-inner { flex: 1 1 auto; min-height: 0; + min-width: 0; + max-width: 100%; display: flex; flex-direction: column; } @@ -519,6 +531,8 @@ $_section-header-height: 24px; &__sections { flex: 1 1 auto; + min-width: 0; + max-width: 100%; overflow-y: auto; overflow-x: hidden; // No padding-top: separation from top-actions is only .bitfun-nav-panel__top-actions padding-bottom. @@ -548,7 +562,7 @@ $_section-header-height: 24px; gap: $size-gap-1; height: $_section-header-height; padding: 0 $size-gap-2; - margin: 0 $size-gap-2; + margin: 0 $size-gap-1; border-radius: 4px; opacity: 0.92; transition: background $motion-fast $easing-standard, @@ -838,8 +852,8 @@ $_section-header-height: 24px; display: inline-flex; align-items: center; justify-content: center; - width: 20px; - height: 20px; + width: var(--bitfun-nav-row-action-size); + height: var(--bitfun-nav-row-action-size); padding: 0; border: none; border-radius: 4px; @@ -1013,6 +1027,8 @@ $_section-header-height: 24px; &__collapsible { display: grid; grid-template-rows: 1fr; + min-width: 0; + max-width: 100%; transition: grid-template-rows $motion-base $easing-standard, transform $motion-base $easing-decelerate, opacity $motion-base $easing-decelerate; @@ -1027,8 +1043,10 @@ $_section-header-height: 24px; } &__collapsible-inner { - overflow: hidden; + overflow: visible; min-height: 0; + min-width: 0; + max-width: 100%; } // ────────────────────────────────────────────── @@ -1038,7 +1056,9 @@ $_section-header-height: 24px; &__items { display: flex; flex-direction: column; - padding: 2px $size-gap-2; + min-width: 0; + max-width: 100%; + padding: 2px $size-gap-1; gap: 2px; // Multiple SessionsSection siblings: match vertical rhythm of __workspace-list (gap 0 + 2px row padding). @@ -1260,8 +1280,8 @@ $_section-header-height: 24px; display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: var(--bitfun-nav-row-action-size); + height: var(--bitfun-nav-row-action-size); margin-left: auto; border-radius: $size-radius-sm; color: var(--color-text-primary); diff --git a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts new file mode 100644 index 000000000..63e740e3d --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readNavPanelStylesheet(): string { + const stylesheet = readFileSync( + fileURLToPath(new URL('./NavPanel.scss', import.meta.url)), + 'utf8', + ); + return stylesheet.replace(/\r\n/g, '\n'); +} + +function extractBlock(stylesheet: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = stylesheet.match(new RegExp(`${escapedSelector}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); + return match?.groups?.body ?? ''; +} + +describe('NavPanel layout styles', () => { + it('allows navigation list wrappers to shrink instead of inheriting long item widths', () => { + const stylesheet = readNavPanelStylesheet(); + const rootBlock = extractBlock(stylesheet, '.bitfun-nav-panel'); + const contentBlock = extractBlock(stylesheet, '&__content'); + const mainLayerBlock = extractBlock(stylesheet, '&--main'); + const collapsibleBlock = extractBlock(stylesheet, '&__collapsible'); + const collapsibleInnerBlock = extractBlock(stylesheet, '&__collapsible-inner'); + const itemsBlock = extractBlock(stylesheet, '&__items'); + + for (const block of [ + rootBlock, + contentBlock, + mainLayerBlock, + collapsibleBlock, + collapsibleInnerBlock, + itemsBlock, + ]) { + expect(block).toContain('min-width: 0;'); + expect(block).toContain('max-width: 100%;'); + } + }); + + it('keeps root navigation rows close to the panel edge', () => { + const stylesheet = readNavPanelStylesheet(); + const sectionHeaderBlock = extractBlock(stylesheet, '&__section-header'); + const itemsBlock = extractBlock(stylesheet, '&__items'); + + expect(itemsBlock).toContain('padding: 2px $size-gap-1;'); + expect(sectionHeaderBlock).toContain('margin: 0 $size-gap-1;'); + }); + + it('uses one shared row-action size for root action buttons', () => { + const stylesheet = readNavPanelStylesheet(); + const rootBlock = extractBlock(stylesheet, '.bitfun-nav-panel'); + const sectionActionBlock = extractBlock(stylesheet, '&__section-action'); + const itemActionBlock = extractBlock(stylesheet, '&__item-action'); + + expect(rootBlock).toContain('--bitfun-nav-row-action-size: 20px;'); + expect(rootBlock).toContain('--bitfun-nav-row-action-icon-size: 13px;'); + expect(rootBlock).toContain('--bitfun-nav-row-action-offset: 4px;'); + expect(rootBlock).toContain('--bitfun-nav-row-action-gap: 4px;'); + for (const block of [sectionActionBlock, itemActionBlock]) { + expect(block).toContain('width: var(--bitfun-nav-row-action-size);'); + expect(block).toContain('height: var(--bitfun-nav-row-action-size);'); + } + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx index 6535508ce..3c2c90ec4 100644 --- a/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavSearchDialog.tsx @@ -18,6 +18,11 @@ import { sessionAPI } from '@/infrastructure/api'; import { WorkspaceKind } from '@/shared/types'; import { i18nService } from '@/infrastructure/i18n'; import { resolvePersistedSessionTitle, resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; +import { + compareSessionsForDisplay, + getSessionMetadataSortTimestamp, + getSessionSortTimestamp, +} from '@/flow_chat/utils/sessionOrdering'; import './NavSearchDialog.scss'; interface NavSearchDialogProps { @@ -40,9 +45,6 @@ const MAX_PER_GROUP = 20; const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); -const sessionRecencyTime = (session: Session): number => - session.updatedAt ?? session.lastActiveAt ?? session.createdAt ?? 0; - const matchesQuery = (query: string, ...fields: (string | undefined | null)[]): boolean => { const q = query.toLowerCase(); return fields.some(f => f && f.toLowerCase().includes(q)); @@ -126,12 +128,15 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { const sessionsInOpenedWorkspaces = useMemo((): Array<{ session: Session; workspace: WorkspaceInfo }> => { const result: Array<{ session: Session; workspace: WorkspaceInfo }> = []; for (const session of flowChatState.sessions.values()) { + if (session.isTransient) { + continue; + } const workspace = findWorkspaceForSession(session, openedWorkspacesList); if (workspace && openedWorkspaceIdSet.has(workspace.id)) { result.push({ session, workspace }); } } - result.sort((a, b) => sessionRecencyTime(b.session) - sessionRecencyTime(a.session)); + result.sort((a, b) => compareSessionsForDisplay(a.session, b.session)); return result; }, [flowChatState.sessions, openedWorkspacesList, openedWorkspaceIdSet]); @@ -192,13 +197,23 @@ const NavSearchDialog: React.FC = ({ open, onClose }) => { merged.sort((a, b) => { const ta = 'session' in a - ? sessionRecencyTime(a.session) - : a.disk.lastActiveAt ?? a.disk.createdAt ?? 0; + ? getSessionSortTimestamp(a.session) + : getSessionMetadataSortTimestamp(a.disk); const tb = 'session' in b - ? sessionRecencyTime(b.session) - : b.disk.lastActiveAt ?? b.disk.createdAt ?? 0; - return tb - ta; + ? getSessionSortTimestamp(b.session) + : getSessionMetadataSortTimestamp(b.disk); + const timestampDiff = tb - ta; + if (timestampDiff !== 0) return timestampDiff; + + const createdAtA = 'session' in a ? a.session.createdAt : a.disk.createdAt; + const createdAtB = 'session' in b ? b.session.createdAt : b.disk.createdAt; + const createdAtDiff = createdAtB - createdAtA; + if (createdAtDiff !== 0) return createdAtDiff; + + const sessionIdA = 'session' in a ? a.session.sessionId : a.disk.sessionId; + const sessionIdB = 'session' in b ? b.session.sessionId : b.disk.sessionId; + return sessionIdA.localeCompare(sessionIdB); }); for (const entry of merged.slice(0, MAX_PER_GROUP)) { diff --git a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx index 8fd9868ff..6d83ea771 100644 --- a/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/NavItem.tsx @@ -104,7 +104,7 @@ const NavItem: React.FC = ({ tabIndex={-1} aria-label={actionTitle} > - + ) : ( @@ -115,7 +115,7 @@ const NavItem: React.FC = ({ role="button" tabIndex={-1} > - + ) )} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 29ba8c145..4fe2070c1 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -12,11 +12,13 @@ &__inline-list { display: flex; flex-direction: column; - padding: 2px $size-gap-2 2px; - gap: 1px; + min-width: 0; + max-width: 100%; + padding: 2px $size-gap-1 2px; + gap: 0; // No vertical margin: spacing between assistant blocks comes from 2px top/bottom padding only // (see .bitfun-nav-panel__items--session-blocks gap: 0), aligned with __workspace-item padding. - margin: 0 $size-gap-2 0 calc(#{$size-gap-2} + 4px); + margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px); } &__inline-action { @@ -59,6 +61,8 @@ display: flex; align-items: center; gap: 5px; + min-width: 0; + max-width: 100%; height: 26px; padding: 0 $size-gap-1; border: none; @@ -69,7 +73,10 @@ font-weight: 400; cursor: pointer; width: 100%; + overflow: hidden; text-align: left; + overflow: hidden; + position: relative; transition: color $motion-fast $easing-standard, background $motion-fast $easing-standard; @@ -94,6 +101,7 @@ } &.is-child { + margin-top: -2px; min-height: 24px; font-size: var(--font-size-xs); padding-left: calc(#{$size-gap-1} + 14px); @@ -179,15 +187,24 @@ } &__inline-item-main { - flex: 1; + flex: 1 1 0; min-width: 0; + max-width: 100%; + overflow: hidden; display: flex; align-items: center; - gap: 6px; + gap: 4px; + overflow: visible; + } + + &__inline-item:hover &__inline-item-main, + &__inline-item.is-menu-open &__inline-item-main, + &__inline-item:focus-within &__inline-item-main { + padding-right: calc(var(--bitfun-nav-row-action-size) + var(--bitfun-nav-row-action-offset)); } &__inline-item-label { - flex: 1; + flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -200,14 +217,16 @@ align-items: center; height: 12px; padding: 0 4px; - border: 1px solid color-mix(in srgb, var(--border-subtle) 85%, transparent); + border: 1px solid color-mix(in srgb, var(--color-accent-400, #8b5cf6) 34%, var(--border-subtle)); border-radius: 999px; - background: color-mix(in srgb, var(--element-bg-soft) 42%, transparent); - color: var(--color-text-muted); + background: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 13%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 62%, var(--color-text-primary)); font-size: var(--font-size-xxs); - font-weight: 500; + font-weight: 600; letter-spacing: 0.02em; - opacity: 0.78; + opacity: 0.96; + overflow: visible; + white-space: nowrap; } &__inline-item-review-badge { @@ -217,19 +236,38 @@ gap: 3px; height: 14px; padding: 0 5px; - border: 1px solid color-mix(in srgb, var(--color-accent-400, #8b5cf6) 28%, var(--border-subtle)); + border: 1px solid color-mix(in srgb, var(--color-accent-400, #8b5cf6) 44%, var(--border-subtle)); border-radius: 999px; - background: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 10%, transparent); - color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 72%, var(--color-text-primary)); + background: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 18%, var(--element-bg-soft)); + color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 82%, var(--color-text-primary)); font-size: var(--font-size-xxs); - font-weight: 500; + font-weight: 600; letter-spacing: 0.02em; + white-space: nowrap; svg { animation: bitfun-nav-session-spin 1s linear infinite; } } + // Attention badge for high-priority states (ask_user / tool_confirm) + &__inline-item-attention-badge { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 3px; + height: 14px; + padding: 0 5px; + border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 35%, var(--border-subtle)); + border-radius: 999px; + background: color-mix(in srgb, var(--color-warning, #f59e0b) 14%, transparent); + color: color-mix(in srgb, var(--color-warning, #f59e0b) 80%, var(--color-text-primary)); + font-size: var(--font-size-xxs); + font-weight: 600; + letter-spacing: 0.02em; + white-space: nowrap; + } + &__inline-item-tooltip { display: flex; flex-direction: column; @@ -265,23 +303,73 @@ 0 0 0 1px color-mix(in srgb, var(--color-success, #22c55e) 35%, transparent), 0 0 6px color-mix(in srgb, var(--color-success, #22c55e) 42%, transparent); - &.is-error { + &.is-error, + &.is-interrupted { background: var(--color-error, #ef4444); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error, #ef4444) 35%, transparent), 0 0 6px color-mix(in srgb, var(--color-error, #ef4444) 42%, transparent); } + + // High-priority attention states: larger, brighter, with subtle pulse + &.is-ask-user, + &.is-tool-confirm { + width: 8px; + height: 8px; + right: -2px; + bottom: -2px; + } + + &.is-ask-user { + background: var(--color-warning, #f59e0b); + box-shadow: + 0 0 0 1.5px color-mix(in srgb, var(--color-warning, #f59e0b) 45%, transparent), + 0 0 8px color-mix(in srgb, var(--color-warning, #f59e0b) 55%, transparent); + } + + &.is-tool-confirm { + background: var(--color-accent-500, #8b5cf6); + box-shadow: + 0 0 0 1.5px color-mix(in srgb, var(--color-accent-500, #8b5cf6) 45%, transparent), + 0 0 8px color-mix(in srgb, var(--color-accent-500, #8b5cf6) 55%, transparent); + } + + // Pulse animation for high-priority states + &.is-high-priority { + animation: bitfun-unread-dot-pulse 2s ease-in-out infinite; + } + } + + @keyframes bitfun-unread-dot-pulse { + 0%, 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.35); + opacity: 0.85; + } } &__inline-item-actions { - display: none; + position: absolute; + top: 50%; + right: var(--bitfun-nav-row-action-offset); + transform: translateY(-50%); + display: flex; align-items: center; - gap: 2px; - flex-shrink: 0; - margin-left: auto; + gap: var(--bitfun-nav-row-action-gap); + visibility: hidden; + opacity: 0; + pointer-events: none; + transition: opacity $motion-fast $easing-standard, + visibility $motion-fast $easing-standard; - .bitfun-nav-panel__inline-item:hover & { - display: flex; + .bitfun-nav-panel__inline-item:hover &, + &.is-open { + visibility: visible; + opacity: 1; + pointer-events: auto; } } @@ -289,8 +377,8 @@ display: flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: var(--bitfun-nav-row-action-size); + height: var(--bitfun-nav-row-action-size); padding: 0; border: none; border-radius: 4px; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 089d74f71..aac6f2ed8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -24,7 +24,7 @@ import { } from '@/flow_chat/services/openBtwSession'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { - compareSessionsForDisplay, + compareSessionsForNavStable, sessionBelongsToWorkspaceNavRow, } from '@/flow_chat/utils/sessionOrdering'; import { stateMachineManager } from '@/flow_chat/state-machine'; @@ -183,6 +183,7 @@ const SessionsSection: React.FC = ({ requestAnimationFrame(() => { requestAnimationFrame(() => { flowChatStore.clearSessionUnreadCompletion(sessionId); + flowChatStore.clearSessionNeedsAttention(sessionId); }); }); }; @@ -195,12 +196,15 @@ const SessionsSection: React.FC = ({ () => Array.from(flowChatState.sessions.values()) .filter((s: Session) => { + if (s.isTransient) { + return false; + } if (workspacePath) { return sessionBelongsToWorkspaceNavRow(s, workspacePath, remoteConnectionId, remoteSshHost); } return !s.workspacePath; }) - .sort(compareSessionsForDisplay), + .sort(compareSessionsForNavStable), [flowChatState.sessions, workspacePath, remoteConnectionId, remoteSshHost] ); @@ -222,11 +226,11 @@ const SessionsSection: React.FC = ({ } for (const [pid, list] of childMap) { - childMap.set(pid, [...list].sort(compareSessionsForDisplay)); + childMap.set(pid, [...list].sort(compareSessionsForNavStable)); } return { - topLevelSessions: [...parents].sort(compareSessionsForDisplay), + topLevelSessions: [...parents].sort(compareSessionsForNavStable), childrenByParent: childMap, }; }, [sessions]); @@ -477,7 +481,6 @@ const SessionsSection: React.FC = ({ : Bot : Code2; const isRunning = runningSessionIds.has(session.sessionId); - const isUnreadCompleted = !isRunning && session.hasUnreadCompletion; const isRowActive = isSessionNavRowActive({ rowSessionId: session.sessionId, activeTabId, @@ -485,6 +488,12 @@ const SessionsSection: React.FC = ({ activeChildSessionId: activeBtwSessionData?.childSessionId, activeChildParentSessionId: activeBtwSessionData?.parentSessionId, }); + // Determine the notification state for this session row. + // Priority: needsUserAttention > hasUnreadCompletion. + const attentionKind = !isRunning && !isRowActive + ? (session.needsUserAttention || session.hasUnreadCompletion || undefined) + : undefined; + const isHighPriority = !!session.needsUserAttention; const row = (
= ({ isChildSession && 'is-btw-child', isRowActive && 'is-active', isEditing && 'is-editing', + openMenuSessionId === session.sessionId && 'is-menu-open', ] .filter(Boolean) .join(' ')} @@ -521,16 +531,26 @@ const SessionsSection: React.FC = ({ ].join(' ')} /> )} - {isUnreadCompleted ? ( + {attentionKind ? ( ) : null} @@ -577,6 +597,13 @@ const SessionsSection: React.FC = ({ {isChildSession ? ( {childSessionBadge} ) : null} + {attentionKind === 'ask_user' || attentionKind === 'tool_confirm' ? ( + + {attentionKind === 'ask_user' + ? t('nav.sessions.badgeNeedsInput') + : t('nav.sessions.badgeNeedsConfirm')} + + ) : null} {reviewActivityKind ? ( @@ -584,13 +611,15 @@ const SessionsSection: React.FC = ({ ) : null} -
+
{openMenuSessionId === session.sessionId && sessionMenuPosition && createPortal( diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts new file mode 100644 index 000000000..2885cb63a --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSectionLayout.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readSessionsSectionStylesheet(): string { + const stylesheet = readFileSync( + fileURLToPath(new URL('./SessionsSection.scss', import.meta.url)), + 'utf8', + ); + return stylesheet.replace(/\r\n/g, '\n'); +} + +function extractInlineItemActionsBlock(stylesheet: string): string { + const match = stylesheet.match(/&__inline-item-actions\s*\{(?[\s\S]*?)\n\s*\}/); + return match?.groups?.body ?? ''; +} + +function extractInlineItemBlock(stylesheet: string, element: string): string { + const match = stylesheet.match(new RegExp(`&__inline-item-${element}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); + return match?.groups?.body ?? ''; +} + +function extractBlock(stylesheet: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = stylesheet.match(new RegExp(`${escapedSelector}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); + return match?.groups?.body ?? ''; +} + +describe('SessionsSection layout styles', () => { + it('keeps session rows visually compact without reducing the click target height', () => { + const stylesheet = readSessionsSectionStylesheet(); + const inlineListBlock = extractBlock(stylesheet, '&__inline-list'); + const inlineItemBlock = extractBlock(stylesheet, '&__inline-item'); + + expect(inlineListBlock).toContain('padding: 2px $size-gap-1 2px;'); + expect(inlineListBlock).toContain('margin: 0 $size-gap-1 0 calc(#{$size-gap-1} + 4px);'); + expect(inlineListBlock).toContain('gap: 0;'); + expect(inlineItemBlock).toContain('height: 26px;'); + expect(stylesheet).toContain('margin-top: -2px;'); + }); + + it('keeps hidden session row actions from reserving title width', () => { + const stylesheet = readSessionsSectionStylesheet(); + const inlineItemBlock = extractBlock(stylesheet, '&__inline-item'); + const mainBlock = extractInlineItemBlock(stylesheet, 'main'); + const actionsBlock = extractInlineItemActionsBlock(stylesheet); + + expect(stylesheet).toContain('&__inline-item-main {\n flex: 1 1 0;'); + expect(inlineItemBlock).toContain('position: relative;'); + expect(mainBlock).not.toContain('padding-right'); + expect(stylesheet).toContain('&__inline-item:hover &__inline-item-main'); + expect(stylesheet).toContain('&__inline-item:focus-within &__inline-item-main'); + expect(stylesheet).toContain('padding-right: calc(var(--bitfun-nav-row-action-size) + var(--bitfun-nav-row-action-offset));'); + expect(actionsBlock).not.toContain('display: none;'); + expect(actionsBlock).toContain('position: absolute;'); + expect(actionsBlock).toContain('right: var(--bitfun-nav-row-action-offset);'); + expect(actionsBlock).toContain('gap: var(--bitfun-nav-row-action-gap);'); + expect(actionsBlock).toContain('visibility: hidden;'); + expect(actionsBlock).toContain('opacity: 0;'); + expect(actionsBlock).toContain('pointer-events: none;'); + expect(actionsBlock).toContain('.bitfun-nav-panel__inline-item:hover &'); + expect(actionsBlock).toContain('&.is-open'); + expect(actionsBlock).toContain('visibility: visible;'); + }); + + it('uses the shared nav row-action size for session menu buttons', () => { + const stylesheet = readSessionsSectionStylesheet(); + const actionButtonBlock = extractBlock(stylesheet, '&__inline-item-action-btn'); + + expect(actionButtonBlock).toContain('width: var(--bitfun-nav-row-action-size);'); + expect(actionButtonBlock).toContain('height: var(--bitfun-nav-row-action-size);'); + }); + + it('keeps child-session badges visible while long titles are ellipsized', () => { + const stylesheet = readSessionsSectionStylesheet(); + const labelBlock = extractInlineItemBlock(stylesheet, 'label'); + const btwBadgeBlock = extractInlineItemBlock(stylesheet, 'btw-badge'); + const reviewBadgeBlock = extractInlineItemBlock(stylesheet, 'review-badge'); + + expect(labelBlock).toContain('flex: 1 1 0;'); + expect(labelBlock).toContain('overflow: hidden;'); + expect(labelBlock).toContain('text-overflow: ellipsis;'); + expect(btwBadgeBlock).toContain('white-space: nowrap;'); + expect(btwBadgeBlock).toContain('overflow: visible;'); + expect(btwBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 62%, var(--color-text-primary));'); + expect(btwBadgeBlock).toContain('font-weight: 600;'); + expect(btwBadgeBlock).toContain('opacity: 0.96;'); + expect(reviewBadgeBlock).toContain('white-space: nowrap;'); + expect(reviewBadgeBlock).toContain('color: color-mix(in srgb, var(--color-accent-400, #8b5cf6) 82%, var(--color-text-primary));'); + expect(reviewBadgeBlock).toContain('font-weight: 600;'); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 18060907b..7ba47f8f1 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,10 +1,12 @@ -import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; -import { ConfirmDialog, Tooltip } from '@/component-library'; +import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { i18nService } from '@/infrastructure/i18n'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createWorktreeWorkspace, @@ -18,6 +20,11 @@ import { notificationService } from '@/shared/notification-system'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { openMainSession } from '@/flow_chat/services/openBtwSession'; import { findReusableEmptySessionId } from '@/app/utils/projectSessionWorkspace'; +import { + ACPClientAPI, + type AcpClientInfo, + type AcpClientRequirementProbe, +} from '@/infrastructure/api/service-api/ACPClientAPI'; import { BranchSelectModal, type BranchSelectResult } from '../../../panels/BranchSelectModal'; import SessionsSection from '../sessions/SessionsSection'; import { @@ -27,6 +34,8 @@ import { type WorkspaceInfo, } from '@/shared/types'; import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; +import { useWorkspaceSearchIndex } from '@/tools/file-explorer'; + interface WorkspaceItemProps { workspace: WorkspaceInfo; @@ -38,6 +47,13 @@ interface WorkspaceItemProps { onDragEnd?: React.DragEventHandler; } +function getIndexActionKind(phase?: string | null): 'build' | 'rebuild' { + if (!phase || phase === 'needs_index' || phase === 'preparing') { + return 'build'; + } + return 'rebuild'; +} + const WorkspaceItem: React.FC = ({ workspace, isActive, @@ -48,6 +64,7 @@ const WorkspaceItem: React.FC = ({ onDragEnd, }) => { const { t } = useI18n('common'); + const { t: tFiles } = useTranslation('panels/files'); const { openWorkspace, setActiveWorkspace, @@ -67,9 +84,15 @@ const WorkspaceItem: React.FC = ({ const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); const [sessionsCollapsed, setSessionsCollapsed] = useState(false); + const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false); + const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState( + () => aiExperienceConfigService.getSettings().enable_workspace_search, + ); + const [acpClients, setAcpClients] = useState([]); const menuRef = useRef(null); const menuAnchorRef = useRef(null); const menuPopoverRef = useRef(null); + const cardRef = useRef(null); const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const isNamedAssistantWorkspace = workspace.workspaceKind === WorkspaceKind.Assistant && @@ -82,6 +105,22 @@ const WorkspaceItem: React.FC = ({ ? workspace.identity?.name?.trim() || workspace.name : workspace.name; const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); + const canShowSearchIndex = + isActive + && workspaceSearchEnabled + && workspace.workspaceKind === WorkspaceKind.Normal + && !isRemoteWorkspace(workspace); + const workspaceSearchIndex = useWorkspaceSearchIndex({ + workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, + enabled: canShowSearchIndex, + }); + + useEffect(() => { + setWorkspaceSearchEnabled(aiExperienceConfigService.getSettings().enable_workspace_search); + return aiExperienceConfigService.addChangeListener(settings => { + setWorkspaceSearchEnabled(settings.enable_workspace_search); + }); + }, []); // Remote connection status — optional: safe if not inside SSHRemoteProvider const sshContext = useContext(SSHContext); @@ -89,6 +128,138 @@ const WorkspaceItem: React.FC = ({ ? (sshContext.workspaceStatuses[workspace.connectionId] ?? 'connecting') : undefined; + const searchIndexIndicator = useMemo(() => { + if (!canShowSearchIndex) { + return null; + } + + const repoStatus = workspaceSearchIndex.indexStatus?.repoStatus ?? null; + const activeTask = workspaceSearchIndex.indexStatus?.activeTask ?? null; + const phase = repoStatus?.phase; + const isTaskActive = activeTask?.state === 'queued' || activeTask?.state === 'running'; + const hasError = Boolean( + workspaceSearchIndex.error + || repoStatus?.lastError + || activeTask?.error + || activeTask?.state === 'failed' + ); + const dirtyFiles = repoStatus + ? repoStatus.dirtyFiles.modified + repoStatus.dirtyFiles.deleted + repoStatus.dirtyFiles.new + : 0; + + let tone: 'green' | 'yellow' | 'gray' | 'red' = 'gray'; + if (hasError || phase === 'limited') { + tone = 'red'; + } else if (!phase || phase === 'needs_index') { + tone = 'gray'; + } else if ( + isTaskActive + || phase === 'preparing' + || phase === 'building' + || phase === 'refreshing' + || Boolean(repoStatus?.rebuildRecommended) + ) { + tone = 'yellow'; + } else if (phase === 'ready' || phase === 'tracking_changes') { + tone = 'green'; + } + + const phaseLabel = tFiles(`search.index.phase.${phase ?? 'unknown'}`, { + defaultValue: phase ?? tFiles('search.index.phase.unknown'), + }); + const title = tFiles(`search.index.indicator.tones.${tone}`); + const summary = repoStatus + ? tFiles(`search.index.summary.${phase ?? 'unavailable'}`, { + defaultValue: tFiles('search.index.summary.unavailable'), + }) + : workspaceSearchIndex.loading + ? tFiles('search.index.indicator.checking') + : tFiles('search.index.summary.unavailable'); + const activeTaskLabel = activeTask + ? tFiles(`search.index.taskState.${activeTask.state}`, { + defaultValue: activeTask.state, + }) + : null; + const progressLabel = activeTask + ? typeof activeTask.total === 'number' && activeTask.total > 0 + ? tFiles('search.index.indicator.progressKnown', { + processed: activeTask.processed, + total: activeTask.total, + }) + : tFiles('search.index.indicator.progressUnknown', { + processed: activeTask.processed, + }) + : null; + const progressPercent = + activeTask && typeof activeTask.total === 'number' && activeTask.total > 0 + ? Math.max(0, Math.min(100, (activeTask.processed / activeTask.total) * 100)) + : null; + const progressPercentLabel = + typeof progressPercent === 'number' + ? `${Math.round(progressPercent)}%` + : null; + const dirtyFilesLabel = + repoStatus && dirtyFiles > 0 + ? tFiles('search.index.indicator.dirtyFiles', { + modified: repoStatus.dirtyFiles.modified, + deleted: repoStatus.dirtyFiles.deleted, + new: repoStatus.dirtyFiles.new, + }) + : null; + const errorText = workspaceSearchIndex.error ?? activeTask?.error ?? repoStatus?.lastError ?? null; + + return { + tone, + title, + phaseLabel, + summary, + activeTaskLabel, + activeTaskMessage: activeTask?.message ?? null, + progressLabel, + progressPercent, + progressPercentLabel, + dirtyFilesLabel, + rebuildRecommended: Boolean(repoStatus?.rebuildRecommended), + probeHealthy: repoStatus?.probeHealthy ?? true, + errorText, + ariaLabel: `${tFiles('search.index.indicator.label')}: ${title} · ${phaseLabel}`, + }; + }, [ + canShowSearchIndex, + tFiles, + workspaceSearchIndex.error, + workspaceSearchIndex.indexStatus, + workspaceSearchIndex.loading, + ]); + const searchIndexActionKind = getIndexActionKind( + workspaceSearchIndex.indexStatus?.repoStatus.phase ?? null + ); + const searchIndexActionLabel = tFiles( + searchIndexActionKind === 'build' + ? 'search.index.actions.build' + : 'search.index.actions.rebuild' + ); + + const handleSearchIndexAction = useCallback(async () => { + const result = + searchIndexActionKind === 'build' + ? await workspaceSearchIndex.buildIndex() + : await workspaceSearchIndex.rebuildIndex(); + + if (!result) { + return; + } + + notificationService.success( + tFiles( + searchIndexActionKind === 'build' + ? 'notifications.searchIndexBuildStarted' + : 'notifications.searchIndexRebuildStarted' + ), + { duration: 2200 } + ); + }, [searchIndexActionKind, tFiles, workspaceSearchIndex]); + const updateMenuPosition = useCallback(() => { const anchor = menuAnchorRef.current; if (!anchor) return; @@ -133,6 +304,36 @@ const WorkspaceItem: React.FC = ({ }; }, [menuOpen, updateMenuPosition]); + useEffect(() => { + let cancelled = false; + + const loadAcpClients = async () => { + try { + const [clients, requirementProbes] = await Promise.all([ + ACPClientAPI.getClients(), + ACPClientAPI.probeClientRequirements(), + ]); + const probesById = new Map( + requirementProbes.map(probe => [probe.id, probe]) + ); + if (!cancelled) { + setAcpClients(clients.filter(client => client.enabled && probesById.get(client.id)?.runnable === true)); + } + } catch (_error) { + setAcpClients([]); + } + }; + + void loadAcpClients(); + window.addEventListener('bitfun:acp-clients-changed', loadAcpClients); + window.addEventListener('bitfun:acp-requirements-changed', loadAcpClients); + return () => { + cancelled = true; + window.removeEventListener('bitfun:acp-clients-changed', loadAcpClients); + window.removeEventListener('bitfun:acp-requirements-changed', loadAcpClients); + }; + }, []); + const handleActivate = useCallback(async () => { if (!isActive) { await setActiveWorkspace(workspace.id); @@ -146,6 +347,7 @@ const WorkspaceItem: React.FC = ({ const handleCardNameClick = useCallback(async () => { if (!isActive) { await setActiveWorkspace(workspace.id); + setSessionsCollapsed(false); } else { setSessionsCollapsed(prev => !prev); } @@ -293,6 +495,33 @@ const WorkspaceItem: React.FC = ({ void handleCreateSession('Cowork'); }, [handleCreateSession]); + const handleCreateAcpSession = useCallback(async (client: AcpClientInfo) => { + setMenuOpen(false); + try { + const sessionId = await flowChatManager.createAcpChatSession( + client.id, + { + workspacePath: workspace.rootPath, + ...(isRemoteWorkspace(workspace) && workspace.connectionId + ? { remoteConnectionId: workspace.connectionId } + : {}), + ...(isRemoteWorkspace(workspace) && workspace.sshHost + ? { remoteSshHost: workspace.sshHost } + : {}), + }, + ); + await openMainSession(sessionId, { + workspaceId: workspace.id, + activateWorkspace: setActiveWorkspace, + }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'), + { duration: 4000 } + ); + } + }, [setActiveWorkspace, t, workspace]); + const handleCreateInitSession = useCallback(async () => { setMenuOpen(false); @@ -411,15 +640,18 @@ const WorkspaceItem: React.FC = ({ aria-current={isActive ? 'location' : undefined} aria-grabbed={draggable ? isDragging : undefined}>
{ void handleCardNameClick(); }} + style={{ cursor: 'pointer' }} > - + + + -
+
e.stopPropagation()}>
@@ -470,7 +704,7 @@ const WorkspaceItem: React.FC = ({ className={`bitfun-nav-panel__assistant-item-menu-trigger${menuOpen ? ' is-open' : ''}`} onClick={() => setMenuOpen(prev => !prev)} > - +
@@ -585,15 +819,18 @@ const WorkspaceItem: React.FC = ({ aria-current={isActive ? 'location' : undefined} aria-grabbed={draggable ? isDragging : undefined}>
{ void handleCardNameClick(); }} + style={{ cursor: 'pointer' }} > - + + {searchIndexIndicator && ( + <> + + +
+
+ + + )} +
{isRemoteWorkspace(workspace) && ( = ({ {workspace.connectionName} )} - - - -
- - - -
-
+
- {menuOpen && menuPosition && createPortal( -
- - - -
- {isLinkedWorktree ? ( +
+ + {menuOpen && menuPosition && createPortal( +
+ + + {acpClients.map(client => { + const label = client.name || client.id; + return ( + + ); + })} + +
+ {isLinkedWorktree ? ( + + ) : ( + + )} - ) : ( - )} - - -
- -
, - document.body - )} +
+ +
, + document.body + )} +
diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss index 2b10bcbb8..7c0f2155f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSection.scss @@ -5,12 +5,17 @@ &__workspace-list { display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; gap: 0; + min-width: 0; + max-width: 100%; padding: 2px 0 0; &.is-dragging { .bitfun-nav-panel__workspace-item { - cursor: grabbing; + cursor: default; } } } @@ -18,7 +23,12 @@ &__workspace-group { display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; gap: $size-gap-1; + min-width: 0; + max-width: 100%; } &__workspace-group-title { @@ -75,7 +85,13 @@ position: relative; display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; gap: 2px; + min-width: 0; + max-width: 100%; padding: 2px $size-gap-1; border-radius: $size-radius-base; background: transparent; @@ -87,7 +103,7 @@ opacity: 0.42; .bitfun-nav-panel__workspace-item-card { - cursor: grabbing; + cursor: default; } } @@ -106,7 +122,6 @@ gap: 1px; &:hover { - background: transparent; color: var(--color-text-secondary); .bitfun-nav-panel__workspace-item-collapse-btn, @@ -124,12 +139,16 @@ } } - .bitfun-nav-panel__workspace-item-name-btn { + .bitfun-nav-panel__workspace-item-name-cluster { border-radius: 0 6px 6px 0; } + + .bitfun-nav-panel__workspace-item-name-btn { + border-radius: 0; + } } - &:not(.is-active):not(:hover):not(:focus-within):not(.is-menu-open) { + &:not(:hover):not(:focus-within):not(.is-menu-open) { .bitfun-nav-panel__workspace-item-branch { max-width: 0; margin-left: 0; @@ -153,16 +172,18 @@ display: flex; align-items: center; width: 100%; + min-width: 0; + max-width: 100%; min-height: 30px; + overflow: hidden; border-radius: 6px; color: var(--color-text-primary); - background: transparent; + background: var(--color-bg-primary); transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; + background $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; + - &[draggable='true'] { - cursor: grab; - } &:hover { .bitfun-nav-panel__workspace-item-icon-default { @@ -187,10 +208,6 @@ &__workspace-item.is-active &__workspace-item-card { color: var(--color-text-primary); - - &:hover { - background: transparent; - } } &__workspace-item:not(.is-active) &__workspace-item-icon { @@ -230,22 +247,70 @@ } } + &__workspace-item-name-cluster { + display: inline-flex; + align-items: stretch; + flex: 1 1 0; + min-width: 0; + } + + &__workspace-item-name-stack { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 1px; + flex: 1 1 0; + min-width: 0; + max-width: 100%; + padding: 0 4px; + } + + &__workspace-item-name-row { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + width: 100%; + } + &__workspace-item-name-btn { - flex: 1; + flex: 0 1 auto; min-width: 0; + max-width: 100%; display: flex; align-items: center; gap: 6px; min-height: 30px; - padding: 0 58px 0 4px; + padding: 0; border: none; - border-radius: 0 6px 6px 0; + border-radius: 0; background: transparent; color: inherit; cursor: pointer; + overflow: hidden; text-align: left; } + &__workspace-item.is-active &__workspace-item-name-stack { + padding-right: calc( + 4px + + var(--bitfun-nav-row-action-size) + + var(--bitfun-nav-row-action-offset) + ); + } + + &__workspace-item:hover &__workspace-item-name-stack, + &__workspace-item.is-menu-open &__workspace-item-name-stack, + &__workspace-item:focus-within &__workspace-item-name-stack { + padding-right: calc( + 4px + + var(--bitfun-nav-row-action-size) + + var(--bitfun-nav-row-action-gap) + + var(--bitfun-nav-row-action-size) + + var(--bitfun-nav-row-action-offset) + ); + } + &__workspace-item-active-icon { display: inline-flex; align-items: center; @@ -350,6 +415,7 @@ } &__workspace-item-label { + flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -369,10 +435,11 @@ &__workspace-item-title { min-width: 0; + max-width: 100%; display: inline-flex; align-items: center; gap: 6px; - flex: 1; + flex: 1 1 0; overflow: hidden; &.is-remote { @@ -382,6 +449,14 @@ } } + &__workspace-item-name-line { + display: flex; + align-items: center; + min-width: 0; + flex: 1 1 0; + overflow: hidden; + } + &__workspace-item-subtitle { display: inline-flex; align-items: center; @@ -426,6 +501,299 @@ } } + &__workspace-index-indicator { + display: inline-flex; + flex-shrink: 0; + width: 7px; + height: 7px; + border-radius: 50%; + appearance: none; + padding: 0; + margin: 0; + border: none; + font: inherit; + vertical-align: middle; + cursor: pointer; + transition: opacity $motion-fast $easing-standard; + + &:hover { + opacity: 0.88; + } + + &.is-green { + background: var(--color-success, #36c275); + } + + &.is-yellow { + background: var(--color-warning, #e8b54b); + } + + &.is-gray { + background: color-mix(in srgb, var(--color-text-muted) 78%, var(--element-bg-medium)); + } + + &.is-red { + background: var(--color-error, #e05d5d); + } + } + + &__workspace-index-modal-content { + overflow-x: hidden; + } + + &__workspace-index-tooltip { + --bitfun-index-tone: var(--color-text-secondary); + + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + min-width: 0; + max-width: none; + box-sizing: border-box; + padding: 12px 0 16px; + color: var(--color-text-primary); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + } + + &__workspace-index-tooltip-heading { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1; + } + + &__workspace-index-tooltip-dot { + flex-shrink: 0; + width: 10px; + height: 10px; + margin-top: 3px; + border-radius: 50%; + background: var(--bitfun-index-tone); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--bitfun-index-tone) 18%, transparent), + 0 0 10px color-mix(in srgb, var(--bitfun-index-tone) 26%, transparent); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-title-wrap { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; + } + + &__workspace-index-tooltip-title { + font-size: var(--font-size-xs); + font-weight: 700; + line-height: 1.2; + color: var(--color-text-primary); + } + + &__workspace-index-tooltip-badge { + display: inline-flex; + align-items: center; + padding: 3px 8px; + border-radius: 999px; + font-size: var(--font-size-xxs); + font-weight: 600; + line-height: 1.4; + white-space: nowrap; + border: 1px solid color-mix(in srgb, var(--bitfun-index-tone) 24%, transparent); + background: color-mix(in srgb, var(--bitfun-index-tone) 10%, transparent); + color: color-mix(in srgb, var(--bitfun-index-tone) 78%, var(--color-text-primary)); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + } + } + + &__workspace-index-tooltip-phase { + font-size: var(--font-size-xxs); + color: var(--color-text-muted); + line-height: 1.35; + } + + &__workspace-index-tooltip-summary { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--bitfun-index-tone) 12%, var(--border-subtle)); + background: + linear-gradient( + 180deg, + color-mix(in srgb, var(--bitfun-index-tone) 7%, transparent), + color-mix(in srgb, var(--element-bg-subtle) 92%, transparent) + ); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + line-height: 1.45; + } + + &__workspace-index-tooltip-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border-radius: 10px; + background: color-mix(in srgb, var(--element-bg-medium) 56%, transparent); + border: 1px solid color-mix(in srgb, var(--border-subtle) 76%, transparent); + font-size: var(--font-size-xxs); + color: var(--color-text-secondary); + } + + &__workspace-index-tooltip-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + &__workspace-index-tooltip-progress-value { + font-variant-numeric: tabular-nums; + color: color-mix(in srgb, var(--bitfun-index-tone) 76%, var(--color-text-primary)); + font-weight: 600; + } + + &__workspace-index-tooltip-progress-bar { + width: 100%; + height: 6px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in srgb, var(--element-bg-strong) 68%, transparent); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.22); + } + + &__workspace-index-tooltip-progress-fill { + display: block; + height: 100%; + border-radius: inherit; + box-shadow: 0 0 12px color-mix(in srgb, var(--bitfun-index-tone) 28%, transparent); + + &.is-green { + --bitfun-index-tone: var(--color-success, #36c275); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 82%, #fff 18%), + var(--bitfun-index-tone) + ); + } + + &.is-yellow { + --bitfun-index-tone: var(--color-warning, #e8b54b); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + var(--bitfun-index-tone) + ); + } + + &.is-gray { + --bitfun-index-tone: color-mix(in srgb, var(--color-text-muted) 75%, var(--element-bg-medium)); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 88%, #fff 12%), + var(--bitfun-index-tone) + ); + } + + &.is-red { + --bitfun-index-tone: var(--color-error, #e05d5d); + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--bitfun-index-tone) 84%, #fff 16%), + var(--bitfun-index-tone) + ); + } + } + + &__workspace-index-tooltip-meta, + &__workspace-index-tooltip-error { + padding: 8px 10px; + border-radius: 9px; + font-size: var(--font-size-xxs); + line-height: 1.45; + border: 1px solid color-mix(in srgb, var(--border-subtle) 78%, transparent); + background: color-mix(in srgb, var(--element-bg-subtle) 92%, transparent); + } + + &__workspace-index-tooltip-meta { + color: var(--color-text-secondary); + + &.is-warning { + color: color-mix(in srgb, var(--color-warning, #e8b54b) 84%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-warning, #e8b54b) 18%, transparent); + background: color-mix(in srgb, var(--color-warning, #e8b54b) 10%, transparent); + } + } + + &__workspace-index-tooltip-error { + color: color-mix(in srgb, var(--color-error, #e05d5d) 88%, var(--color-text-primary)); + border-color: color-mix(in srgb, var(--color-error, #e05d5d) 22%, transparent); + background: color-mix(in srgb, var(--color-error, #e05d5d) 11%, transparent); + } + + &__workspace-index-tooltip-actions { + display: flex; + align-items: center; + justify-content: stretch; + padding-top: 2px; + + .btn { + width: 100%; + min-height: 28px; + border-radius: 9px; + font-weight: 600; + box-shadow: none; + } + } + &__workspace-item-badge { display: inline-flex; align-items: center; @@ -465,14 +833,20 @@ } } - &__workspace-item-menu { + &__workspace-item-actions { position: absolute; top: 50%; - right: 6px; + right: var(--bitfun-nav-row-action-offset); transform: translateY(-50%); display: inline-flex; align-items: center; - gap: 4px; + gap: var(--bitfun-nav-row-action-gap); + } + + &__workspace-item-menu { + display: inline-flex; + align-items: center; + gap: var(--bitfun-nav-row-action-gap); transition: opacity $motion-fast $easing-standard, visibility $motion-fast $easing-standard; } @@ -481,8 +855,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: var(--bitfun-nav-row-action-size); + height: var(--bitfun-nav-row-action-size); border: none; border-radius: 6px; background: transparent; @@ -580,22 +954,27 @@ } &__workspace-item-sessions { - overflow: hidden; - max-height: 600px; - transition: max-height $motion-base $easing-standard, - opacity $motion-fast $easing-standard; + position: relative; + z-index: 0; + min-width: 0; + max-width: 100%; + overflow-x: clip; + transition: opacity $motion-fast $easing-standard; opacity: 1; &.is-collapsed { max-height: 0; opacity: 0; + overflow: hidden; pointer-events: none; } .bitfun-nav-panel__inline-list { - margin-left: 12px; + margin-left: 8px; margin-right: 0; padding-top: 0; + padding-left: 2px; + padding-right: 0; } .bitfun-nav-panel__inline-empty { @@ -607,6 +986,9 @@ border-radius: $size-radius-base; display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; gap: 0; &.is-drag-active { @@ -637,7 +1019,13 @@ position: relative; display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; gap: 2px; + min-width: 0; + max-width: 100%; padding: $size-gap-1; border-radius: $size-radius-base; background: transparent; @@ -649,7 +1037,7 @@ opacity: 0.42; .bitfun-nav-panel__assistant-item-card { - cursor: grabbing; + cursor: default; } } @@ -658,7 +1046,7 @@ border-color: transparent; } - &:not(.is-active):not(:hover):not(:focus-within):not(.is-menu-open) { + &:not(:hover):not(:focus-within):not(.is-menu-open) { .bitfun-nav-panel__assistant-item-menu { opacity: 0; visibility: hidden; @@ -673,15 +1061,15 @@ display: flex; align-items: center; width: 100%; + min-width: 0; + max-width: 100%; border-radius: 6px; color: var(--color-text-secondary); - background: transparent; + background: var(--color-bg-primary); + overflow: hidden; transition: color $motion-fast $easing-standard, - background $motion-fast $easing-standard; - - &[draggable='true'] { - cursor: grab; - } + background $motion-fast $easing-standard, + box-shadow $motion-fast $easing-standard; &:hover { .bitfun-nav-panel__assistant-item-avatar-letter { @@ -701,7 +1089,6 @@ &:hover { color: var(--color-text-secondary); - background: transparent; } } @@ -794,22 +1181,36 @@ } &__assistant-item-name-btn { - flex: 1; + flex: 1 1 0; min-width: 0; display: flex; align-items: center; gap: 6px; min-height: 44px; - padding: 0 58px 0 6px; + padding: 0 6px; border: none; border-radius: 0 6px 6px 0; background: transparent; color: inherit; cursor: pointer; + overflow: hidden; text-align: left; } + &__assistant-item:hover &__assistant-item-name-btn, + &__assistant-item.is-menu-open &__assistant-item-name-btn, + &__assistant-item:focus-within &__assistant-item-name-btn { + padding-right: calc( + var(--bitfun-nav-row-action-size) + + var(--bitfun-nav-row-action-size) + + var(--bitfun-nav-row-action-gap) + + var(--bitfun-nav-row-action-offset) + + 4px + ); + } + &__assistant-item-label { + flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -845,11 +1246,11 @@ &__assistant-item-menu { position: absolute; top: 50%; - right: 6px; + right: var(--bitfun-nav-row-action-offset); transform: translateY(-50%); display: inline-flex; align-items: center; - gap: 4px; + gap: var(--bitfun-nav-row-action-gap); transition: opacity $motion-fast $easing-standard, visibility $motion-fast $easing-standard; } @@ -858,8 +1259,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; + width: var(--bitfun-nav-row-action-size); + height: var(--bitfun-nav-row-action-size); border: none; border-radius: 6px; background: transparent; @@ -876,22 +1277,27 @@ } &__assistant-item-sessions { - overflow: hidden; - max-height: 600px; - transition: max-height $motion-base $easing-standard, - opacity $motion-fast $easing-standard; + position: relative; + z-index: 0; + min-width: 0; + max-width: 100%; + overflow-x: clip; + transition: opacity $motion-fast $easing-standard; opacity: 1; &.is-collapsed { max-height: 0; opacity: 0; + overflow: hidden; pointer-events: none; } .bitfun-nav-panel__inline-list { - margin-left: 12px; + margin-left: 8px; margin-right: 0; padding-top: 0; + padding-left: 2px; + padding-right: 0; } .bitfun-nav-panel__inline-empty { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts new file mode 100644 index 000000000..5534c5288 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceListSectionLayout.test.ts @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readWorkspaceListStylesheet(): string { + const stylesheet = readFileSync( + fileURLToPath(new URL('./WorkspaceListSection.scss', import.meta.url)), + 'utf8', + ); + return stylesheet.replace(/\r\n/g, '\n'); +} + +function extractBlock(stylesheet: string, selector: string): string { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = stylesheet.match(new RegExp(`${escapedSelector}\\s*\\{(?[\\s\\S]*?)\\n\\s*\\}`)); + return match?.groups?.body ?? ''; +} + +describe('WorkspaceListSection layout styles', () => { + it('keeps workspace rows constrained while only visible row actions reserve title space', () => { + const stylesheet = readWorkspaceListStylesheet(); + const workspaceList = extractBlock(stylesheet, '&__workspace-list'); + const workspaceGroup = extractBlock(stylesheet, '&__workspace-group'); + const workspaceItem = extractBlock(stylesheet, '&__workspace-item'); + const workspaceCard = extractBlock(stylesheet, '&__workspace-item-card'); + const workspaceNameButton = extractBlock(stylesheet, '&__workspace-item-name-btn'); + const workspaceTitle = extractBlock(stylesheet, '&__workspace-item-title'); + const workspaceLabel = extractBlock(stylesheet, '&__workspace-item-label'); + const workspaceActions = extractBlock(stylesheet, '&__workspace-item-actions'); + const workspaceMenu = extractBlock(stylesheet, '&__workspace-item-menu'); + const assistantItem = extractBlock(stylesheet, '&__assistant-item'); + const assistantCard = extractBlock(stylesheet, '&__assistant-item-card'); + const assistantNameButton = extractBlock(stylesheet, '&__assistant-item-name-btn'); + const assistantLabel = extractBlock(stylesheet, '&__assistant-item-label'); + const assistantMenu = extractBlock(stylesheet, '&__assistant-item-menu'); + + expect(workspaceList).toContain('min-width: 0;'); + expect(workspaceList).toContain('max-width: 100%;'); + expect(workspaceGroup).toContain('min-width: 0;'); + expect(workspaceItem).toContain('min-width: 0;'); + expect(workspaceItem).toContain('max-width: 100%;'); + expect(workspaceCard).toContain('max-width: 100%;'); + expect(workspaceCard).toContain('overflow: hidden;'); + expect(workspaceNameButton).toContain('flex: 0 1 auto;'); + expect(workspaceNameButton).toContain('overflow: hidden;'); + expect(workspaceNameButton).not.toContain('58px'); + expect(stylesheet).toContain('var(--bitfun-nav-row-action-size) +\n var(--bitfun-nav-row-action-size)'); + expect(stylesheet).toContain('&__workspace-item:hover &__workspace-item-name-stack'); + expect(stylesheet).toContain('&__workspace-item.is-menu-open &__workspace-item-name-stack'); + expect(stylesheet).not.toContain('&__workspace-item.is-active &__workspace-item-name-btn'); + expect(stylesheet).toContain('&:not(:hover):not(:focus-within):not(.is-menu-open)'); + expect(workspaceTitle).toContain('flex: 1 1 0;'); + expect(workspaceTitle).toContain('max-width: 100%;'); + expect(workspaceLabel).toContain('flex: 1 1 0;'); + expect(workspaceLabel).toContain('text-overflow: ellipsis;'); + expect(workspaceActions).toContain('position: absolute;'); + expect(workspaceActions).toContain('right: var(--bitfun-nav-row-action-offset);'); + expect(workspaceActions).toContain('gap: var(--bitfun-nav-row-action-gap);'); + expect(workspaceMenu).toContain('gap: var(--bitfun-nav-row-action-gap);'); + + expect(assistantItem).toContain('min-width: 0;'); + expect(assistantItem).toContain('max-width: 100%;'); + expect(assistantCard).toContain('max-width: 100%;'); + expect(assistantCard).toContain('overflow: hidden;'); + expect(assistantNameButton).toContain('flex: 1 1 0;'); + expect(assistantNameButton).toContain('overflow: hidden;'); + expect(assistantNameButton).not.toContain('58px'); + expect(stylesheet).toContain('&__assistant-item:hover &__assistant-item-name-btn'); + expect(stylesheet).toContain('&__assistant-item.is-menu-open &__assistant-item-name-btn'); + expect(stylesheet).not.toContain('&__assistant-item.is-active &__assistant-item-name-btn'); + expect(assistantLabel).toContain('flex: 1 1 0;'); + expect(assistantLabel).toContain('text-overflow: ellipsis;'); + expect(assistantMenu).toContain('position: absolute;'); + expect(assistantMenu).toContain('right: var(--bitfun-nav-row-action-offset);'); + expect(assistantMenu).toContain('gap: var(--bitfun-nav-row-action-gap);'); + expect(stylesheet).toContain('.bitfun-nav-panel__inline-list {\n margin-left: 8px;'); + expect(stylesheet).toContain('padding-left: 2px;'); + expect(stylesheet).toContain('padding-right: 0;'); + }); + + it('uses the shared nav row-action size for workspace and assistant menu triggers', () => { + const stylesheet = readWorkspaceListStylesheet(); + const workspaceTrigger = extractBlock(stylesheet, '&__workspace-item-menu-trigger'); + const assistantTrigger = extractBlock(stylesheet, '&__assistant-item-menu-trigger'); + + for (const block of [workspaceTrigger, assistantTrigger]) { + expect(block).toContain('width: var(--bitfun-nav-row-action-size);'); + expect(block).toContain('height: var(--bitfun-nav-row-action-size);'); + } + }); +}); diff --git a/src/web-ui/src/app/components/panels/FilesPanel.scss b/src/web-ui/src/app/components/panels/FilesPanel.scss index eebc6b01f..755bbb786 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.scss +++ b/src/web-ui/src/app/components/panels/FilesPanel.scss @@ -91,6 +91,62 @@ min-width: 0; } + &__search-index { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border: 1px solid $border-base; + border-radius: $size-radius-base; + background: + linear-gradient(180deg, color-mix(in srgb, var(--element-bg-subtle) 84%, transparent), transparent), + var(--color-bg-secondary); + } + + &__search-index-main { + display: flex; + flex-direction: column; + gap: $size-gap-1; + min-width: 0; + flex: 1 1 auto; + } + + &__search-index-badges { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + min-width: 0; + } + + &__search-index-summary { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: 1.4; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__search-index-error-text { + color: var(--color-error-600); + } + + &__search-index-actions { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-shrink: 0; + } + &__search-content { display: flex; flex-direction: column; @@ -107,6 +163,29 @@ overflow: hidden; } + &__search-backend { + display: flex; + flex-direction: column; + gap: $size-gap-1; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid $border-base; + background: color-mix(in srgb, var(--element-bg-subtle) 82%, var(--color-bg-secondary) 18%); + flex-shrink: 0; + } + + &__search-backend-badges { + display: flex; + align-items: center; + gap: $size-gap-1; + flex-wrap: wrap; + } + + &__search-backend-summary { + font-size: var(--font-size-xs); + color: var(--color-text-secondary); + line-height: 1.4; + } + &__search-status { display: flex; align-items: center; @@ -129,6 +208,11 @@ flex-shrink: 0; } + &__search-limit-notice--warning { + background: color-mix(in srgb, var(--element-bg-subtle) 64%, var(--color-warning-100) 36%); + color: var(--color-warning-700); + } + &__search-spinner { animation: bitfun-files-panel-spin 0.8s linear infinite; flex-shrink: 0; @@ -207,6 +291,22 @@ } } + @container files-panel (max-width: 340px) { + .bitfun-files-panel__search-index { + flex-direction: column; + align-items: stretch; + } + + .bitfun-files-panel__search-index-actions { + justify-content: space-between; + } + + .bitfun-files-panel__search-toolbar { + align-items: flex-start; + flex-direction: column; + } + } + // ==================== Panel Content ==================== &__content { flex: 1; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index f7eaa8a60..e1f0e3e43 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -13,7 +13,7 @@ import { type FileExplorerToolbarHandlers, } from '@/tools/file-system'; import { useExplorerSearch } from '@/tools/file-explorer'; -import { Search, IconButton, Tooltip } from '@/component-library'; +import { Search, IconButton, Tooltip, Badge } from '@/component-library'; import { FileSearchResults } from '@/tools/file-system/components/FileSearchResults'; import { workspaceAPI } from '@/infrastructure/api'; import type { FileSystemNode } from '@/tools/file-system/types'; @@ -33,6 +33,10 @@ import { import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { isRemoteWorkspace } from '@/shared/types'; +import type { + SearchMetadata, + WorkspaceSearchRepoPhase, +} from '@/infrastructure/api/service-api/tauri-commands'; import { downloadWorkspaceFileToDisk, isDragPositionOverElement, @@ -47,6 +51,40 @@ const log = createLogger('FilesPanel'); const FOCUS_REFRESH_THROTTLE_MS = 1000; const REMOTE_REFRESH_POLL_MS = 15000; +function getIndexPhaseBadgeVariant(phase?: WorkspaceSearchRepoPhase): 'neutral' | 'warning' | 'success' | 'error' | 'info' { + switch (phase) { + case 'ready': + return 'success'; + case 'tracking_changes': + return 'info'; + case 'needs_index': + return 'warning'; + case 'building': + case 'refreshing': + case 'preparing': + return 'info'; + case 'limited': + return 'error'; + default: + return 'neutral'; + } +} + +function getSearchBackendBadgeVariant( + metadata: SearchMetadata | null +): 'neutral' | 'success' | 'warning' | 'info' { + switch (metadata?.backend) { + case 'indexed': + case 'indexed_workspace': + return 'success'; + case 'text_fallback': + case 'scan_fallback': + return 'warning'; + default: + return 'neutral'; + } +} + interface FilesPanelProps { workspacePath?: string; onFileSelect?: (filePath: string, fileName: string) => void; @@ -82,7 +120,6 @@ const FilesPanel: React.FC = ({ && pathsEquivalentFs(currentWorkspace.rootPath, workspacePath) && isRemoteWorkspace(currentWorkspace) ); - const { query: searchQuery, setQuery: setSearchQuery, @@ -95,6 +132,7 @@ const FilesPanel: React.FC = ({ contentLimit, filenameTruncated, contentTruncated, + contentSearchMetadata, searchOptions, setSearchOptions, clearSearch, @@ -131,6 +169,13 @@ const FilesPanel: React.FC = ({ : filenameTruncated ? t('search.limitReachedFiles', { count: filenameLimit }) : null; + const contentSearchBackendLabel = contentSearchMetadata + ? t(`search.backend.${contentSearchMetadata.backend}`, { + defaultValue: contentSearchMetadata.backend, + }) + : null; + const showContentSearchMetadata = + searchMode === 'content' && Boolean(searchQuery.trim()) && Boolean(contentSearchMetadata); const { fileTree, @@ -891,7 +936,34 @@ const FilesPanel: React.FC = ({ {searchLimitNotice}
)} - + + {showContentSearchMetadata && contentSearchMetadata && ( +
+
+ + {contentSearchBackendLabel} + + + {t(`search.index.phase.${contentSearchMetadata.repoPhase}`, { + defaultValue: contentSearchMetadata.repoPhase, + })} + + {contentSearchMetadata.rebuildRecommended ? ( + + {t('search.index.badges.rebuildRecommended')} + + ) : null} +
+
+ {t('search.backendSummary', { + candidateDocs: contentSearchMetadata.candidateDocs, + matchedLines: contentSearchMetadata.matchedLines, + matchedOccurrences: contentSearchMetadata.matchedOccurrences, + })} +
+
+ )} + {searchError && (

❌ {searchError}

diff --git a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx index b5296bcad..0bc15f5d6 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/ContentCanvas.tsx @@ -12,6 +12,7 @@ import { useCanvasStore } from './stores'; import { useTabLifecycle, useKeyboardShortcuts, usePanelTabCoordinator } from './hooks'; import type { AnchorPosition } from './types'; import { openMainSession, selectActiveBtwSessionTab } from '@/flow_chat/services/openBtwSession'; +import { isSamePath } from '@/shared/utils/pathUtils'; import './ContentCanvas.scss'; export interface ContentCanvasProps { /** Workspace path */ @@ -69,9 +70,17 @@ export const ContentCanvas: React.FC = ({ return; } + // Only sync when the BTW session belongs to the current workspace, + // preventing the wrong session from opening when switching workspaces. + const btwWorkspacePath = activeBtwSessionData.workspacePath; + if (workspacePath && btwWorkspacePath && !isSamePath(workspacePath, btwWorkspacePath)) { + lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id; + return; + } + lastSyncedBtwTabIdRef.current = activeBtwSessionTab.id; void openMainSession(activeBtwSessionData.parentSessionId); - }, [activeBtwSessionData?.parentSessionId, activeBtwSessionTab?.id, mode]); + }, [activeBtwSessionData?.parentSessionId, activeBtwSessionData?.workspacePath, activeBtwSessionTab?.id, mode, workspacePath]); // Check if primary group has visible tabs const hasPrimaryVisibleTabs = useMemo(() => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts index ff983e002..9a15fbc6d 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,7 @@ import { useCallback } from 'react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; +import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService'; import { useCanvasStore } from '../stores'; import type { EditorGroupId } from '../types'; @@ -41,6 +42,16 @@ export const useKeyboardShortcuts = (options: UseKeyboardShortcutsOptions = {}) return getActiveGroup().tabs.filter((t) => !t.isHidden); }, [getActiveGroup]); + // Find in file (Monaco) — only when `data-shortcut-scope="editor"` is innermost + useShortcut( + 'editor.findInFile', + { key: 'f', ctrl: true, scope: 'editor', allowInInput: true }, + () => { + activeEditTargetService.openMonacoFind(); + }, + { enabled, priority: 20, description: 'keyboard.shortcuts.editor.findInFile' } + ); + // Mission control useShortcut( 'canvas.missionControl', diff --git a/src/web-ui/src/app/layout/AppLayout.scss b/src/web-ui/src/app/layout/AppLayout.scss index 841ef3b5c..84b0f79f2 100644 --- a/src/web-ui/src/app/layout/AppLayout.scss +++ b/src/web-ui/src/app/layout/AppLayout.scss @@ -118,6 +118,59 @@ html, body { background: var(--color-bg-flowchat); } +.bitfun-app-acp-session-loading { + position: absolute; + left: 50%; + bottom: $size-gap-5; + z-index: 10020; + display: inline-flex; + align-items: center; + gap: $size-gap-2; + max-width: min(360px, calc(100vw - 32px)); + min-height: 36px; + padding: 0 $size-gap-3; + border: 1px solid var(--border-subtle); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); + color: var(--color-text-primary); + font-size: var(--font-size-sm); + line-height: 1.35; + transform: translateX(-50%); + pointer-events: none; + animation: bitfun-acp-session-loading-in $motion-fast $easing-decelerate forwards; + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__spinner { + flex-shrink: 0; + color: var(--color-accent-500); + animation: bitfun-acp-session-spinner 0.9s linear infinite; + } +} + +@keyframes bitfun-acp-session-loading-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(6px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes bitfun-acp-session-spinner { + to { + transform: rotate(360deg); + } +} + // ==================== Scrollbar styles ==================== // Moved to global scrollbar.css for shared management. diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 8562105db..3d99fc565 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -9,6 +9,7 @@ */ import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from 'react'; +import { LoaderCircle } from 'lucide-react'; import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap'; @@ -43,8 +44,14 @@ interface AppLayoutProps { className?: string; } +interface AcpSessionCreationEventDetail { + phase?: 'start' | 'finish'; + clientId?: string; +} + const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); + const { t: tCommon } = useI18n('common'); const { currentWorkspace, hasWorkspace, @@ -115,6 +122,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const [showNewProjectDialog, setShowNewProjectDialog] = useState(false); const [showAboutDialog, setShowAboutDialog] = useState(false); const [showWorkspaceStatus, setShowWorkspaceStatus] = useState(false); + const [pendingAcpSessionClients, setPendingAcpSessionClients] = useState([]); const handleOpenProject = useCallback(async () => { try { const selected = await workspaceAPI.open_oh_file_dialog(); @@ -413,6 +421,36 @@ const AppLayout: React.FC = ({ className = '' }) => { return () => window.removeEventListener('toolbar-create-session', handler); }, [handleCreateFlowChatSession]); + React.useEffect(() => { + const handler = (e: Event) => { + const clientId = (e as CustomEvent<{ clientId?: string }>).detail?.clientId?.trim(); + if (!clientId) return; + void FlowChatManager.getInstance() + .createAcpChatSession(clientId) + .catch(error => log.error('Failed to create ACP FlowChat session', error)); + }; + window.addEventListener('bitfun:create-acp-session', handler); + return () => window.removeEventListener('bitfun:create-acp-session', handler); + }, []); + + React.useEffect(() => { + const handler = (event: Event) => { + const detail = (event as CustomEvent).detail; + const clientId = detail?.clientId?.trim() || 'ACP'; + if (detail?.phase === 'start') { + setPendingAcpSessionClients(prev => [...prev, clientId]); + } else if (detail?.phase === 'finish') { + setPendingAcpSessionClients(prev => { + const index = prev.indexOf(clientId); + if (index === -1) return prev; + return prev.filter((_, currentIndex) => currentIndex !== index); + }); + } + }; + window.addEventListener('bitfun:acp-session-creation', handler); + return () => window.removeEventListener('bitfun:acp-session-creation', handler); + }, []); + // Global drag-and-drop React.useEffect(() => { const handleDragStart = (e: DragEvent) => { @@ -464,6 +502,16 @@ const AppLayout: React.FC = ({ className = '' }) => { {/* Non-agent scenes: floating mini chat button */} {!isWelcomeScene && !isAgentScene && } + {pendingAcpSessionClients.length > 0 && ( +
+ + + {tCommon('nav.workspaces.creatingAcpSession', { + agentName: pendingAcpSessionClients[pendingAcpSessionClients.length - 1], + })} + +
+ )}
{/* Dialogs (previously owned by TitleBar) */} diff --git a/src/web-ui/src/app/layout/WorkspaceBody.scss b/src/web-ui/src/app/layout/WorkspaceBody.scss index fa5a486e1..c8a5e7139 100644 --- a/src/web-ui/src/app/layout/WorkspaceBody.scss +++ b/src/web-ui/src/app/layout/WorkspaceBody.scss @@ -36,13 +36,14 @@ $_nav-collapsed-width: 80px; width: var(--nav-width, $_nav-width); flex-shrink: 0; height: 100%; - overflow: hidden; + overflow: visible; position: relative; z-index: 3; transition: width $motion-base $easing-standard; &.is-collapsed { width: 0; + overflow: hidden; pointer-events: none; } } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index ebc086323..ce0da1ed1 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -35,7 +35,7 @@ import { import { useAgentsList } from './hooks/useAgentsList'; import { AGENT_ICON_MAP, CAPABILITY_ACCENT } from './agentsIcons'; import { getCardGradient } from '@/shared/utils/cardGradients'; -import { getAgentBadge, getCapabilityLabel } from './utils'; +import { getAgentBadge, getAgentDescription, getCapabilityLabel } from './utils'; import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; @@ -540,28 +540,15 @@ const AgentsHomeView: React.FC = () => { })} subtitle={t('reviewTeams.default.summary', { defaultValue: - 'A deep-review code team with locked logic, performance, security, and quality-gate roles.', + 'A deep-review code team with locked logic, performance, security, architecture, and quality-gate roles.', })} - localOnlyLabel={t('reviewTeams.detail.localOnly', { + roleName={t('reviewTeams.detail.localOnly', { defaultValue: 'Code review', })} - qualityGateLabel={t('reviewTeams.detail.qualityGate', { - defaultValue: 'Quality gate', - })} - membersLabel={t('reviewTeams.default.members', { - count: reviewTeam.members.length, - defaultValue: `${reviewTeam.members.length} members`, - })} - openLabel={t('reviewTeams.detail.open', { - defaultValue: 'Open team', - })} - memberNames={reviewTeam.coreMembers.map((member) => - member.definitionKey - ? t(`reviewTeams.members.${member.definitionKey}.role`, { - defaultValue: member.roleName, - }) - : member.displayName, - )} + tagNames={t('reviewTeams.default.tags', { + returnObjects: true, + defaultValue: ['Quality', 'Performance', 'Architecture'], + }) as string[]} onOpen={openReviewTeam} /> @@ -684,7 +671,9 @@ const AgentsHomeView: React.FC = () => { {selectedAgent.model ? {selectedAgent.model} : null} ) : null} - description={selectedAgent?.description} + description={selectedAgent + ? getAgentDescription(t, selectedAgent) + : undefined} meta={selectedAgent ? ( <> {t('agentCard.meta.tools', { count: selectedAgentToolCount })} diff --git a/src/web-ui/src/app/scenes/agents/agentVisibility.ts b/src/web-ui/src/app/scenes/agents/agentVisibility.ts index 6844a5e63..65da93526 100644 --- a/src/web-ui/src/app/scenes/agents/agentVisibility.ts +++ b/src/web-ui/src/app/scenes/agents/agentVisibility.ts @@ -5,6 +5,8 @@ export const HIDDEN_AGENT_IDS = new Set([ 'ReviewBusinessLogic', 'ReviewPerformance', 'ReviewSecurity', + 'ReviewArchitecture', + 'ReviewFrontend', 'ReviewJudge', ]); diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx index c22ddbb9a..f19a943db 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx @@ -10,7 +10,7 @@ import { Badge, Switch } from '@/component-library'; import type { AgentWithCapabilities } from '../agentsStore'; import { AGENT_ICON_MAP, CAPABILITY_ACCENT } from '../agentsIcons'; import { getCardGradient } from '@/shared/utils/cardGradients'; -import { getAgentBadge, getCapabilityLabel } from '../utils'; +import { getAgentBadge, getAgentDescription, getCapabilityLabel } from '../utils'; import './AgentCard.scss'; interface AgentCardProps { @@ -82,7 +82,9 @@ const AgentCard: React.FC = ({ {/* Body: description + meta */}
-

{agent.description?.trim() || '—'}

+

+ {getAgentDescription(t, agent)} +

diff --git a/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.scss b/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.scss index 13f96afbc..291c6acb6 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.scss +++ b/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.scss @@ -40,7 +40,7 @@ &__header-copy { display: flex; flex-direction: column; - gap: 6px; + gap: 4px; min-width: 0; flex: 1; overflow: hidden; @@ -65,86 +65,44 @@ text-overflow: ellipsis; } - &__badges { + &__role { display: inline-flex; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-end; - flex-shrink: 0; - } - - &__subtitle { - margin: 0; - font-size: 0.85em; - line-height: $line-height-relaxed; - color: var(--color-text-secondary); - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; + align-items: center; + gap: 4px; + font-size: var(--font-size-xs); + font-weight: $font-weight-medium; + color: var(--team-card-accent); + opacity: 0.9; } &__body { display: flex; flex-direction: column; - gap: $size-gap-2; + justify-content: flex-start; flex: 1; - min-height: 0; padding: $size-gap-2 $size-gap-3; - overflow: hidden; position: relative; z-index: 1; } - &__meta { - display: grid; - gap: 4px; - } - - &__meta-item { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: var(--font-size-xs); - line-height: 1.5; - color: var(--color-text-muted); - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - svg { - flex-shrink: 0; - } - } - - &__chips { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - - &__chip { - display: inline-flex; - align-items: center; - min-height: 22px; - padding: 0 8px; - border-radius: 999px; - border: 1px solid color-mix(in srgb, var(--team-card-accent) 20%, var(--border-subtle)); - background: color-mix(in srgb, var(--element-bg-soft) 84%, transparent); + &__desc { + margin: 0; + font-size: 0.85em; + font-weight: 300; color: var(--color-text-secondary); - font-size: 10px; - line-height: 1.4; - max-width: 100%; + line-height: $line-height-relaxed; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + word-break: break-word; } &__footer { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; + gap: $size-gap-2; padding: $size-gap-2 $size-gap-3; @include surface.agent-surface-footer(var(--team-card-gradient)); } @@ -153,13 +111,35 @@ opacity: 1; } - &__open { - font-size: var(--font-size-xs); - font-weight: $font-weight-semibold; - color: color-mix(in srgb, var(--team-card-accent) 76%, var(--color-text-primary)); + &__tags { + display: inline-flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; position: relative; z-index: 1; } + + &__tag-chip { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $size-radius-full; + border: 1px solid; + background: rgba(255, 255, 255, 0.04); + font-size: 10px; + font-weight: $font-weight-medium; + white-space: nowrap; + } +} + +// Shared with ReviewTeamPage for summary metric chips (BEM class reuse). +.agent-team-card__metrics { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; } @media (max-width: 720px) { diff --git a/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.tsx b/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.tsx index 2037e7f9d..4f3672aad 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentTeamCard.tsx @@ -1,29 +1,28 @@ import React from 'react'; -import { ShieldCheck, Users } from 'lucide-react'; -import { Badge } from '@/component-library'; +import { ShieldCheck, Sparkles } from 'lucide-react'; import './AgentTeamCard.scss'; interface AgentTeamCardProps { index?: number; title: string; subtitle: string; - localOnlyLabel: string; - qualityGateLabel: string; - membersLabel: string; - openLabel: string; - memberNames: string[]; + roleName: string; + tagNames: string[]; onOpen: () => void; } +const TAG_COLORS = [ + { color: '#f59e0b', border: '#f59e0b44' }, + { color: '#14b8a6', border: '#14b8a644' }, + { color: '#6366f1', border: '#6366f144' }, +]; + const AgentTeamCard: React.FC = ({ index = 0, title, subtitle, - localOnlyLabel, - qualityGateLabel, - membersLabel, - openLabel, - memberNames, + roleName, + tagNames, onOpen, }) => { return ( @@ -47,35 +46,34 @@ const AgentTeamCard: React.FC = ({
{title} -
- {localOnlyLabel} - {qualityGateLabel} -
-

{subtitle}

+ + + {roleName} +
-
- - - {membersLabel} - -
+

{subtitle}

+
-
- {memberNames.map((memberName) => ( - - {memberName} +
+
+ {tagNames.slice(0, 3).map((name, i) => ( + + {name} ))}
- -
- {openLabel} -
); }; diff --git a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx index 70c6de083..2f3f87216 100644 --- a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx @@ -9,6 +9,7 @@ import { import { useTranslation } from 'react-i18next'; import type { AgentWithCapabilities } from '../agentsStore'; import { AGENT_ICON_MAP } from '../agentsIcons'; +import { getAgentDescription } from '../utils'; import './CoreAgentCard.scss'; export interface CoreAgentMeta { @@ -72,7 +73,7 @@ const CoreAgentCard: React.FC = ({

- {agent.description?.trim() || '—'} + {getAgentDescription(t, agent)}

diff --git a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.scss b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.scss index dd4fbdb06..6998c5421 100644 --- a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.scss +++ b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.scss @@ -1,8 +1,19 @@ @use '../../../../component-library/styles/tokens' as *; .review-team-page { + --config-page-content-max-width: 1480px; + --config-page-content-inline-padding: clamp(40px, 6vw, 80px); + /* Aligns overview + member rows with `AgentTeamCard` / gallery team cards */ + --review-team-accent: var(--color-accent-400, #0ea5e9); + .bitfun-config-page-content__inner { - max-width: 1120px; + max-width: 1480px; + } + + &__agent-team-metrics-wrap { + margin-bottom: $size-gap-3; + width: 100%; + min-width: 0; } &__summary-grid { @@ -14,12 +25,45 @@ &__summary-card { display: flex; flex-direction: column; - gap: 8px; + gap: 10px; min-height: 118px; - padding: 16px; - border: 1px solid var(--border-subtle); - border-radius: 8px; - background: var(--color-surface); + padding: 12px 14px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--review-team-accent) 16%, var(--border-subtle)); + background: + linear-gradient(180deg, color-mix(in srgb, var(--review-team-accent) 8%, transparent), transparent), + color-mix(in srgb, var(--element-bg-soft) 86%, transparent); + } + + &__summary-card--primary { + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--review-team-accent) 22%, transparent), transparent 62%), + color-mix(in srgb, var(--review-team-accent) 9%, var(--element-bg-soft)); + } + + &__summary-card-head { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + + &__summary-card-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + flex-shrink: 0; + color: color-mix(in srgb, var(--review-team-accent) 78%, var(--color-text-primary)); + background: color-mix(in srgb, var(--review-team-accent) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--review-team-accent) 24%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); + + svg { + flex-shrink: 0; + } } &__header-actions, @@ -32,7 +76,7 @@ &__policy-panel { display: grid; - grid-template-columns: minmax(220px, 1fr) minmax(360px, 1.25fr) auto; + grid-template-columns: minmax(220px, 1fr) minmax(360px, 1.25fr); align-items: center; gap: $size-gap-4; width: 100%; @@ -43,15 +87,16 @@ color: var(--color-text-primary); text-align: left; cursor: pointer; - transition: - background $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; + transition: background $motion-fast $easing-standard; + + &:hover { + background: color-mix(in srgb, var(--element-bg-medium) 68%, transparent); + } - &:hover, &:focus-visible { background: color-mix(in srgb, var(--element-bg-medium) 68%, transparent); - box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-accent-500) 74%, transparent); - outline: none; + outline: 2px solid color-mix(in srgb, var(--color-accent-500) 45%, var(--border-medium)); + outline-offset: 2px; } } @@ -79,7 +124,7 @@ &__policy-metrics { display: grid; - grid-template-columns: repeat(auto-fit, minmax(86px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; min-width: 0; } @@ -91,9 +136,11 @@ min-width: 0; min-height: 58px; padding: 9px 10px; - border: 1px solid var(--border-subtle); - border-radius: 8px; - background: color-mix(in srgb, var(--element-bg-soft) 74%, transparent); + border: 1px solid color-mix(in srgb, var(--review-team-accent) 16%, var(--border-subtle)); + border-radius: 12px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--review-team-accent) 8%, transparent), transparent), + color-mix(in srgb, var(--element-bg-soft) 86%, transparent); span { overflow: hidden; @@ -121,20 +168,6 @@ overflow: visible; } - &__policy-action { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-width: max-content; - padding: 7px 10px; - border-radius: 8px; - background: color-mix(in srgb, var(--color-accent-500) 10%, transparent); - color: var(--color-accent-500); - font-size: var(--font-size-xs); - font-weight: $font-weight-medium; - } - &__error-detail { max-height: 220px; overflow: auto; @@ -147,6 +180,8 @@ } &__summary-kicker { + flex: 1; + min-width: 0; font-size: 11px; font-weight: $font-weight-semibold; text-transform: uppercase; @@ -163,8 +198,9 @@ &__section-badges { display: inline-flex; - gap: 8px; + gap: 6px; flex-wrap: wrap; + justify-content: flex-end; } &__row-control { @@ -283,152 +319,117 @@ color: var(--color-text-muted); } - &__member-grid { + &__member-layout { display: grid; - gap: $size-gap-3; - grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $size-gap-4; + grid-template-columns: 280px 1fr; + align-items: start; } - &__member-card { + &__member-list { display: flex; flex-direction: column; + gap: 6px; + min-width: 0; + } + + &__member-list-item { + display: flex; + align-items: center; + gap: 10px; width: 100%; - border-radius: 8px; - border: 1px solid var(--border-subtle); - background: color-mix(in srgb, var(--element-bg-soft) 78%, transparent); + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--review-team-accent) 16%, var(--border-subtle)); + border-radius: 12px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--member-accent, #64748b) 8%, transparent), transparent), + color-mix(in srgb, var(--element-bg-soft) 86%, transparent); + color: var(--color-text-primary); text-align: left; + cursor: pointer; transition: - transform $motion-fast $easing-standard, + background $motion-fast $easing-standard, border-color $motion-fast $easing-standard, box-shadow $motion-fast $easing-standard; &:hover { - transform: translateY(-1px); border-color: color-mix(in srgb, var(--member-accent, #64748b) 34%, var(--border-medium)); - box-shadow: 0 10px 22px color-mix(in srgb, var(--shadow-color, #0f172a) 10%, transparent); + background: + linear-gradient(180deg, color-mix(in srgb, var(--member-accent, #64748b) 10%, transparent), transparent), + color-mix(in srgb, var(--element-bg-soft) 94%, transparent); } &.is-selected { border-color: color-mix(in srgb, var(--member-accent, #64748b) 54%, var(--color-bg-primary)); - box-shadow: - 0 0 0 1px color-mix(in srgb, var(--member-accent, #64748b) 30%, transparent), - 0 12px 24px color-mix(in srgb, var(--shadow-color, #0f172a) 10%, transparent); + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--member-accent, #64748b) 22%, transparent), transparent 62%), + color-mix(in srgb, var(--member-accent, #64748b) 9%, var(--element-bg-soft)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--member-accent, #64748b) 24%, transparent); } - &.is-expanded { - grid-column: 1 / -1; - } - } - - &__member-card-header { - display: flex; - align-items: flex-start; - gap: $size-gap-3; - width: 100%; - min-height: 148px; - padding: 14px; - border: 0; - border-radius: inherit; - background: transparent; - color: var(--color-text-primary); - text-align: left; - cursor: pointer; - &:focus-visible { - outline: none; - box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--member-accent, #64748b) 50%, transparent); + outline: 2px solid color-mix(in srgb, var(--member-accent, #64748b) 50%, var(--border-medium)); + outline-offset: 2px; } } - &__member-card-icon { + &__member-list-icon { display: inline-flex; align-items: center; justify-content: center; - width: 42px; - height: 42px; - border-radius: 8px; - background: color-mix(in srgb, var(--member-accent, #64748b) 18%, transparent); + width: 32px; + height: 32px; + border-radius: 10px; + background: color-mix(in srgb, var(--member-accent, #64748b) 14%, transparent); color: color-mix(in srgb, var(--member-accent, #64748b) 78%, var(--color-text-primary)); - border: 1px solid color-mix(in srgb, var(--member-accent, #64748b) 22%, transparent); + border: 1px solid color-mix(in srgb, var(--member-accent, #64748b) 24%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); flex-shrink: 0; } - &__member-card-body { + &__member-list-body { display: flex; flex-direction: column; - gap: 8px; + gap: 2px; min-width: 0; flex: 1; } - &__member-card-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: $size-gap-2; - } - - &__member-card-name { - font-size: var(--font-size-base); + &__member-list-name { + font-size: var(--font-size-sm); font-weight: $font-weight-semibold; color: var(--color-text-primary); line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - &__member-card-badges { - display: inline-flex; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-end; - } - - &__member-card-role { - font-size: var(--font-size-xs); - font-weight: $font-weight-medium; - color: color-mix(in srgb, var(--member-accent, #64748b) 72%, var(--color-text-primary)); - } - - &__member-card-description { - margin: 0; - font-size: var(--font-size-sm); - line-height: 1.6; - color: var(--color-text-secondary); - } - - &__member-card-model { - display: inline-flex; - align-self: flex-start; - gap: 6px; - min-height: 24px; - align-items: center; - padding: 0 10px; - border-radius: 999px; - background: rgba(15, 23, 42, 0.06); - color: var(--color-text-muted); + &__member-list-meta { font-size: 11px; - font-weight: $font-weight-medium; - } - - &__member-card-model-note { - color: color-mix(in srgb, #f59e0b 82%, var(--color-text-muted)); + color: var(--color-text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - &__member-card-chevron { + &__member-list-badges { display: inline-flex; - align-items: center; - align-self: flex-start; - color: var(--color-text-muted); - margin-top: 2px; + gap: 6px; + flex-shrink: 0; } - &__member-card-detail { - overflow: hidden; + &__member-detail-panel { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + border: 1px solid color-mix(in srgb, var(--review-team-accent) 16%, var(--border-subtle)); + border-radius: 12px; + background: + linear-gradient(180deg, color-mix(in srgb, var(--member-accent, #64748b) 8%, transparent), transparent), + color-mix(in srgb, var(--element-bg-soft) 86%, transparent); animation: review-team-card-expand $motion-base $easing-standard forwards; - border-top: 1px solid var(--border-subtle); - } - - &__member-card-detail-inner { - padding: 16px; } &__detail-hero { @@ -445,10 +446,11 @@ justify-content: center; width: 44px; height: 44px; - border-radius: 8px; - background: color-mix(in srgb, var(--member-accent, #64748b) 20%, transparent); - color: color-mix(in srgb, var(--member-accent, #64748b) 76%, var(--color-text-primary)); + border-radius: 10px; + background: color-mix(in srgb, var(--member-accent, #64748b) 14%, transparent); + color: color-mix(in srgb, var(--member-accent, #64748b) 78%, var(--color-text-primary)); border: 1px solid color-mix(in srgb, var(--member-accent, #64748b) 24%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, white 8%, transparent); flex-shrink: 0; } @@ -491,6 +493,7 @@ &__detail-description { margin: 0; + padding: 0 0 16px; font-size: var(--font-size-sm); line-height: 1.7; color: var(--color-text-secondary); @@ -569,7 +572,7 @@ @media (max-width: 960px) { .review-team-page { &__summary-grid, - &__member-grid, + &__member-layout, &__execution-grid, &__strategy-options, &__strategy-options--compact { @@ -581,29 +584,25 @@ align-items: stretch; } - &__policy-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - &__detail-hero, &__detail-title-row { flex-direction: column; } + + &__member-detail-panel { + animation: none; + } } } @media (max-width: 720px) { .review-team-page { - &__member-card-top { - flex-direction: column; + &__member-list-badges { + display: none; } &__add-controls { grid-template-columns: 1fr; } - - &__policy-metrics { - grid-template-columns: 1fr; - } } } diff --git a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.test.tsx b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.test.tsx index e2f3a6b3f..3d285c586 100644 --- a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.test.tsx +++ b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.test.tsx @@ -158,15 +158,23 @@ describeWithJsdom('ReviewTeamPage', () => { vi.clearAllMocks(); }); + async function waitForText(text: string, maxTicks = 20) { + for (let i = 0; i < maxTicks; i++) { + await act(async () => { + await Promise.resolve(); + }); + if (container.textContent?.includes(text)) return; + } + throw new Error(`waitForText: "${text}" not found after ${maxTicks} ticks`); + } + it('loads review team data only once on initial render', async () => { const { default: ReviewTeamPage } = await import('./ReviewTeamPage'); await act(async () => { root.render(); }); - await act(async () => { - await Promise.resolve(); - }); + await waitForText('Team Overview'); expect(loadDefaultReviewTeam).toHaveBeenCalledTimes(1); }); @@ -177,9 +185,7 @@ describeWithJsdom('ReviewTeamPage', () => { await act(async () => { root.render(); }); - await act(async () => { - await Promise.resolve(); - }); + await waitForText('Team Overview'); expect(container.textContent).toContain('Team Overview'); expect(container.textContent).toContain('Current Policy'); @@ -200,9 +206,7 @@ describeWithJsdom('ReviewTeamPage', () => { await act(async () => { root.render(); }); - await act(async () => { - await Promise.resolve(); - }); + await waitForText('Team Overview'); const settingsButton = Array.from(container.querySelectorAll('button')) .find((button) => button.textContent?.includes('Review settings')); @@ -227,9 +231,7 @@ describeWithJsdom('ReviewTeamPage', () => { await act(async () => { root.render(); }); - await act(async () => { - await Promise.resolve(); - }); + await waitForText('Team Overview'); const policyPanel = container.querySelector('.review-team-page__policy-panel'); expect(policyPanel).toBeTruthy(); @@ -287,9 +289,7 @@ describeWithJsdom('ReviewTeamPage', () => { await act(async () => { root.render(); }); - await act(async () => { - await Promise.resolve(); - }); + await waitForText('Logic'); const memberButton = Array.from(container.querySelectorAll('button')) .find((button) => button.textContent?.includes('Logic')); diff --git a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx index 37a3c94a8..cc14943a3 100644 --- a/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx @@ -2,13 +2,15 @@ import React, { Component, useCallback, useEffect, useMemo, useState } from 'rea import { ArrowLeft, BadgeCheck, + Blocks, Bot, - ChevronDown, Gauge, GitBranch, + Layout, Lock, Settings, Shield, + Users, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Badge, Button, ConfigPageLoading } from '@/component-library'; @@ -21,7 +23,7 @@ import { import type { AIModelConfig } from '@/infrastructure/config/types'; import { getModelDisplayName } from '@/infrastructure/config/services/modelConfigs'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import type { SubagentSource } from '@/infrastructure/api/service-api/SubagentAPI'; + import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -38,6 +40,7 @@ import { type ReviewTeamMember, } from '@/shared/services/reviewTeamService'; import '../AgentsView.scss'; +import './AgentTeamCard.scss'; import './ReviewTeamPage.scss'; const rtLog = createLogger('ReviewTeamPage'); @@ -51,6 +54,10 @@ function getMemberIcon(member: ReviewTeamMember) { return Gauge; case 'security': return Shield; + case 'architecture': + return Blocks; + case 'frontend': + return Layout; case 'judge': return BadgeCheck; default: @@ -58,12 +65,6 @@ function getMemberIcon(member: ReviewTeamMember) { } } -function getSourceVariant(source?: SubagentSource): 'neutral' | 'info' | 'purple' { - if (source === 'user') return 'info'; - if (source === 'project') return 'purple'; - return 'neutral'; -} - function getMemberResponsibilities(member: ReviewTeamMember): string[] { return Array.isArray(member.responsibilities) ? member.responsibilities : []; } @@ -249,6 +250,28 @@ const ReviewTeamPage: React.FC = () => { return match ? getModelDisplayName(match) : modelId; }, [models, tModel]); + const reviewTeamCoreMemberNames = useMemo( + () => (team?.coreMembers ?? []).map((member) => + member.definitionKey + ? t(`reviewTeams.members.${member.definitionKey}.role`, { + defaultValue: member.roleName, + }) + : member.displayName, + ), + [team?.coreMembers, t], + ); + + const reviewTeamMembersLabel = useMemo( + () => + team + ? t('reviewTeams.default.members', { + count: team.members.length, + defaultValue: `${team.members.length} members`, + }) + : '', + [team, t], + ); + const openReviewSettings = useCallback(() => { setSettingsTab('review'); openScene('settings'); @@ -313,20 +336,36 @@ const ReviewTeamPage: React.FC = () => { description={t('reviewTeams.detail.summaryDescription', { defaultValue: 'The code review team launches reviewers in parallel and finishes with a quality-gate pass.', })} - titleSuffix={( - - {t('reviewTeams.detail.membersCount', { - count: team.members.length, - defaultValue: `${team.members.length} members`, - })} - - )} > -
-
- +
+
+ + + {reviewTeamMembersLabel} + + + {t('reviewTeams.detail.localOnly', { defaultValue: 'Code review' })} - + + + + {t('reviewTeams.detail.qualityGate', { defaultValue: 'Quality gate' })} + +
+
+
+
+
+ + + + + {t('reviewTeams.detail.localOnly', { defaultValue: 'Code review' })} + +

{t('reviewTeams.detail.localOnlyDescription', { defaultValue: 'Reviewers run as BitFun subagents and report through the same review workflow.', @@ -334,9 +373,14 @@ const ReviewTeamPage: React.FC = () => {

- - {t('reviewTeams.detail.parallelLabel', { defaultValue: 'Parallel reviewers' })} - +
+ + + + + {t('reviewTeams.detail.parallelLabel', { defaultValue: 'Parallel reviewers' })} + +

{t('reviewTeams.detail.parallelDescription', { defaultValue: 'Business logic, performance, security, and extra reviewers run concurrently before the judge verifies them.', @@ -344,9 +388,14 @@ const ReviewTeamPage: React.FC = () => {

- - {t('reviewTeams.detail.qualityGate', { defaultValue: 'Quality gate' })} - +
+ + + + + {t('reviewTeams.detail.qualityGate', { defaultValue: 'Quality gate' })} + +

{t('reviewTeams.detail.warning', { defaultValue: team.warning })}

@@ -409,10 +458,7 @@ const ReviewTeamPage: React.FC = () => {
- - - {t('reviewTeams.detail.openSettings', { defaultValue: 'Review settings' })} - + @@ -439,127 +485,102 @@ const ReviewTeamPage: React.FC = () => {
)} > -
- {team.members.map((member) => { - const MemberIcon = getMemberIcon(member); - const isSelected = selectedMember?.id === member.id; - const responsibilities = getLocalizedResponsibilities(member); - - return ( -
+
+
+ {team.members.map((member) => { + const MemberIcon = getMemberIcon(member); + const isSelected = selectedMember?.id === member.id; + + return ( + ); + })} +
- {isSelected ? ( -
-
-
-
- -
-
-
-
-

- {getLocalizedMemberName(member)} -

-

- {member.subagentId} -

-
-
- {formatModelLabel(member.model)} - - {getStrategyLabel(member.strategyLevel)} - - {member.locked ? ( - - {t('reviewTeams.detail.memberTypes.core', { defaultValue: 'Core role' })} - - ) : null} -
-
-

- {getLocalizedMemberDescription(member)} -

-
-
- -
- - {t('reviewTeams.detail.responsibilities', { defaultValue: 'Responsibilities' })} - -
    - {responsibilities.map((item, index) => ( -
  • - {item} -
  • - ))} -
-
+ {selectedMember ? ( +
+
+
+ {(() => { + const DetailIcon = getMemberIcon(selectedMember); + return ; + })()} +
+
+
+
+

+ {getLocalizedMemberName(selectedMember)} +

+

+ {selectedMember.subagentId} +

+
+
+ {formatModelLabel(selectedMember.model)} + + {getStrategyLabel(selectedMember.strategyLevel)} + + {selectedMember.locked ? ( + + {t('reviewTeams.detail.memberTypes.core', { defaultValue: 'Core role' })} + + ) : null}
- ) : null} +
- ); - })} +

+ {getLocalizedMemberDescription(selectedMember)} +

+ +
+ + {t('reviewTeams.detail.responsibilities', { defaultValue: 'Responsibilities' })} + +
    + {getLocalizedResponsibilities(selectedMember).map((item, index) => ( +
  • + {item} +
  • + ))} +
+
+
+ ) : null}
diff --git a/src/web-ui/src/app/scenes/agents/utils.ts b/src/web-ui/src/app/scenes/agents/utils.ts index 760126fa1..6b13ca461 100644 --- a/src/web-ui/src/app/scenes/agents/utils.ts +++ b/src/web-ui/src/app/scenes/agents/utils.ts @@ -2,6 +2,15 @@ import type { TFunction } from 'i18next'; import type { SubagentSource } from '@/infrastructure/api/service-api/SubagentAPI'; import type { AgentKind, AgentWithCapabilities, CapabilityCategory } from './agentsStore'; +const MODE_DESCRIPTION_KEY_BY_ID: Record = { + agentic: 'Agentic', + plan: 'Plan', + debug: 'Debug', + cowork: 'Cowork', + computeruse: 'ComputerUse', + deepresearch: 'DeepResearch', +}; + interface AgentBadgeConfig { variant: 'accent' | 'info' | 'success' | 'purple' | 'neutral'; label: string; @@ -46,6 +55,28 @@ function getAgentBadge( } } +function getAgentDescription( + t: TFunction<'scenes/agents'>, + agent: Pick, +): string { + const fallback = agent.description?.trim() || '—'; + const canonicalModeKey = MODE_DESCRIPTION_KEY_BY_ID[agent.id.toLowerCase()]; + const candidates = Array.from(new Set([ + agent.id, + canonicalModeKey, + agent.name, + ].filter(Boolean))); + + for (const key of candidates) { + const translated = t(`agentDescriptions.${key}`, { defaultValue: '' }).trim(); + if (translated) { + return translated; + } + } + + return fallback; +} + function enrichCapabilities(agent: AgentWithCapabilities): AgentWithCapabilities { if (agent.capabilities?.length) { return { @@ -81,5 +112,5 @@ function enrichCapabilities(agent: AgentWithCapabilities): AgentWithCapabilities return { ...agent, capabilities: [{ category: 'analysis', level: 3 }] }; } -export { getAgentBadge, getCapabilityLabel, enrichCapabilities }; +export { getAgentBadge, getCapabilityLabel, getAgentDescription, enrichCapabilities }; export type { AgentBadgeConfig }; diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 7b510e4ca..fb3d69451 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -5,7 +5,7 @@ * Renamed from panels/CenterPanel. All logic preserved. */ -import React, { useCallback, memo } from 'react'; +import React, { useCallback, memo, useEffect, useRef } from 'react'; import { FlowChatContainer, ChatInput } from '../../../flow_chat'; import { useCanvasStore } from '../../components/panels/content-canvas/stores/canvasStore'; import type { LineRange } from '@/component-library'; @@ -16,6 +16,10 @@ import { hasNonFileUriScheme } from '@/shared/utils/pathUtils'; import './ChatPane.scss'; const log = createLogger('ChatPane'); +const TASK_DETAIL_PANEL_EXPAND_DEFER_MS = 520; +const TASK_DETAIL_IDLE_TIMEOUT_MS = 300; + +const preloadTaskDetailPanel = () => import('@/flow_chat/components/TaskDetailPanel'); interface ChatPaneProps { width: number; @@ -33,6 +37,8 @@ const ChatPaneInner: React.FC = ({ showChatInput = false, }) => { const addTab = useCanvasStore(state => state.addTab); + const deferredTaskDetailTimersRef = useRef([]); + const deferredTaskDetailIdleCallbacksRef = useRef([]); const handleFileViewRequest = useCallback(async ( filePath: string, @@ -68,6 +74,68 @@ const ChatPaneInner: React.FC = ({ }); }, [workspacePath]); + useEffect(() => { + return () => { + deferredTaskDetailTimersRef.current.forEach(timerId => window.clearTimeout(timerId)); + deferredTaskDetailTimersRef.current = []; + if ('cancelIdleCallback' in window) { + deferredTaskDetailIdleCallbacksRef.current.forEach(id => { + window.cancelIdleCallback(id); + }); + } + deferredTaskDetailIdleCallbacksRef.current = []; + }; + }, []); + + const addPanelTab = useCallback((tabInfo: any) => { + addTab({ + type: tabInfo.type, + title: tabInfo.title || 'New Tab', + data: tabInfo.data, + metadata: tabInfo.metadata + }); + }, [addTab]); + + const handleTabOpen = useCallback((tabInfo: any) => { + log.info('Opening tab', { tabInfo }); + if (!tabInfo || !tabInfo.type) { + return; + } + + if (tabInfo.type !== 'task-detail') { + addPanelTab(tabInfo); + return; + } + + void preloadTaskDetailPanel(); + window.dispatchEvent(new CustomEvent('expand-right-panel')); + + const timerId = window.setTimeout(() => { + deferredTaskDetailTimersRef.current = deferredTaskDetailTimersRef.current.filter(id => id !== timerId); + + const mountDetail = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + addPanelTab(tabInfo); + }); + }); + }; + + if ('requestIdleCallback' in window) { + const idleId = window.requestIdleCallback(() => { + deferredTaskDetailIdleCallbacksRef.current = deferredTaskDetailIdleCallbacksRef.current.filter(id => id !== idleId); + mountDetail(); + }, { timeout: TASK_DETAIL_IDLE_TIMEOUT_MS }); + deferredTaskDetailIdleCallbacksRef.current.push(idleId); + return; + } + + mountDetail(); + }, TASK_DETAIL_PANEL_EXPAND_DEFER_MS); + + deferredTaskDetailTimersRef.current.push(timerId); + }, [addPanelTab]); + return (
= ({ log.info('Opening visualization', { type, data }); }} onFileViewRequest={handleFileViewRequest} - onTabOpen={(tabInfo) => { - log.info('Opening tab', { tabInfo }); - if (tabInfo && tabInfo.type) { - addTab({ - type: tabInfo.type, - title: tabInfo.title || 'New Tab', - data: tabInfo.data, - metadata: tabInfo.metadata - }); - } - }} + onTabOpen={handleTabOpen} onSwitchToChatPanel={() => {}} config={{ enableMarkdown: true, diff --git a/src/web-ui/src/app/scenes/session/SessionScene.scss b/src/web-ui/src/app/scenes/session/SessionScene.scss index 8d9dd3944..12c5b920d 100644 --- a/src/web-ui/src/app/scenes/session/SessionScene.scss +++ b/src/web-ui/src/app/scenes/session/SessionScene.scss @@ -10,6 +10,8 @@ display: flex; flex-direction: row; flex: 1; + min-width: 0; + max-width: 100%; height: 100%; overflow: hidden; position: relative; diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index bc2be986f..d559670a4 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -13,6 +13,7 @@ import AIModelConfig from '../../../infrastructure/config/components/AIModelConf import SessionConfig from '../../../infrastructure/config/components/SessionConfig'; import AIRulesMemoryConfig from '../../../infrastructure/config/components/AIRulesMemoryConfig'; import McpToolsConfig from '../../../infrastructure/config/components/McpToolsConfig'; +import AcpAgentsConfig from '../../../infrastructure/config/components/AcpAgentsConfig'; import EditorConfig from '../../../infrastructure/config/components/EditorConfig'; import BasicsConfig from '../../../infrastructure/config/components/BasicsConfig'; import ReviewConfig from '../../../infrastructure/config/components/ReviewConfig'; @@ -43,6 +44,7 @@ const SettingsScene: React.FC = () => { case 'review': Content = ReviewConfig; break; case 'ai-context': Content = AIRulesMemoryConfig; break; case 'mcp-tools': Content = McpToolsConfig; break; + case 'acp-agents': Content = AcpAgentsConfig; break; case 'editor': Content = EditorConfig; break; } diff --git a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx index ca54df25f..2c7393586 100644 --- a/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx +++ b/src/web-ui/src/app/scenes/settings/components/KeyboardShortcutsTab.tsx @@ -25,6 +25,7 @@ import { configManager } from '@/infrastructure/config'; import type { ShortcutConfig, ShortcutScope } from '@/shared/types/shortcut'; import { ALL_SHORTCUTS, + compareShortcutScope, SCOPE_ORDER, SCOPE_LABEL_KEYS, getShortcutDescriptionI18nKey, @@ -102,10 +103,10 @@ function mergeCatalogWithLive(live: ShortcutRegistration[]): ShortcutRegistratio out.push(r); } out.sort((a, b) => { - const scopeOrder: Record = { app: 0, chat: 1, canvas: 2, filetree: 3, git: 4 }; - const sa = scopeOrder[a.config.scope ?? 'app']; - const sb = scopeOrder[b.config.scope ?? 'app']; - if (sa !== sb) return sa - sb; + const c = compareShortcutScope(a.config.scope, b.config.scope); + if (c !== 0) { + return c; + } return b.priority - a.priority; }); return out; diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index 944662e1d..544c410e8 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -12,6 +12,7 @@ export type ConfigTab = | 'review' | 'ai-context' | 'mcp-tools' + | 'acp-agents' // | 'lsp' // temporarily hidden from config center | 'editor' | 'keyboard'; @@ -82,7 +83,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ id: 'keyboard', labelKey: 'configCenter.tabs.keyboard', descriptionKey: 'configCenter.tabDescriptions.keyboard', - beta: true, keywords: [ 'keyboard', 'shortcut', @@ -91,7 +91,6 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'shortcut key', '\u5feb\u6377\u952e', '\u952e\u4f4d', - 'beta', ], }, ], @@ -146,6 +145,20 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ descriptionKey: 'configCenter.tabDescriptions.mcpTools', keywords: ['mcp', 'server', 'plugin', 'stdio', 'sse', 'tools'], }, + { + id: 'acp-agents', + labelKey: 'configCenter.tabs.acpAgents', + descriptionKey: 'configCenter.tabDescriptions.acpAgents', + keywords: [ + 'acp', + 'agent client protocol', + 'external agent', + 'opencode', + 'claude code', + 'codex', + 'stdio', + ], + }, ], }, { diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 2137ab1a6..4d26411d0 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -41,6 +41,9 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record { searchDraft, marketQuery, installedFilter, + hideDuplicates, isAddFormOpen, setSearchDraft, submitMarketQuery, setInstalledFilter, + setHideDuplicates, setAddFormOpen, toggleAddForm, } = useSkillsSceneStore(); @@ -115,7 +118,9 @@ const SkillsScene: React.FC = () => { const selectedInstalledSkill = selectedDetail?.type === 'installed' ? selectedDetail.skill : null; const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; - const installedFiltered = installed.filteredSkills; + const installedFiltered = hideDuplicates + ? installed.filteredSkills.filter((s) => !s.isShadowed) + : installed.filteredSkills; const installedTotalPages = Math.max( 1, Math.ceil(installedFiltered.length / INSTALLED_PAGE_SIZE), @@ -216,7 +221,6 @@ const SkillsScene: React.FC = () => {
{market.marketSkills.map((skill, index) => { const isInstalled = installedSkillNames.has(skill.name); - const isDownloading = market.downloadingPackage === skill.installId; return ( { {skill.installs ?? 0} )} - actions={[ - { - id: 'download', - icon: isInstalled ? : , - ariaLabel: isInstalled ? t('market.item.installed') : t('market.item.downloadProject'), - title: isDownloading - ? t('market.item.downloading') - : (isInstalled ? t('market.item.installedTooltip') : t('market.item.downloadProject')), - disabled: isDownloading || !market.hasWorkspace || market.isRemoteWorkspace || isInstalled, - tone: isInstalled ? 'success' : 'primary', - onClick: () => market.handleDownload(skill), - }, - ]} onOpenDetails={() => setSelectedDetail({ type: 'market', skill })} /> ); @@ -314,13 +305,24 @@ const SkillsScene: React.FC = () => { {count} ))} +
@@ -375,7 +377,10 @@ const SkillsScene: React.FC = () => { {!installed.loading && !installed.error && pagedInstalledSkills.map((skill, index) => (
setSelectedDetail({ type: 'installed', skill })} role="button" @@ -402,6 +407,14 @@ const SkillsScene: React.FC = () => { onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > + {skill.isShadowed && ( + + + + {t('list.item.shadowed')} + + + )} {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} @@ -464,9 +477,19 @@ const SkillsScene: React.FC = () => { )} title={selectedInstalledSkill?.name ?? selectedMarketSkill?.name ?? ''} badges={selectedInstalledSkill ? ( - - {selectedInstalledSkill.level === 'user' ? t('list.item.user') : t('list.item.project')} - + <> + {selectedInstalledSkill.isShadowed && ( + + + + {t('list.item.shadowed')} + + + )} + + {selectedInstalledSkill.level === 'user' ? t('list.item.user') : t('list.item.project')} + + ) : selectedMarketSkill && installedSkillNames.has(selectedMarketSkill.name) ? ( @@ -493,39 +516,62 @@ const SkillsScene: React.FC = () => { {t('deleteModal.delete')} ) : selectedMarketSkill ? ( - + <> + {installedSkillNames.has(selectedMarketSkill.name) ? ( + + ) : ( + <> + {!market.isRemoteWorkspace && ( + + )} + + + )} + ) : null} > {selectedInstalledSkill ? ( -
- {t('list.item.pathLabel')} - {canRevealSkillPath ? ( - - ) : ( - {selectedInstalledSkill.path} + <> + {selectedInstalledSkill.isShadowed && ( +
+ {t('list.item.shadowedLabel')} + + {t('list.item.shadowedDetail', { key: selectedInstalledSkill.shadowedByKey ?? '' })} + +
)} -
+
+ {t('list.item.pathLabel')} + {canRevealSkillPath ? ( + + ) : ( + {selectedInstalledSkill.path} + )} +
+ ) : null} {selectedMarketSkill?.source ? ( diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.scss b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss index d7761eea4..73b1494d1 100644 --- a/src/web-ui/src/app/scenes/skills/components/SkillCard.scss +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.scss @@ -48,6 +48,10 @@ z-index: 0; } + &--no-actions::before { + bottom: 0; + } + &:hover { transform: translateY(-4px) scale(1.02); box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); diff --git a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx index b1683f4b6..ab0f9e436 100644 --- a/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx +++ b/src/web-ui/src/app/scenes/skills/components/SkillCard.tsx @@ -41,9 +41,11 @@ const SkillCard: React.FC = ({ const Icon = iconKind === 'market' ? Package : Puzzle; const openDetails = () => onOpenDetails?.(); + const hasActions = actions.length > 0; + return (
= ({
{/* Footer: action buttons */} - {actions.length > 0 && ( + {hasActions && (
e.stopPropagation()}> {actions.map((action) => ( diff --git a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts index 5b646e2c6..3b7348eaa 100644 --- a/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts +++ b/src/web-ui/src/app/scenes/skills/hooks/useSkillMarket.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { configAPI } from '@/infrastructure/api'; -import type { SkillMarketItem } from '@/infrastructure/config/types'; +import type { SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; import { useWorkspaceManagerSync } from '@/infrastructure/hooks/useWorkspaceManagerSync'; import { useNotification } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; @@ -129,21 +129,18 @@ export function useSkillMarket({ } }, [currentPage, displayMarketSkills.length, fetchSkills, hasMore, pageSize, searchQuery]); - const handleDownload = useCallback(async (skill: SkillMarketItem) => { - if (!hasWorkspace) { + const handleDownload = useCallback(async (skill: SkillMarketItem, targetLevel: SkillLevel = 'project') => { + const resolvedLevel: SkillLevel = isRemoteWorkspace ? 'user' : targetLevel; + if (resolvedLevel === 'project' && !hasWorkspace) { notification.warning(t('messages.noWorkspace')); return; } - if (isRemoteWorkspace) { - notification.warning('Remote workspaces do not support project skill downloads yet.'); - return; - } try { setDownloadingPackage(skill.installId); const result = await configAPI.downloadSkillMarket({ packageId: skill.installId, - level: 'project', - workspacePath: workspacePath || undefined, + level: resolvedLevel, + workspacePath: resolvedLevel === 'project' ? workspacePath || undefined : undefined, }); const installedName = result.installedSkills[0] ?? skill.name; notification.success(t('messages.marketDownloadSuccess', { name: installedName })); diff --git a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts index c25b44989..b14575354 100644 --- a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts +++ b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts @@ -6,10 +6,12 @@ interface SkillsSceneState { searchDraft: string; marketQuery: string; installedFilter: InstalledFilter; + hideDuplicates: boolean; isAddFormOpen: boolean; setSearchDraft: (value: string) => void; submitMarketQuery: () => void; setInstalledFilter: (filter: InstalledFilter) => void; + setHideDuplicates: (hide: boolean) => void; setAddFormOpen: (open: boolean) => void; toggleAddForm: () => void; } @@ -18,10 +20,12 @@ export const useSkillsSceneStore = create((set) => ({ searchDraft: '', marketQuery: '', installedFilter: 'all', + hideDuplicates: false, isAddFormOpen: false, setSearchDraft: (value) => set({ searchDraft: value }), submitMarketQuery: () => set((state) => ({ marketQuery: state.searchDraft.trim() })), setInstalledFilter: (filter) => set({ installedFilter: filter }), + setHideDuplicates: (hide) => set({ hideDuplicates: hide }), setAddFormOpen: (open) => set({ isAddFormOpen: open }), toggleAddForm: () => set((state) => ({ isAddFormOpen: !state.isAddFormOpen })), })); diff --git a/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts b/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts new file mode 100644 index 000000000..d80e8a76e --- /dev/null +++ b/src/web-ui/src/app/utils/projectSessionWorkspace.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; +import { findReusableEmptySessionId } from './projectSessionWorkspace'; + +const resetStore = () => { + flowChatStore.setState((): FlowChatState => ({ + sessions: new Map(), + activeSessionId: null, + })); +}; + +const createWorkspace = (): WorkspaceInfo => ({ + id: 'workspace-1', + name: 'BitFun', + rootPath: '/workspace/BitFun', + workspaceKind: WorkspaceKind.Normal, +}); + +const createSession = (overrides: Partial = {}): Session => ({ + sessionId: 'session-1', + title: 'Session 1', + dialogTurns: [], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + isHistorical: false, + maxContextTokens: 128128, + mode: 'agentic', + workspacePath: '/workspace/BitFun', + workspaceId: 'workspace-1', + sessionKind: 'normal', + btwThreads: [], + isTransient: false, + ...overrides, +}); + +describe('findReusableEmptySessionId', () => { + afterEach(() => { + resetStore(); + }); + + it('does not reuse an empty ACP session for a new code session', () => { + const workspace = createWorkspace(); + const acpSession = createSession({ + sessionId: 'acp-session', + config: { agentType: 'acp:codex' }, + mode: 'acp:codex', + lastActiveAt: 10, + }); + + flowChatStore.setState(() => ({ + sessions: new Map([[acpSession.sessionId, acpSession]]), + activeSessionId: acpSession.sessionId, + })); + + expect(findReusableEmptySessionId(workspace, 'agentic')).toBeNull(); + }); + + it('still reuses a matching empty code session when ACP sessions also exist', () => { + const workspace = createWorkspace(); + const codeSession = createSession({ + sessionId: 'code-session', + lastActiveAt: 5, + }); + const acpSession = createSession({ + sessionId: 'acp-session', + config: { agentType: 'acp:codex' }, + mode: 'acp:codex', + lastActiveAt: 20, + }); + + flowChatStore.setState(() => ({ + sessions: new Map([ + [codeSession.sessionId, codeSession], + [acpSession.sessionId, acpSession], + ]), + activeSessionId: acpSession.sessionId, + })); + + expect(findReusableEmptySessionId(workspace, 'agentic')).toBe(codeSession.sessionId); + }); +}); diff --git a/src/web-ui/src/app/utils/projectSessionWorkspace.ts b/src/web-ui/src/app/utils/projectSessionWorkspace.ts index 9aa6a9535..d0193d8b9 100644 --- a/src/web-ui/src/app/utils/projectSessionWorkspace.ts +++ b/src/web-ui/src/app/utils/projectSessionWorkspace.ts @@ -1,5 +1,6 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; import type { Session } from '@/flow_chat/types/flow-chat'; +import { isAcpFlowSession } from '@/flow_chat/utils/acpSession'; import { WorkspaceKind, isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; type SessionDisplayBucket = 'code' | 'cowork' | 'claw'; @@ -53,6 +54,9 @@ function isEmptyReusableSession(session: Session, workspace: WorkspaceInfo, buck if (session.sessionKind !== 'normal') { return false; } + if (isAcpFlowSession(session)) { + return false; + } if (session.isHistorical) { return false; } diff --git a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx index 3192eb18f..6de8f3b95 100644 --- a/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx +++ b/src/web-ui/src/component-library/components/ConfirmDialog/ConfirmDialog.tsx @@ -83,7 +83,6 @@ export const ConfirmDialog: React.FC = ({ const handleConfirm = () => { onConfirm(); - onClose(); }; const handleCancel = () => { diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.scss b/src/web-ui/src/component-library/components/Markdown/Markdown.scss index 86d29d0f7..ab8c34132 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.scss +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.scss @@ -3,6 +3,8 @@ --markdown-font-mono: "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace; --markdown-block-gap: 0.65rem; --markdown-code-bg-elevated: color-mix(in srgb, var(--color-bg-primary) 92%, #ffffff 8%); + --markdown-link-color: var(--flowchat-link-color, #60a5fa); + --markdown-link-hover-color: var(--flowchat-link-hover-color, #93c5fd); color: var(--color-text-primary); line-height: var(--line-height-relaxed); @@ -473,15 +475,37 @@ margin: 0.5rem 0; } +/* inline-block so multiple badges / linked images can sit on one row (shields, etc.) */ .markdown-renderer img, .markdown-renderer .markdown-image { - display: block; + display: inline-block; max-width: 100%; height: auto; - margin: 0.75rem 0; + margin: 0.35rem 0.25rem; + vertical-align: middle; border-radius: 8px; } +/* README-style centered badge rows: each badge is often its own

inside a div */ +.markdown-renderer div[align="center"] { + text-align: center; +} + +.markdown-renderer div[align="center"] > p { + display: inline-block; + margin: 0.2rem 0.25rem; + vertical-align: middle; +} + +.markdown-renderer div[align="center"] > p + p { + margin-top: 0.2rem; +} + +.markdown-renderer div[align="center"] > p > a:has(> img) + br, +.markdown-renderer div[align="center"] > p > img + br { + display: none; +} + .markdown-renderer .markdown-image--loading { opacity: 0.75; } @@ -582,12 +606,15 @@ .markdown-renderer a { - color: var(--primary-color); - text-decoration: none; + color: var(--markdown-link-color); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; } .markdown-renderer a:hover { - text-decoration: underline; + color: var(--markdown-link-hover-color); + text-decoration-thickness: 1.5px; } @@ -596,8 +623,10 @@ .tab-link { background: none; border: none; - color: var(--primary-color); + color: var(--markdown-link-color); text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 2px; cursor: pointer; font: inherit; padding: 0; @@ -607,7 +636,25 @@ .file-link:hover, .visualization-link:hover, .tab-link:hover { - color: var(--primary-color-hover); + color: var(--markdown-link-hover-color); + text-decoration-thickness: 1.5px; +} + +.markdown-renderer a .inline-code, +.file-link .inline-code, +.visualization-link .inline-code, +.tab-link .inline-code { + color: inherit; +} + +.markdown-link-path-tooltip { + display: inline-block; + max-width: min(520px, 80vw); + overflow-wrap: anywhere; + text-align: left; + font-family: var(--markdown-font-mono); + font-size: 0.78rem; + line-height: 1.4; } @@ -704,6 +751,8 @@ :root[data-theme-type="light"] .markdown-renderer, [data-theme-type="light"] .markdown-renderer, .light .markdown-renderer { + --markdown-link-color: #0969da; + --markdown-link-hover-color: #0550ae; .inline-code { background: rgba(0, 0, 0, 0.05); @@ -813,6 +862,19 @@ .table-wrapper tbody tr:hover { background: #eef4ff; } + + blockquote, + .custom-blockquote { + background: var(--flowchat-md-bq-bg, rgba(15, 23, 42, 0.07)); + border-left: 3px solid var(--flowchat-md-bq-border, rgba(15, 23, 42, 0.24)); + color: var(--flowchat-md-bq-fg, var(--color-text-secondary, #475569)); + } + + blockquote:hover, + .custom-blockquote:hover { + background: var(--flowchat-md-bq-hover-bg, rgba(15, 23, 42, 0.1)); + border-left-color: var(--flowchat-md-bq-hover-border, rgba(15, 23, 42, 0.34)); + } } diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 75db75183..cb0ff8d73 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -16,9 +16,12 @@ import { visit } from 'unist-util-visit'; import { useI18n } from '@/infrastructure/i18n'; import { MermaidBlock } from './MermaidBlock'; import { ReproductionStepsBlock } from './ReproductionStepsBlock'; +import { Tooltip } from '../Tooltip'; import { globalAPI, systemAPI, workspaceAPI } from '../../../infrastructure/api'; import { getPrismLanguageFromAlias } from '@/infrastructure/language-detection'; import { useTheme } from '@/infrastructure/theme'; +import { contextMenuController } from '@/shared/context-menu-system'; +import { ContextType, type CustomContext, type MenuItem } from '@/shared/context-menu-system/types'; import { createLogger } from '@/shared/utils/logger'; import path from 'path-browserify'; import 'katex/dist/katex.min.css'; @@ -123,6 +126,7 @@ const sanitizeSchema = { ...defaultSchema.attributes, a: [...(defaultSchema.attributes?.a || []), 'href', 'title'], code: [...(defaultSchema.attributes?.code || []), 'className'], + div: [...(defaultSchema.attributes?.div || []), 'align'], details: [...(defaultSchema.attributes?.details || []), 'open'], img: [...(defaultSchema.attributes?.img || []), 'src', 'alt', 'title', 'width', 'height', 'align'], input: [...(defaultSchema.attributes?.input || []), 'type', 'checked', 'disabled'], @@ -231,6 +235,20 @@ function normalizePath(filePath: string): string { return filePath.replace(/\\/g, '/'); } +function normalizeDisplayPath(filePath: string): string { + const normalized = normalizePath(filePath); + + if (/^[A-Za-z]:\//.test(normalized)) { + return normalized.replace(/\//g, '\\'); + } + + if (/^\/[A-Za-z]:\//.test(normalized)) { + return normalized.slice(1).replace(/\//g, '\\'); + } + + return normalized; +} + function isAbsoluteFilesystemPath(filePath: string): boolean { const normalized = normalizePath(filePath); if (/^[A-Za-z]:/.test(normalized) || /^\/[A-Za-z]:/.test(normalized)) { @@ -253,6 +271,16 @@ function resolveBaseRelativePath(targetPath: string, basePath?: string): string return path.normalize(path.join(basePath, normalizedTarget)); } +function resolveDisplayFilePath(targetPath: string, basePath?: string, workspacePath?: string): string { + const baseResolved = resolveBaseRelativePath(targetPath, basePath); + + if (!baseResolved || isAbsoluteFilesystemPath(baseResolved) || !workspacePath) { + return normalizeDisplayPath(baseResolved); + } + + return normalizeDisplayPath(resolveBaseRelativePath(baseResolved, workspacePath)); +} + function isLocalAssetPath(src: string): boolean { if (!src) { return false; @@ -539,10 +567,30 @@ export const Markdown = React.memo(({ onReproductionProceed }) => { const { isLight } = useTheme(); + const { t } = useI18n('components'); + const [currentWorkspacePath, setCurrentWorkspacePath] = useState(''); const syntaxTheme = isLight ? vs : vscDarkPlus; const contentStr = typeof content === 'string' ? content : String(content || ''); + + useEffect(() => { + let cancelled = false; + + void globalAPI.getCurrentWorkspacePath() + .then((workspacePath) => { + if (!cancelled && workspacePath) { + setCurrentWorkspacePath(workspacePath); + } + }) + .catch((error) => { + log.warn('Failed to resolve workspace path for markdown links', { error }); + }); + + return () => { + cancelled = true; + }; + }, []); // Fault-tolerant extraction of content const { markdownContent, reproductionSteps } = useMemo(() => { @@ -606,21 +654,151 @@ export const Markdown = React.memo(({ }, [onTabOpen]); const handleRevealInExplorer = useCallback(async (filePath: string) => { - let targetPath = filePath; + let targetPath = resolveDisplayFilePath(filePath, basePath, currentWorkspacePath); try { - const workspacePath = await globalAPI.getCurrentWorkspacePath(); - const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(filePath); - const isUnixAbsolutePath = filePath.startsWith('/'); - - if (!isWindowsAbsolutePath && !isUnixAbsolutePath && workspacePath) { - targetPath = path.join(workspacePath, filePath); + if (!isAbsoluteFilesystemPath(targetPath)) { + const workspacePath = await globalAPI.getCurrentWorkspacePath(); + targetPath = resolveDisplayFilePath(filePath, basePath, workspacePath || currentWorkspacePath); } await workspaceAPI.revealInExplorer(targetPath); } catch (error) { log.error('Failed to reveal file in explorer', { filePath: targetPath, error }); } + }, [basePath, currentWorkspacePath]); + + const showLinkContextMenu = useCallback(( + event: React.MouseEvent, + items: MenuItem[], + customType: string, + data: Record + ) => { + event.preventDefault(); + event.stopPropagation(); + event.nativeEvent.stopImmediatePropagation?.(); + + const position = { x: event.clientX, y: event.clientY }; + const context: CustomContext = { + type: ContextType.CUSTOM, + customType, + data, + event: event.nativeEvent, + targetElement: event.currentTarget, + position, + timestamp: Date.now(), + }; + + void contextMenuController.show(position, items, context); + }, []); + + const canOpenInBuiltInBrowser = useCallback((targetElement: HTMLElement | null): boolean => { + if (typeof window === 'undefined' || !targetElement) { + return false; + } + + return Boolean( + targetElement.closest('.bitfun-session-scene') && + targetElement.closest('.modern-flowchat-container, .flow-chat-container') + ); + }, []); + + const handleCopyLink = useCallback(async (url: string) => { + try { + await navigator.clipboard.writeText(url); + } catch (error) { + log.warn('Failed to copy markdown link', { url, error }); + } + }, []); + + const handleOpenExternalLink = useCallback(async (url: string) => { + try { + await systemAPI.openExternal(url); + } catch (error) { + log.error('Failed to open external URL', { url, error }); + } }, []); + + const handleOpenBuiltInBrowserLink = useCallback((url: string) => { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent('agent-create-tab', { + detail: { + type: 'browser', + title: t('markdown.openInBuiltInBrowser'), + data: { url }, + metadata: { + duplicateCheckKey: `browser-panel:${url}`, + }, + checkDuplicate: true, + duplicateCheckKey: `browser-panel:${url}`, + replaceExisting: false, + }, + })); + }, [t]); + + const handleLocalFileContextMenu = useCallback(( + event: React.MouseEvent, + filePath: string, + displayPath: string + ) => { + const items: MenuItem[] = [ + { + id: 'markdown-open-in-explorer', + label: t('markdown.openInExplorer'), + icon: 'FolderOpen', + onClick: () => handleRevealInExplorer(displayPath || filePath), + }, + { + id: 'markdown-copy-file-path', + label: t('markdown.copyFilePath'), + icon: 'Copy', + onClick: () => void handleCopyLink(displayPath || filePath), + }, + ]; + + showLinkContextMenu(event, items, 'markdown-local-file-link', { + filePath, + displayPath, + }); + }, [handleRevealInExplorer, handleCopyLink, showLinkContextMenu, t]); + + const handleWebLinkContextMenu = useCallback((event: React.MouseEvent, url: string) => { + const targetElement = event.currentTarget; + const items: MenuItem[] = [ + { + id: 'markdown-open-in-browser', + label: t('markdown.openInBrowser'), + icon: 'ExternalLink', + onClick: () => void handleOpenExternalLink(url), + }, + { + id: 'markdown-copy-link', + label: t('markdown.copyLink'), + icon: 'Copy', + onClick: () => void handleCopyLink(url), + }, + ]; + + if (canOpenInBuiltInBrowser(targetElement)) { + items.splice(1, 0, { + id: 'markdown-open-in-built-in-browser', + label: t('markdown.openInBuiltInBrowser'), + icon: 'PanelRightOpen', + onClick: () => handleOpenBuiltInBrowserLink(url), + }); + } + + showLinkContextMenu(event, items, 'markdown-web-link', { url }); + }, [ + canOpenInBuiltInBrowser, + handleCopyLink, + handleOpenBuiltInBrowserLink, + handleOpenExternalLink, + showLinkContextMenu, + t, + ]); const components = useMemo(() => ({ code({ node: _node, className, children, ...props }: any) { @@ -727,28 +905,29 @@ export const Markdown = React.memo(({ } filePath = resolveBaseRelativePath(filePath, basePath); + const displayFilePath = resolveDisplayFilePath(filePath, undefined, currentWorkspacePath); const fileName = filePath.split(/[\\/]/).pop() || filePath; const isFolder = filePath.endsWith('/'); const shouldRevealInExplorer = isComputerLink || !isEditorOpenableFilePath(filePath); if (!isFolder) { - return ( + const fileLinkButton = ( ); + + return ( + {displayFilePath || filePath}} + placement="top" + delay={300} + > + {fileLinkButton} + + ); } } @@ -803,7 +992,6 @@ export const Markdown = React.memo(({ type="button" style={{ cursor: 'pointer', - color: '#3b82f6', textDecoration: 'underline', background: 'none', border: 'none', @@ -829,7 +1017,8 @@ export const Markdown = React.memo(({ log.error('Failed to open external URL', { url: hrefValue, error }); } }} - style={{ cursor: 'pointer', color: '#3b82f6', textDecoration: 'underline' }} + onContextMenu={(e) => handleWebLinkContextMenu(e, hrefValue)} + style={{ cursor: 'pointer', textDecoration: 'underline' }} > {children} @@ -851,7 +1040,7 @@ export const Markdown = React.memo(({ onClick={(e) => { e.preventDefault(); }} - style={{ cursor: 'pointer', color: 'inherit' }} + style={{ cursor: 'pointer' }} > {children} @@ -911,11 +1100,14 @@ export const Markdown = React.memo(({ linkMap, handleFileViewRequest, handleRevealInExplorer, + handleLocalFileContextMenu, + handleWebLinkContextMenu, handleOpenVisualization, handleTabOpen, parseLineRange, syntaxTheme, - isLight + isLight, + currentWorkspacePath ]); const wrapperClassName = `markdown-renderer ${className} ${isStreaming && contentStr ? 'markdown-renderer--streaming' : ''}`.trim(); diff --git a/src/web-ui/src/component-library/components/registry.tsx b/src/web-ui/src/component-library/components/registry.tsx index 0db83f00e..3d46bbadc 100644 --- a/src/web-ui/src/component-library/components/registry.tsx +++ b/src/web-ui/src/component-library/components/registry.tsx @@ -1619,7 +1619,7 @@ console.log(user.greet());`); skill_input: { file_path: 'src/App.tsx' } }, { - result: '代码审查已完成', + result: '代码审核已完成', suggestions: ['使用 React.memo', '优化渲染性能', '修复现有警告'] }, 'completed' diff --git a/src/web-ui/src/component-library/preview/PreviewApp.tsx b/src/web-ui/src/component-library/preview/PreviewApp.tsx index c528175c0..51c116229 100644 --- a/src/web-ui/src/component-library/preview/PreviewApp.tsx +++ b/src/web-ui/src/component-library/preview/PreviewApp.tsx @@ -48,7 +48,7 @@ export const PreviewApp: React.FC = () => {

{t('componentLibrary.previewApp.title')}

- v0.2.4 + v0.2.5