atomcode webui (Phase 1) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 让用户在 TUI 输入 /webui(或命令行 atomcode webui)即可在本地浏览器中聊天、运行 agent、交互式批准工具调用——作为 TUI 之外的并行入口。

Architecture:atomcode-daemon 的服务逻辑下沉为库函数 run_server(...)(原二进制变薄壳),atomcode 主程序依赖它,/webui进程内 tokio::spawn(run_server) 起服务——不 spawn 子进程、不依赖独立 daemon 二进制(普通用户 curl|sh 只装主程序)。前端用 Preact+Vite+Tailwind 构建为静态资源,经 rust-embed 嵌入并由 run_server/ 提供。/chat 把权限从 BypassAll 换成已有的 InteractivePermissionDecider,SSE 推 permission_request、新增 POST /chat/permission 回送决定。每会话独立 working_dir(随 /chat 请求带),新增 GET /fs/list 供目录浏览。/webui 命令:起 server(若未起)+ mint token + 开浏览器。Phase 2 官方中转隧道仅留可插拔鉴权接口。

重要前提(已核实): atomcode 已是 #[tokio::main](CLI main.rs:698),依赖含 tokio(full)+reqwestinstall.sh 每平台只下载主程序、不含 daemon;atomcode-cli 原本不依赖 daemon crate。故 server 必须库化供主程序进程内调用。

Tech Stack: Rust / axum 0.7 / tokio / rust-embed;前端 Preact + Vite + Tailwind;复用 atomcode-coreInteractivePermissionDeciderTurnRunneropen_browser

Spec: docs/superpowers/specs/2026-05-29-webui-design.md


File Structure

新建:

  • webui/(前端工程根):package.jsonvite.config.tstailwind.config.jsindex.htmlsrc/main.tsxsrc/app.tsxsrc/api.tssrc/components/*.tsx
  • crates/atomcode-daemon/src/webui.rs:rust-embed 静态资源 handler + SPA fallback
  • crates/atomcode-daemon/src/auth_token.rs:一次性 token 存储 + 鉴权中间件
  • crates/atomcode-daemon/src/permission_bridge.rs:把 daemon /chat 的待批工具桥接到 SSE + /chat/permission

修改:

  • crates/atomcode-daemon/src/lib.rs新建):暴露 pub async fn run_server(opts: ServerOpts) -> anyhow::Result<()>pub fn build_app_state(...) -> AppStatepub use token/权限类型;把 main.rs 中 Router 构建 + bind/serve 主体迁进来
  • crates/atomcode-daemon/src/main.rs:变薄壳,解析参数后调 atomcode_daemon::run_server(...)
  • crates/atomcode-daemon/Cargo.toml:加 [lib]rust-embed 依赖;加 feature webui(默认开)
  • crates/atomcode-cli/Cargo.toml:加依赖 atomcode-daemon = { path = "../atomcode-daemon" }
  • crates/atomcode-cli/src/main.rsCommandsWebui 子命令;进程内 server 单例
  • crates/atomcode-cli/src/webui_launch.rs新建):ensure_server_and_open(port) -> Result<String> —— 进程内起 server(OnceCell 守护)+ mint token + 开浏览器,TUI 与 CLI 共用
  • crates/atomcode-tuix/src/event_loop/commands.rsmatch cmd"webui" 分支(调 cli 暴露的 launcher,或把 launcher 放 core 供 tuix 调)

launcher 归属:tuix 与 cli 都要用。放 atomcode-cli 会让 tuix 反向依赖 cli(不可)。故把 ensure_server_and_openatomcode-core(core 可依赖 daemon 库吗?会成环:daemon 依赖 core)。结论:放 atomcode-daemon 库里pub fn ensure_server_and_open),core/tuix/cli 都能依赖 daemon 库调它。tuix 已可依赖 daemon 库(新增依赖)。实现时确认无依赖环(daemon→core 单向,tuix→daemon 新增,cli→daemon 新增,均不成环)。

关键既有接口(已核实,实现时直接用):

  • atomcode_core::turn::permission::{InteractivePermissionDecider, ApprovalRequest}
    • InteractivePermissionDecider::new(request_tx: mpsc::UnboundedSender<ApprovalRequest>, response_rx: mpsc::UnboundedReceiver<PermissionDecision>, permission_store: Arc<RwLock<PermissionStore>>)
    • ApprovalRequest { call: ToolCall, reason: String }
  • atomcode_core::tool::{PermissionDecision (Allow|Ask(String)|Deny), PermissionStore, ToolCall}
  • atomcode_core::turn::event::TurnEvent::ApprovalRequested { tool_name, reason, call, messages }(daemon SSE 循环当前在 main.rs:2299 显式丢弃它)
  • atomcode_core::auth::oauth::open_browser(url: &str) -> anyhow::Result<()>(已按平台 cfg)
  • daemon 现有:origin_is_allowed(&str) -> boolis_loopback_authority(&str) -> boolactivity_tracker_middleware
  • daemon 默认 127.0.0.1:13456;CLI Commands::Daemon 通过 re-exec atomcode-daemon 二进制启动(atomcode-cli/src/main.rs:930+
  • AppState#[derive(Clone)]main.rs:230),字段均为 Arc<...> 共享句柄

Milestone -1 — daemon 库化(最先做,后续一切依赖它)

Task 0: 把 server 逻辑抽成 run_server 库函数

Files:

  • Create: crates/atomcode-daemon/src/lib.rs

  • Modify: crates/atomcode-daemon/src/main.rscrates/atomcode-daemon/Cargo.toml

  • Step 1: 加 [lib]

crates/atomcode-daemon/Cargo.toml[package] 后加:

[lib]
name = "atomcode_daemon"
path = "src/lib.rs"

[[bin]]
name = "atomcode-daemon"
path = "src/main.rs"
  • Step 2: 抽 run_server

main.rs 中「构建 Router + AppState + bind + axum::serve」的主体迁到 lib.rs

//! atomcode-daemon 既是独立二进制,也是库:`run_server` 供主程序进程内调用,
//! 使 `/webui` 无需第二个二进制。

mod api_auth;
mod api_codingplan;
mod api_config;
mod api_provider;
mod telemetry_scope;
pub mod auth_token;        // Task 1 新增
pub mod permission_bridge; // Task 5 新增
pub mod webui;             // Task 4 新增

// ... 把 main.rs 中 AppState 定义、所有 handler、Router 构建迁入并设为 pub(crate)/pub

/// server 启动参数。
pub struct ServerOpts {
    pub host: String,
    pub port: u16,
    pub cli_override: Option<CliOverride>,
    pub idle_timeout_secs: Option<u64>,
    pub startup_mode: SessionMode,
}

/// 构建并运行 axum server,直到收到 shutdown。供二进制 main 与主程序 /webui 共用。
pub async fn run_server(opts: ServerOpts) -> anyhow::Result<()> {
    // 原 main.rs 中 step 1..N 的 AppState 构建 + Router::new()....with_state(state)
    // + TcpListener::bind + axum::serve(...).with_graceful_shutdown(...) 全部迁来。
    // ...
    Ok(())
}
  • Step 3: main.rs 变薄壳

main.rs 保留 parse_daemon_args() 与平台 cfg,main() 末尾改为:

#[tokio::main]
async fn main() {
    let (host, port, cli_override, idle_timeout_secs, startup_mode) = parse_daemon_args();
    // ... 既有的早期 bootstrap(session 迁移等)保留或一并迁入 run_server 前段
    if let Err(e) = atomcode_daemon::run_server(atomcode_daemon::ServerOpts {
        host, port, cli_override, idle_timeout_secs, startup_mode,
    }).await {
        eprintln!("daemon error: {e}");
        std::process::exit(1);
    }
}

迁移是机械重构:把私有 fn / structpub/pub(crate)use crate::AppState 改为 use crate::AppState(lib 内)。逐个编译错误修。不改任何行为

  • Step 4: 编译 + 既有测试回归

Run: cargo build -p atomcode-daemon && cargo test -p atomcode-daemon Expected: 二进制与库都编译通过,既有 daemon 测试全过(行为不变)。

  • Step 5: 冒烟测试(行为不变)
cargo run -p atomcode-daemon -- --port 13457 &
sleep 2
curl -s http://127.0.0.1:13457/health && echo " OK"
kill %1

Expected: /health 正常响应。

  • Step 6: 提交
git add crates/atomcode-daemon
git commit -m "refactor(daemon): 抽 run_server 库函数,二进制变薄壳(行为不变)"

Milestone 0 — 后端鉴权 token(先做,因为后续路由都要它)

注(Task 0 之后):以下任务中凡提到「main.rs 的 AppState / handler / Router」, 在库化后这些已迁入 crates/atomcode-daemon/src/lib.rs,请在 lib.rs 中改动; mod xxx; 声明也加在 lib.rscrate::AppState 引用在 lib 内同样有效。

Task 1: 一次性 token 存储 + 校验逻辑(纯函数,可单测)

Files:

  • Create: crates/atomcode-daemon/src/auth_token.rs

  • Modify: crates/atomcode-daemon/src/main.rs(声明 mod auth_token;,AppState 加字段)

  • Step 1: 写失败测试

crates/atomcode-daemon/src/auth_token.rs 底部:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mints_and_validates_token() {
        let store = WebuiTokenStore::new();
        let tok = store.mint();
        assert!(store.is_valid(&tok), "freshly minted token must validate");
    }

    #[test]
    fn rejects_unknown_token() {
        let store = WebuiTokenStore::new();
        store.mint();
        assert!(!store.is_valid("not-a-real-token"));
        assert!(!store.is_valid(""));
    }

    #[test]
    fn mint_is_unique() {
        let store = WebuiTokenStore::new();
        assert_ne!(store.mint(), store.mint());
    }
}
  • Step 2: 运行测试确认失败

Run: cargo test -p atomcode-daemon auth_token Expected: 编译失败(WebuiTokenStore 未定义)。

  • Step 3: 实现 token 存储

crates/atomcode-daemon/src/auth_token.rs 顶部:

//! 本地 webui 一次性 token 存储与鉴权中间件。
//!
//! Phase 1:token 随 `/webui` 启动生成,仅存内存、随 daemon 退出失效。
//! Phase 2(官方中转隧道)会把账号 token 接入同一条 `is_valid` 校验链——
//! 故鉴权统一收口在本模块,路由层只调 `require_webui_token` 中间件。

use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use uuid::Uuid;

/// 进程内有效 webui token 集合。线程安全,可放进 `AppState`。
#[derive(Clone, Default)]
pub struct WebuiTokenStore {
    inner: Arc<RwLock<HashSet<String>>>,
}

impl WebuiTokenStore {
    pub fn new() -> Self {
        Self::default()
    }

    /// 生成并登记一个新 token,返回其字符串。
    pub fn mint(&self) -> String {
        let token = Uuid::new_v4().simple().to_string();
        self.inner.write().unwrap().insert(token.clone());
        token
    }

    /// 校验 token 是否有效。空串始终无效。
    pub fn is_valid(&self, token: &str) -> bool {
        if token.is_empty() {
            return false;
        }
        self.inner.read().unwrap().contains(token)
    }
}

main.rs 顶部 mod 区加 mod auth_token;,并在 use 区加 use auth_token::WebuiTokenStore;

  • Step 4: 运行测试确认通过

Run: cargo test -p atomcode-daemon auth_token Expected: 3 个测试 PASS。

  • Step 5: 提交
git add crates/atomcode-daemon/src/auth_token.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui 一次性 token 存储"

Task 2: token 鉴权中间件 + 接进 AppState 与 Router

Files:

  • Modify: crates/atomcode-daemon/src/auth_token.rs(加中间件函数 + 测试)

  • Modify: crates/atomcode-daemon/src/main.rs(AppState 加 webui_tokens 字段;初始化;Router 套层)

  • Step 1: 写中间件单测

auth_token.rstests 模块追加(用纯函数 token_from_parts 便于单测,避免造 Request):

#[test]
fn extracts_bearer_token() {
    assert_eq!(token_from_header(Some("Bearer abc123")), Some("abc123".to_string()));
    assert_eq!(token_from_header(Some("bearer abc123")), Some("abc123".to_string()));
    assert_eq!(token_from_header(Some("abc123")), None);
    assert_eq!(token_from_header(None), None);
}
  • Step 2: 运行确认失败

Run: cargo test -p atomcode-daemon auth_token Expected: 编译失败(token_from_header 未定义)。

  • Step 3: 实现 header 解析 + axum 中间件

auth_token.rstests 之前)追加:

use axum::{
    extract::State,
    http::{Request, StatusCode},
    middleware::Next,
    response::Response,
};

/// 从 `Authorization` 头解析 Bearer token(大小写不敏感前缀)。
pub fn token_from_header(value: Option<&str>) -> Option<String> {
    let v = value?;
    let rest = v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))?;
    let rest = rest.trim();
    if rest.is_empty() { None } else { Some(rest.to_string()) }
}

/// axum 中间件:要求请求带有效 webui token,否则 401。
/// 用 `crate::AppState` 取 `webui_tokens`。仅挂在 webui 专用路由上。
pub async fn require_webui_token<B>(
    State(state): State<crate::AppState>,
    req: Request<B>,
    next: Next<B>,
) -> Result<Response, StatusCode> {
    let header = req
        .headers()
        .get(axum::http::header::AUTHORIZATION)
        .and_then(|h| h.to_str().ok());
    match token_from_header(header) {
        Some(tok) if state.webui_tokens.is_valid(&tok) => Ok(next.run(req).await),
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

注:axum 0.7 中间件签名若与本仓库现有 activity_tracker_middlewaremain.rs:732)不同,以现有那个为准对齐(泛型 B vs axum::body::BodyNext 是否带泛型)。先读 main.rs:732 的签名再照抄形状。

  • Step 4: AppState 加字段并初始化

main.rspub struct AppStatemain.rs:231)末尾加:

    /// 本地 webui 一次性 token 存储(Phase 1)
    pub webui_tokens: WebuiTokenStore,

main() 里构造 AppState { ... } 处加 webui_tokens: WebuiTokenStore::new(),

  • Step 5: 运行测试确认通过 + 编译

Run: cargo test -p atomcode-daemon auth_token && cargo build -p atomcode-daemon Expected: 测试 PASS,daemon 编译通过。

  • Step 6: 提交
git add crates/atomcode-daemon/src/auth_token.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui token 鉴权中间件 + 接入 AppState"

Milestone 1 — 静态资源嵌入

Task 3: rust-embed 依赖 + 占位前端构建产物

Files:

  • Modify: crates/atomcode-daemon/Cargo.toml

  • Create: webui/dist/index.html(占位,后续被真实构建覆盖)

  • Step 1: 加依赖

crates/atomcode-daemon/Cargo.toml[dependencies] 加:

rust-embed = { version = "8", features = ["mime-guess"] }
  • Step 2: 放占位产物

webui/dist/index.html

<!doctype html>
<html><head><meta charset="utf-8"><title>atomcode webui</title></head>
<body><div id="app">webui placeholder</div></body></html>
  • Step 3: 编译确认依赖可用

Run: cargo build -p atomcode-daemon Expected: 成功(仅引入了依赖,未使用尚不报错)。

  • Step 4: 提交
git add crates/atomcode-daemon/Cargo.toml webui/dist/index.html
git commit -m "build(daemon): 引入 rust-embed + webui 占位产物"

Task 4: 静态资源 handler + SPA fallback

Files:

  • Create: crates/atomcode-daemon/src/webui.rs

  • Modify: crates/atomcode-daemon/src/main.rsmod webui;,Router 加 GET / 与 fallback)

  • Step 1: 写 handler 测试

crates/atomcode-daemon/src/webui.rs 底部:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn serves_embedded_index() {
        // dist/index.html 必须被嵌入
        let f = WebuiAssets::get("index.html");
        assert!(f.is_some(), "index.html should be embedded");
    }

    #[test]
    fn unknown_path_falls_back_to_index_marker() {
        // 未知路径应回退 index.html(SPA 路由)
        assert!(asset_or_index("does/not/exist.js").is_none()
            || asset_or_index("does/not/exist.js").is_some());
        // 明确:未知路径返回 index.html 内容
        let body = asset_or_index("some/spa/route");
        assert!(body.is_some(), "SPA route should fall back to index");
    }
}
  • Step 2: 运行确认失败

Run: cargo test -p atomcode-daemon webui Expected: 编译失败(WebuiAssets / asset_or_index 未定义)。

  • Step 3: 实现 embed + handler

crates/atomcode-daemon/src/webui.rs 顶部:

//! 嵌入式 webui 静态资源服务。
//!
//! 编译期把 `webui/dist/` 打进二进制;运行期 `GET /` 与未匹配的非 API 路径
//! 都回退到 `index.html`,交给前端 SPA 路由。
//!
//! dev 模式(设置 `ATOMCODE_WEBUI_DEV=http://localhost:5173`)下应改为反代/
//! 重定向到 vite dev server——见 `webui_dev_redirect`。

use axum::{
    body::Body,
    http::{header, StatusCode, Uri},
    response::{IntoResponse, Response},
};
use rust_embed::RustEmbed;

#[derive(RustEmbed)]
#[folder = "../../webui/dist/"]
pub struct WebuiAssets;

/// 取静态资源;未命中时回退 index.html 的内容(SPA)。
pub fn asset_or_index(path: &str) -> Option<std::borrow::Cow<'static, [u8]>> {
    let p = path.trim_start_matches('/');
    if let Some(f) = WebuiAssets::get(p) {
        return Some(f.data);
    }
    WebuiAssets::get("index.html").map(|f| f.data)
}

/// axum handler:服务任意 webui 路径。
pub async fn serve_webui(uri: Uri) -> Response {
    let path = uri.path();
    let p = path.trim_start_matches('/');
    let lookup = if p.is_empty() { "index.html" } else { p };
    match WebuiAssets::get(lookup) {
        Some(content) => {
            let mime = mime_guess::from_path(lookup).first_or_octet_stream();
            ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response()
        }
        None => match WebuiAssets::get("index.html") {
            Some(index) => (
                [(header::CONTENT_TYPE, "text/html")],
                index.data,
            )
                .into_response(),
            None => (StatusCode::NOT_FOUND, "webui not built").into_response(),
        },
    }
}

main.rsmod webui;

  • Step 4: Router 接线

main.rsRouter::new() 链中加(放在所有 API 路由之后.with_state 之前;fallback 捕获未匹配路径):

        .route("/", get(webui::serve_webui))
        .route("/webui-app/*path", get(webui::serve_webui))
        .fallback(webui::serve_webui)

//webui-app/* 是页面与资源;fallback 兜住 SPA 深链。注意不要让 fallback 吞掉已有 API 路由——axum 精确路由优先,fallback 只接未匹配的。

  • Step 5: 运行测试 + 编译

Run: cargo test -p atomcode-daemon webui && cargo build -p atomcode-daemon Expected: 测试 PASS,编译通过。

  • Step 6: 手动验证
cargo run -p atomcode-daemon -- --port 13457 &
curl -s http://127.0.0.1:13457/ | grep -q "webui placeholder" && echo OK
kill %1

Expected: 打印 OK

  • Step 7: 提交
git add crates/atomcode-daemon/src/webui.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): 嵌入式 webui 静态资源服务 + SPA fallback"

Milestone 2 — 交互式权限流

Task 5: 权限桥接通道 + AppState 存储

Files:

  • Create: crates/atomcode-daemon/src/permission_bridge.rs
  • Modify: crates/atomcode-daemon/src/main.rs(AppState 加 pending_permissions

桥接思路:/chat 为每个 session 建一对 channel。InteractivePermissionDeciderrequest_tx 在工具待批时发 ApprovalRequest;daemon SSE 循环转发 ApprovalRequested 给前端。前端 POST /chat/permission 时,handler 通过 pending_permissionssession_id -> mpsc::Sender<PermissionDecision>)把决定送回 decider 的 response_rx

  • Step 1: 写测试

permission_bridge.rs 底部:

#[cfg(test)]
mod tests {
    use super::*;
    use atomcode_core::tool::PermissionDecision;

    #[tokio::test]
    async fn routes_decision_to_registered_session() {
        let reg = PermissionResponders::new();
        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
        reg.register("sess-1".into(), tx);

        assert!(reg.deliver("sess-1", PermissionDecision::Allow));
        assert!(matches!(rx.recv().await, Some(PermissionDecision::Allow)));
    }

    #[test]
    fn deliver_to_unknown_session_returns_false() {
        let reg = PermissionResponders::new();
        assert!(!reg.deliver("nope", PermissionDecision::Deny));
    }
}
  • Step 2: 运行确认失败

Run: cargo test -p atomcode-daemon permission_bridge Expected: 编译失败(PermissionResponders 未定义)。

  • Step 3: 实现

permission_bridge.rs 顶部:

//! 把 daemon `/chat` 的交互式权限决策桥接到 HTTP。
//!
//! `InteractivePermissionDecider` 阻塞在 `response_rx` 上等待决定;
//! `/chat/permission` 收到前端决定后,经 `PermissionResponders` 按
//! session_id 路由到对应的 `response_tx`,唤醒 decider。

use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc::UnboundedSender;
use atomcode_core::tool::PermissionDecision;

/// session_id -> decider 的 response 发送端。
#[derive(Clone, Default)]
pub struct PermissionResponders {
    inner: Arc<RwLock<HashMap<String, UnboundedSender<PermissionDecision>>>>,
}

impl PermissionResponders {
    pub fn new() -> Self {
        Self::default()
    }

    /// 登记某 session 的决定发送端(`/chat` 启动时调用)。
    pub fn register(&self, session_id: String, tx: UnboundedSender<PermissionDecision>) {
        self.inner.write().unwrap().insert(session_id, tx);
    }

    /// `/chat` 结束时清理。
    pub fn unregister(&self, session_id: &str) {
        self.inner.write().unwrap().remove(session_id);
    }

    /// 把决定送给对应 session 的 decider。返回是否成功(session 是否在等待)。
    pub fn deliver(&self, session_id: &str, decision: PermissionDecision) -> bool {
        if let Some(tx) = self.inner.read().unwrap().get(session_id) {
            tx.send(decision).is_ok()
        } else {
            false
        }
    }
}

main.rsmod permission_bridge;use permission_bridge::PermissionResponders;,AppState 加:

    /// webui 交互式权限:session_id -> decider response 发送端
    pub pending_permissions: PermissionResponders,

并在 main() 构造 AppState 处加 pending_permissions: PermissionResponders::new(),

  • Step 4: 运行测试 + 编译

Run: cargo test -p atomcode-daemon permission_bridge && cargo build -p atomcode-daemon Expected: PASS + 编译通过。

  • Step 5: 提交
git add crates/atomcode-daemon/src/permission_bridge.rs crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): 权限桥接通道 PermissionResponders"

Task 6: chat_stream 改用 InteractivePermissionDecider + 转发 ApprovalRequested

Files:

  • Modify: crates/atomcode-daemon/src/main.rschat_stream 内 permission 构造处 main.rs:2051;SSE 循环 main.rs:2299

  • Step 1: 替换 permission 构造

main.rs:2051 的:

    let permission = Box::new(AutoPermissionDecider::new(AutoPermissionMode::BypassAll));

替换为(用 session_id 注册 responder):

    // webui 交互式权限:建一对 channel,request 经 SSE 推给前端,
    // 决定经 /chat/permission 回送。非交互客户端(无人应答)会在
    // PERMISSION_TIMEOUT 后默认拒绝(见 SSE 循环转发处)。
    use atomcode_core::turn::permission::{ApprovalRequest, InteractivePermissionDecider};
    use atomcode_core::tool::PermissionStore;
    let (perm_req_tx, mut perm_req_rx) =
        tokio::sync::mpsc::unbounded_channel::<ApprovalRequest>();
    let (perm_resp_tx, perm_resp_rx) =
        tokio::sync::mpsc::unbounded_channel::<atomcode_core::tool::PermissionDecision>();
    let perm_store = std::sync::Arc::new(std::sync::RwLock::new(PermissionStore::new()));
    state
        .pending_permissions
        .register(session_id_str.clone(), perm_resp_tx);
    let permission = Box::new(InteractivePermissionDecider::new(
        perm_req_tx,
        perm_resp_rx,
        perm_store,
    ));

session_id_strchat_stream 中已存在(main.rs 当前用它判断 stopped)。若位置在 permission 构造之后,则上移其定义,或改用 req.session_id。读上下文确认变量名后对齐。

  • Step 2: SSE 循环转发 ApprovalRequested

main.rs:2299 的:

            TurnEvent::ApprovalRequested { .. } => {
                // ApprovalRequested is TUI-only (carries conversation.messages ...

整个分支替换为转发(序列化为 permission_request SSE 事件,不带 messages 这类大字段):

            TurnEvent::ApprovalRequested { tool_name, reason, call, .. } => {
                let payload = serde_json::json!({
                    "type": "permission_request",
                    "session_id": session_id_str,
                    "tool_name": tool_name,
                    "reason": reason,
                    "call_id": call.id,
                    "arguments": call.arguments,
                });
                yield Ok::<_, std::convert::Infallible>(
                    axum::response::sse::Event::default().data(payload.to_string())
                );
            }

字段名以 ToolCall 实际结构为准(前面已见 ToolBatchCall { id, name, arguments }ToolCall 应有 id 与序列化后的 arguments/参数字段)。实现时 grep "pub struct ToolCall" 确认字段,必要时调整 json key。SSE yield 语法需与该函数现有 stream 宏(async_stream/手写)一致——参照同函数内其它 yield(如 main.rs:1853)。

  • Step 3: 结束时反注册 + 兜底

chat_stream 的 turn 结束/错误清理处加:

    state.pending_permissions.unregister(&session_id_str);

(与现有 chat_tasks/stopped_sessions 清理放一起。)

  • Step 4: 编译

Run: cargo build -p atomcode-daemon Expected: 编译通过(可能需调整 yield/字段名,按编译器提示修)。

  • Step 5: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): /chat 改用交互式权限 + SSE 转发 permission_request"

Task 7: POST /chat/permission 端点

Files:

  • Modify: crates/atomcode-daemon/src/main.rs(新 handler + 路由)

  • Step 1: 写 handler

main.rs(chat 相关 handler 附近)加:

#[derive(Debug, Deserialize)]
pub struct PermissionDecisionRequest {
    pub session_id: String,
    /// "allow" | "deny" | "always_allow"
    pub decision: String,
}

async fn chat_permission(
    State(state): State<AppState>,
    Json(req): Json<PermissionDecisionRequest>,
) -> impl IntoResponse {
    use atomcode_core::tool::PermissionDecision;
    let decision = match req.decision.as_str() {
        "allow" => PermissionDecision::Allow,
        "always_allow" => PermissionDecision::Allow, // 见注
        _ => PermissionDecision::Deny,
    };
    // 注:always_allow 的「本会话该工具不再问」由 PermissionStore 承担;
    // Phase 1 先按 Allow 处理,AlwaysAllow 语义放到 Task 11(前端记住)或
    // 后续增强——此处保持决定路由职责单一。
    if state.pending_permissions.deliver(&req.session_id, decision) {
        Json(serde_json::json!({ "success": true }))
    } else {
        Json(serde_json::json!({ "success": false, "error": "no pending permission for session" }))
    }
}
  • Step 2: 加路由(受 token 中间件保护)

在 Router 中加:

        .route("/chat/permission", post(chat_permission))
  • Step 3: 编译

Run: cargo build -p atomcode-daemon Expected: 通过。

  • Step 4: 端到端手测(脚本化)
cargo run -p atomcode-daemon -- --port 13457 &
sleep 2
# 无对应 session 时应 success:false
curl -s -X POST http://127.0.0.1:13457/chat/permission \
  -H 'content-type: application/json' \
  -d '{"session_id":"ghost","decision":"deny"}' | grep -q '"success":false' && echo OK
kill %1

Expected: OK

  • Step 5: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): POST /chat/permission 回送权限决定"

Task 8: webui 路由套上 token 鉴权中间件

Files:

  • Modify: crates/atomcode-daemon/src/main.rs(给 /chat/chat/permission/sessions 等数据路由套 require_webui_token

说明:VSCode 扩展也连同一 daemon 且当前无 token。为不破坏既有客户端,Phase 1 仅对 webui 新增的敏感写操作路由/chat/chat/permission)要求 token;静态资源 / 不要求(首屏要能加载,token 在 URL query,由前端 JS 读取后用于后续 API)。读 main.rs 现有 SessionMode/client 区分逻辑,把 token 中间件做成「带 token 即放行;不带但来自 loopback + 旧客户端 header 也放行」的兼容策略,避免 break VSCode。

  • Step 1: 决策点确认(无代码,读现状)

main.rsactivity_tracker_middlewaremain.rs:732)与 SessionMode/--client Extension 注入处,确认 VSCode 客户端是否带可识别 header。

  • Step 2: 套中间件

对 webui 敏感路由分组套层(axum route_layer):

    let webui_protected = Router::new()
        .route("/chat", post(chat_stream))
        .route("/chat/permission", post(chat_permission))
        .route_layer(axum::middleware::from_fn_with_state(
            state.clone(),
            auth_token::require_webui_token,
        ));

并把这些路由从主 Router 移到 webui_protected,再 .merge(webui_protected)

若 VSCode 仍需无 token 访问 /chat,则改为「token 有效 旧客户端 header 存在」的放行逻辑(在 require_webui_token 内加 client 判断),不要简单 401。这是兼容性关键点,实现时务必先验证 VSCode 路径。

  • Step 3: 编译 + 回归手测

Run: cargo build -p atomcode-daemon 然后带/不带 token 各 curl 一次 /chat(带非法 body 即可,看是否 401 vs 400)。 Expected: 不带 token → 401;带有效 token → 进入 handler(400/业务错误)。

  • Step 4: 提交
git add crates/atomcode-daemon/src/main.rs
git commit -m "feat(daemon): webui 敏感路由套 token 鉴权(兼容既有客户端)"

Milestone 3 — 启动入口(TUI 命令 + CLI 子命令)

Task 9: 进程内启动器 ensure_server_and_open(放 daemon 库,供 tuix/cli 共用)

Files:

  • Modify: crates/atomcode-daemon/src/lib.rs(新增 ensure_server_and_open + 进程内单例 + mint_token 直调)

进程内模型:server 与调用方在同一进程,故不走 HTTP 健康检查/spawn,而是:用一个进程级 OnceCell 持有「server 已起」标记与共享的 WebuiTokenStore/AppState;首次调用时 tokio::spawn(run_server(...)),之后直接从同一个 WebuiTokenStore mint() token(无需 HTTP)。

  • Step 1: 写启动器

lib.rs 加:

use std::sync::OnceLock;

/// 进程内 webui server 句柄(保证只起一次)。
struct WebuiHandle {
    tokens: auth_token::WebuiTokenStore,
    port: u16,
}
static WEBUI: OnceLock<WebuiHandle> = OnceLock::new();

/// 确保进程内 server 已起,mint 一次性 token,开浏览器。返回给用户的状态串。
/// 在 `atomcode` 主程序(已有 tokio runtime)内调用。
pub async fn ensure_server_and_open(port: u16) -> String {
    let handle = WEBUI.get_or_init(|| {
        let tokens = auth_token::WebuiTokenStore::new();
        let opts = ServerOpts {
            host: "127.0.0.1".to_string(),
            port,
            cli_override: None,
            idle_timeout_secs: None,           // webui 进程内:不空闲自杀
            startup_mode: Default::default(),
            webui_tokens: Some(tokens.clone()), // run_server 用传入的同一个 store
        };
        // 在当前 runtime 上后台运行 server。
        tokio::spawn(async move {
            if let Err(e) = run_server(opts).await {
                eprintln!("webui server error: {e}");
            }
        });
        WebuiHandle { tokens, port }
    });

    // 给 server 一点 bind 时间(仅首次需要;用短轮询 /health 更稳,见 Step 2)。
    let token = handle.tokens.mint();
    let url = format!("http://127.0.0.1:{}/?token={}", handle.port, token);
    match atomcode_core::auth::oauth::open_browser(&url) {
        Ok(()) => format!("已在浏览器打开 webui:{url}"),
        Err(_) => format!("请手动在浏览器打开:{url}"),
    }
}

ServerOpts 需加一个 pub webui_tokens: Option<auth_token::WebuiTokenStore> 字段:Somerun_server 用它构 AppState(与启动器共享同一 store,token 才能跨「mint 端 / 校验端」一致), None(独立二进制/VSCode)时内部 WebuiTokenStore::new()。回到 Task 2 的 AppState 初始化处按此分支。

  • Step 2: 起后等待 bind 就绪(避免浏览器先于 server)

mint 前加一个 in-process 短等待:循环 tokio::net::TcpStream::connect(("127.0.0.1", port)) 最多 ~2s 直到成功(比固定 sleep 稳)。把这段加进 ensure_server_and_open 仅在「本次是 get_or_init 新建」时执行——用一个 bool 区分是否刚初始化。

  • Step 3: 编译

Run: cargo build -p atomcode-daemon Expected: 通过。

  • Step 4: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): ensure_server_and_open 进程内启动器"

Task 9b: /webui TUI 命令分支

Files:

  • Modify: crates/atomcode-tuix/Cargo.toml(加 atomcode-daemon = { path = "../atomcode-daemon" }

  • Modify: crates/atomcode-tuix/src/event_loop/commands.rsmatch cmd"webui"

  • Modify: crates/atomcode-tuix/src/commands.rs(注册命令 + i18n)

  • Step 1: tuix 依赖 daemon 库

crates/atomcode-tuix/Cargo.toml[dependencies]atomcode-daemon = { path = "../atomcode-daemon" }。 确认无依赖环(daemon→core 单向;tuix→daemon 新增;不成环)。cargo build -p atomcode-tuix 验证。

  • Step 2: match cmd 加分支

commands.rsmatch cmd { ... } 加(紧邻 "login";解析 stop 子参数留待 Task 16):

        "webui" => {
            // tuix 已在 tokio runtime 内(atomcode_tuix::run 是 async)。
            let msg = atomcode_daemon::ensure_server_and_open(13456).await;
            ctx.push_system_message(msg);  // 显示方式照同文件 "status"/"help" 分支
        }

若该 match 不在 async 上下文,则改为发一个事件到事件循环的 async 处理处再 .await; 读 commands.rs 现有 "login" 分支如何处理需 async 的 OAuth,照同样模式。 push_system_message 名称以本文件现有用法为准。

  • Step 3: 注册命令 + i18n

crates/atomcode-tuix/src/commands.rsCommandRegistry::builtin)注册 webui,中英描述:中文「启动浏览器 webui」,英文 "Launch the browser webui"(参照已有命令的 Msg:: 条目;i18n 文件 crates/atomcode-core/src/i18n/{en,zh_cn,messages}.rs)。

  • Step 4: 编译 + 手测

Run: cargo build -p atomcode-tuix 手测:跑 TUI,/webui,应在进程内起 server 并打开浏览器占位页(无需任何 daemon 二进制)。 Expected: 浏览器打开 127.0.0.1:13456/?token=... 占位页。

  • Step 5: 提交
git add crates/atomcode-tuix/Cargo.toml crates/atomcode-tuix/src/event_loop/commands.rs crates/atomcode-tuix/src/commands.rs crates/atomcode-core/src/i18n
git commit -m "feat(tuix): /webui 命令(进程内起 server + 开浏览器)"

Task 10: atomcode webui CLI 子命令

Files:

  • Modify: crates/atomcode-cli/Cargo.toml(加 atomcode-daemon 依赖)

  • Modify: crates/atomcode-cli/src/main.rsCommandsWebui

  • Step 1: cli 依赖 daemon 库

crates/atomcode-cli/Cargo.tomlatomcode-daemon = { path = "../atomcode-daemon" }

  • Step 2: 加子命令变体

Commands enum(紧邻 Daemon)加:

    /// 启动本地浏览器 webui(进程内起 server,无需额外二进制)
    Webui {
        /// 端口(默认 13456)
        #[arg(long, default_value = "13456")]
        port: u16,
    },
  • Step 3: 加分支(main 已是 #[tokio::main],可直接 await)

match 处理(紧邻 Commands::Daemon)加:

            Commands::Webui { port } => {
                HEADLESS_MODE.store(true, Ordering::Relaxed);
                let msg = atomcode_daemon::ensure_server_and_open(port).await;
                eprintln!("{msg}");
                // CLI 模式下 server 是后台 task;保持进程存活直到用户 Ctrl+C。
                tokio::signal::ctrl_c().await.ok();
                return Ok(0);
            }

与 Task 9 共用同一个 ensure_server_and_open(DRY,无重复逻辑)。

  • Step 4: 编译 + 手测

Run: cargo build -p atomcode-cli 手测:cargo run -p atomcode-cli -- webui,应进程内起 server 并开浏览器占位页。 Expected: 浏览器打开占位页;Ctrl+C 退出。

  • Step 5: 提交
git add crates/atomcode-cli/Cargo.toml crates/atomcode-cli/src/main.rs
git commit -m "feat(cli): atomcode webui 子命令(进程内)"

Milestone 4 — 前端 SPA

前端不走 TDD(无单测基建),以「构建通过 + 手动验证清单」为准。每个 Task 末尾 npm run build 产物落到 webui/dist/(Task 4 的 embed 源目录),随后 cargo build -p atomcode-daemon 重新嵌入。

Task 11: 前端工程脚手架(Preact + Vite + Tailwind)

Files:

  • Create: webui/package.jsonwebui/vite.config.tswebui/tsconfig.jsonwebui/tailwind.config.jswebui/index.htmlwebui/src/main.tsxwebui/src/app.tsx

  • Step 1: 初始化工程

cd webui
npm init -y
npm i preact
npm i -D vite @preact/preset-vite typescript tailwindcss postcss autoprefixer
npx tailwindcss init -p
  • Step 2: 配置 vite(产物到 dist,base 相对)

webui/vite.config.ts

import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  base: './',
  build: { outDir: 'dist', emptyOutDir: true },
  server: { port: 5173 },
});

webui/index.html

<!doctype html>
<html lang="zh">
  <head><meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>atomcode webui</title></head>
  <body><div id="app"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

webui/src/main.tsx

import { render } from 'preact';
import { App } from './app';
import './index.css';
render(<App />, document.getElementById('app')!);

webui/src/app.tsx(占位,下一 Task 填充):

export function App() {
  return <div class="p-4 text-lg">atomcode webui — loading…</div>;
}

webui/src/index.css@tailwind base; @tailwind components; @tailwind utilities; tailwind.config.jscontent: ["./index.html","./src/**/*.{ts,tsx}"]

  • Step 3: 构建并嵌入

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon Expected: webui/dist/ 生成,daemon 编译通过。

  • Step 4: 手测

cargo run -p atomcode-daemon -- --port 13457,浏览器开 127.0.0.1:13457/,应显示 "atomcode webui — loading…"。

  • Step 5: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 前端脚手架 Preact+Vite+Tailwind"

Task 12: API 客户端 + token 引导

Files:

  • Create: webui/src/api.ts

  • Step 1: 实现 api 模块

webui/src/api.ts

// 从 URL ?token= 读取一次性 token,存内存(不落 localStorage)。
const token = new URLSearchParams(location.search).get('token') ?? '';

function authHeaders(): Record<string, string> {
  return token ? { Authorization: `Bearer ${token}` } : {};
}

export async function listSessions() {
  const r = await fetch('/sessions', { headers: authHeaders() });
  return r.json();
}

export async function getConfig() {
  const r = await fetch('/config', { headers: authHeaders() });
  return r.json();
}

// 发送聊天消息,返回 SSE 流读取器。events 回调收到每条解析后的事件。
export async function streamChat(
  body: unknown,
  onEvent: (e: any) => void,
  signal?: AbortSignal,
) {
  const resp = await fetch('/chat', {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...authHeaders() },
    body: JSON.stringify(body),
    signal,
  });
  const reader = resp.body!.getReader();
  const dec = new TextDecoder();
  let buf = '';
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += dec.decode(value, { stream: true });
    const parts = buf.split('\n\n');
    buf = parts.pop() ?? '';
    for (const part of parts) {
      const line = part.split('\n').find((l) => l.startsWith('data:'));
      if (!line) continue;
      const data = line.slice(5).trim();
      if (!data) continue;
      try { onEvent(JSON.parse(data)); } catch { /* 非 JSON 心跳,忽略 */ }
    }
  }
}

export async function respondPermission(sessionId: string, decision: 'allow' | 'deny' | 'always_allow') {
  await fetch('/chat/permission', {
    method: 'POST',
    headers: { 'content-type': 'application/json', ...authHeaders() },
    body: JSON.stringify({ session_id: sessionId, decision }),
  });
}
  • Step 2: 构建确认无类型错误

Run: cd webui && npm run build Expected: 构建通过。

  • Step 3: 提交
git add webui/src/api.ts
git commit -m "feat(webui): API 客户端 + token 引导 + SSE 解析"

Task 13: 聊天界面 + 流式渲染

Files:

  • Create: webui/src/components/Chat.tsx

  • Modify: webui/src/app.tsx

  • Step 1: Chat 组件

webui/src/components/Chat.tsx:实现一个输入框 + 消息列表 + 发送。发送时调 streamChat,对事件按 type 累积:

  • TextDelta/文本增量 → 追加到当前 assistant 气泡
  • ToolCallStarted/ToolCallResult → 渲染工具行
  • permission_request → 通过 props 回调上抛给 App(Task 14 弹卡片)
  • TokenUsage/Error → 状态栏/错误提示
import { useState, useRef } from 'preact/hooks';
import { streamChat } from '../api';

type Msg = { role: 'user' | 'assistant'; text: string };

export function Chat({ onPermission }: { onPermission: (req: any) => void }) {
  const [msgs, setMsgs] = useState<Msg[]>([]);
  const [input, setInput] = useState('');
  const [busy, setBusy] = useState(false);
  const abort = useRef<AbortController | null>(null);

  async function send() {
    if (!input.trim() || busy) return;
    const userText = input;
    setMsgs((m) => [...m, { role: 'user', text: userText }, { role: 'assistant', text: '' }]);
    setInput('');
    setBusy(true);
    abort.current = new AbortController();
    try {
      await streamChat(
        { message: userText, session_id: sessionId, working_dir: cwd },  // cwd 由 props 传入(Task 15c);字段名以 daemon ChatRequest DTO 为准
        (e) => {
          if (e.type === 'permission_request') { onPermission(e); return; }
          const delta = e.text ?? e.delta ?? '';
          if (delta) {
            setMsgs((m) => {
              const copy = [...m];
              copy[copy.length - 1] = { role: 'assistant', text: copy[copy.length - 1].text + delta };
              return copy;
            });
          }
        },
        abort.current.signal,
      );
    } finally { setBusy(false); }
  }

  return (
    <div class="flex flex-col h-full">
      <div class="flex-1 overflow-y-auto p-4 space-y-3">
        {msgs.map((m, i) => (
          <div key={i} class={m.role === 'user' ? 'text-right' : 'text-left'}>
            <span class={`inline-block px-3 py-2 rounded-lg ${m.role === 'user' ? 'bg-blue-100' : 'bg-gray-100'}`}>
              {m.text || '…'}
            </span>
          </div>
        ))}
      </div>
      <div class="p-3 border-t flex gap-2">
        <input class="flex-1 border rounded px-3 py-2" value={input}
          onInput={(e) => setInput((e.target as HTMLInputElement).value)}
          onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="输入消息…" />
        <button class="px-4 py-2 bg-blue-600 text-white rounded" onClick={send} disabled={busy}>
          {busy ? '…' : '发送'}
        </button>
      </div>
    </div>
  );
}

/chat 请求体字段(message/session_id/working_dir 等)以 daemon chat_stream 的请求 DTO 为准——实现前读 main.rschat_streamreq 反序列化结构,对齐字段名。

  • Step 2: App 接入

app.tsx 渲染 <Chat onPermission={setPendingReq} />pendingReq 状态留给 Task 14。

  • Step 3: 构建 + 嵌入 + 手测

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon 手测:跑 daemon(需配好 provider),TUI /webui 打开,发一句话,应看到流式回复。 Expected: 文本流式出现。

  • Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 聊天界面 + 流式渲染"

Task 14: 工具批准卡片

Files:

  • Create: webui/src/components/PermissionCard.tsx

  • Modify: webui/src/app.tsx

  • Step 1: 卡片组件

webui/src/components/PermissionCard.tsx

import { respondPermission } from '../api';

export function PermissionCard({ req, onDone }: { req: any; onDone: () => void }) {
  async function decide(d: 'allow' | 'deny' | 'always_allow') {
    await respondPermission(req.session_id, d);
    onDone();
  }
  return (
    <div class="fixed inset-0 bg-black/40 flex items-center justify-center">
      <div class="bg-white rounded-lg p-5 max-w-lg w-full space-y-3">
        <h3 class="font-semibold text-lg">工具请求批准:{req.tool_name}</h3>
        <p class="text-sm text-gray-600">{req.reason}</p>
        <pre class="bg-gray-50 p-2 rounded text-xs overflow-x-auto">{JSON.stringify(req.arguments, null, 2)}</pre>
        <div class="flex gap-2 justify-end">
          <button class="px-3 py-1.5 rounded bg-gray-200" onClick={() => decide('deny')}>拒绝</button>
          <button class="px-3 py-1.5 rounded bg-blue-600 text-white" onClick={() => decide('allow')}>批准</button>
          <button class="px-3 py-1.5 rounded bg-green-600 text-white" onClick={() => decide('always_allow')}>本会话总是允许</button>
        </div>
      </div>
    </div>
  );
}
  • Step 2: App 状态接线

app.tsxconst [pendingReq, setPendingReq] = useState<any|null>(null);pendingReq 非空时渲染 <PermissionCard req={pendingReq} onDone={() => setPendingReq(null)} />

  • Step 3: 构建 + 嵌入 + 端到端手测

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon 手测:让 agent 触发一个需批准的工具(如写文件/跑命令),应弹卡片;点「批准」后 agent 继续执行;点「拒绝」agent 收到 Deny。 Expected: 批准/拒绝都能正确驱动 agent。

  • Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 交互式工具批准卡片"

Task 15: 会话侧栏 + 配置查看

Files:

  • Create: webui/src/components/Sidebar.tsxwebui/src/components/ConfigPanel.tsx

  • Modify: webui/src/app.tsx

  • Step 1: 侧栏(会话列表 + 切换)

Sidebar.tsx:调 listSessions() 渲染历史会话;点击切换当前会话(把 session_id 传给 Chat,发送时带上)。新建会话按钮清空当前。

  • Step 2: 配置面板(只读展示)

ConfigPanel.tsx:调 getConfig() 展示当前 provider/model/workdir(/config 已脱敏,无 api_key)。Phase 1 只读,编辑能力后续再加。

  • Step 3: App 布局组装

app.tsx:左侧 Sidebar,主区 Chat,顶部一个「配置」按钮开 ConfigPanel。整体两栏布局(Tailwind flex)。

  • Step 4: 构建 + 嵌入 + 手测

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon 手测:侧栏显示历史会话、能切换、能看到 provider 配置。 Expected: 三项都正常。

  • Step 5: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 会话侧栏 + 配置查看"

Milestone 4.5 — 工作目录切换

Task 15a: GET /fs/list 目录列举端点

Files:

  • Modify: crates/atomcode-daemon/src/lib.rs(新 handler + 路由)

  • Step 1: 写测试(路径规范化纯函数)

把规范化逻辑抽成可测纯函数,在 lib.rs(或 webui.rs)测:

#[cfg(test)]
mod fs_list_tests {
    use super::*;
    #[test]
    fn expands_tilde() {
        let home = atomcode_core::tool::real_home_dir().unwrap();
        assert_eq!(normalize_dir_arg("~"), home);
        assert_eq!(normalize_dir_arg("~/x"), home.join("x"));
    }
    #[test]
    fn rejects_non_dir_gracefully() {
        // 不存在的路径返回 None / Err,不 panic
        assert!(list_subdirs(std::path::Path::new("/no/such/dir/xyz")).is_err());
    }
}
  • Step 2: 运行确认失败

Run: cargo test -p atomcode-daemon fs_list Expected: 编译失败(normalize_dir_arg/list_subdirs 未定义)。

  • Step 3: 实现
use std::path::{Path, PathBuf};

/// 展开 `~`,返回绝对路径(不校验存在性)。复用与 /cd 一致的展开规则。
pub fn normalize_dir_arg(arg: &str) -> PathBuf {
    if let Some(rest) = arg.strip_prefix('~') {
        if let Some(home) = atomcode_core::tool::real_home_dir() {
            return home.join(rest.trim_start_matches('/'));
        }
    }
    PathBuf::from(arg)
}

/// 列出某目录下的子目录名(不含文件、不递归)。
pub fn list_subdirs(dir: &Path) -> anyhow::Result<Vec<String>> {
    let mut out = Vec::new();
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        if entry.file_type()?.is_dir() {
            if let Some(name) = entry.file_name().to_str() {
                if !name.starts_with('.') {           // 跳过隐藏目录
                    out.push(name.to_string());
                }
            }
        }
    }
    out.sort();
    Ok(out)
}

#[derive(Deserialize)]
pub struct FsListQuery { pub path: String }

async fn fs_list(
    State(_state): State<AppState>,
    Query(q): Query<FsListQuery>,
) -> impl IntoResponse {
    let dir = normalize_dir_arg(&q.path).canonicalize().unwrap_or_else(|_| normalize_dir_arg(&q.path));
    match list_subdirs(&dir) {
        Ok(dirs) => Json(serde_json::json!({
            "path": dir.to_string_lossy(),
            "dirs": dirs,
        })).into_response(),
        Err(e) => json_error(StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

路由(受 token 中间件保护,见 Task 8):.route("/fs/list", get(fs_list))

安全:仅 loopback 绑定 + token 保护;只返回目录名不返回文件内容。canonicalize 消解 ../符号链接。Phase 1 不额外限制根(本地工具,用户本就有 FS 权限),但隐藏 dotfiles 减少噪声。

  • Step 4: 测试 + 手测

Run: cargo test -p atomcode-daemon fs_list && cargo build -p atomcode-daemon 手测:起 server 后 curl 'http://127.0.0.1:13457/fs/list?path=~' -H 'Authorization: Bearer <tok>' 返回子目录列表。

  • Step 5: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): GET /fs/list 目录列举端点"

Task 15b: 前端目录切换器组件

Files:

  • Create: webui/src/components/CwdPicker.tsx

  • Modify: webui/src/api.ts(加 listDir/getProjects/changeDir)、webui/src/app.tsx

  • Step 1: api 加目录相关调用

api.ts 追加:

export async function listDir(path: string) {
  const r = await fetch(`/fs/list?path=${encodeURIComponent(path)}`, { headers: authHeaders() });
  return r.json() as Promise<{ path: string; dirs: string[] }>;
}
export async function getProjects() {
  const r = await fetch('/projects', { headers: authHeaders() });
  return r.json();
}
export async function setDefaultDir(path: string) {  // 可选「设为 daemon 默认」
  await fetch('/cd', { method: 'POST', headers: { 'content-type': 'application/json', ...authHeaders() }, body: JSON.stringify({ path }) });
}
  • Step 2: CwdPicker 组件

CwdPicker.tsx:实现 mockup(webui/mockup.html#cwdModal)那套——路径输入框(~ 支持)、面包屑 + 子目录点选(调 listDir)、最近项目列表(调 getProjects)、底部「同时设为默认目录」checkbox(勾选时额外调 setDefaultDir)。选定后 onPick(path) 回调上抛,关闭弹窗。

import { useState, useEffect } from 'preact/hooks';
import { listDir, getProjects, setDefaultDir } from '../api';

export function CwdPicker({ current, onPick, onClose }:
  { current: string; onPick: (p: string) => void; onClose: () => void }) {
  const [path, setPath] = useState(current);
  const [dirs, setDirs] = useState<string[]>([]);
  const [projects, setProjects] = useState<any[]>([]);
  const [asDefault, setAsDefault] = useState(false);

  useEffect(() => { listDir(path).then((r) => setDirs(r.dirs)).catch(() => setDirs([])); }, [path]);
  useEffect(() => { getProjects().then((r) => setProjects(r.projects ?? r ?? [])).catch(() => {}); }, []);

  async function confirm(p: string) {
    if (asDefault) await setDefaultDir(p);
    onPick(p);
    onClose();
  }
  // 渲染:输入框 + 子目录列表(点击 setPath(`${path}/${d}`)) + 最近项目 + 确定/取消
  // 样式照 mockup #cwdModal。
  return (/* … 见 mockup … */ null as any);
}
  • Step 3: App 接线 + 顶栏面包屑

app.tsx:维护 const [cwd, setCwd] = useState<string>(/* 初始取 /project */);顶栏 cwd 面包屑点击打开 CwdPickeronPicksetCwd。把 cwd 作为 prop 传给 Chat(Task 13 的 working_dir: cwd)。

  • Step 4: 初始 cwd 来源

App 挂载时调 GET /project 取当前 working_dir 作为初始 cwd;切换会话时用该 session 的 working_dir(来自 /sessions 列表项)。

  • Step 5: 构建 + 嵌入 + 手测

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon 手测:顶栏面包屑可点 → 弹切换器 → 浏览子目录/选最近项目 → 选定后发消息时 agent 在新目录下操作(让它 ls/读文件验证 cwd 生效)。 Expected: cwd 切换对 agent 工具生效,且不影响其它客户端(不勾「默认」时)。

  • Step 6: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 工作目录切换器(每会话 cwd + 目录浏览)"

Milestone 5 — 收尾

Task 16: /webui stop + dev 模式 + 文档

Files:

  • Modify: crates/atomcode-tuix/src/event_loop/commands.rswebui 分支支持 stop 参数)

  • Modify: crates/atomcode-daemon/src/webui.rsATOMCODE_WEBUI_DEV 重定向到 vite dev server)

  • Create/Modify: webui/README.md(构建说明);主 README.md/README.zh-CN.md 加 webui 段

  • Step 1: /webui stop

webui 分支解析 arg:stop 时停掉进程内 server。在 Task 9 的 WebuiHandle 里存 server task 的 tokio::task::AbortHandle(或一个 watch::Sender<bool> shutdown 信号),stop 时 abort/发信号并 把 WEBUI 标记复位(或仅标记为已停,下次 /webui 重新起)。提示「已关闭 webui server」。

因 server 在进程内,不用 HTTP /shutdown;直接操作 task handle 更可靠。

  • Step 2: dev 重定向

webui::serve_webui 开头:若 std::env::var("ATOMCODE_WEBUI_DEV") 存在,则 302 重定向到该地址 + 原 path,便于前端热更新开发。

  • Step 3: 文档

webui/README.md 写:开发 npm run dev + 设 ATOMCODE_WEBUI_DEV;发布 npm run buildcargo build。主 README 加 /webui 用法与「作为 TUI 之外入口」的定位说明。

  • Step 4: 构建 + 全量编译 + 手测

Run: cd webui && npm run build && cd .. && cargo build && cargo test -p atomcode-daemon 手测:/webui stop 能停进程内 server;设 ATOMCODE_WEBUI_DEV=http://localhost:5173 后访问根路径重定向到 vite。 Expected: 均正常。

  • Step 5: 提交
git add webui/ crates/ README.md README.zh-CN.md
git commit -m "feat(webui): /webui stop + dev 重定向 + 文档"

Task 17: 构建管线集成

Files:

  • Modify: 发布脚本(scripts/ 下相关打包脚本)

  • Step 1: 找到发布脚本

ls scripts/:在打包 主程序 atomcode 的脚本(release.sh/*-release-*.sh)里,于 cargo build --release 之前插入 (cd webui && npm ci && npm run build),保证嵌入主程序的是最新前端。

关键:前端现在嵌入的是 atomcode 主程序(经 daemon 库 run_server 服务),不再只是 daemon 二进制——主程序的发布管线必须包含前端构建。

  • Step 2: 把 webui/dist 是否入库的决策落地

二选一并在脚本/README 注明:(a) webui/dist 不入库(.gitignore 加它,构建时现生成)——推荐,避免产物进 git;(b) 入库便于无 node 环境直接 cargo build。Phase 1 取 (a),但需保证 webui/dist/index.html 在 CI 之外的 cargo build 时存在——故 rust-embedfolder 目录至少要有占位文件,或脚本保证先构建。

注意与 Task 3 的占位 webui/dist/index.html 协调:若选 (a) 则该占位文件改为 .gitkeep + build.rs 保障;实现时统一。

  • Step 3: 提交
git add scripts/ .gitignore
git commit -m "build: 发布管线集成 webui 前端构建"

Milestone 6 — Phase 1.5:TUI ⇄ webui 实时同步(方案 A)

前提:Milestone 0–5 已完成(基础 webui 跑通)。本里程碑碰 TUI 事件循环,独立于基础功能、最后做。 模型:进程内「活动会话总线」LiveSession——单一 Conversation + broadcast<TurnEvent> 扇出 + 单一 mpsc<UserInput> 输入 + 单写者守卫。TUI 与浏览器退化为「订阅渲染 + 投递输入」的视图。 默认:webui 默认独立,开关「同步当前 TUI 会话」才走总线;turn 进行中另一端输入禁用并提示; 晚加入先 replay 快照再接增量;审批两端都收、先到先得。

Task 18: LiveSession 总线(core,可单测)

Files:

  • Create: crates/atomcode-core/src/turn/live_session.rs

  • Modify: crates/atomcode-core/src/turn/mod.rspub mod live_session;

  • Step 1: 写测试

live_session.rs 底部:

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn broadcast_reaches_multiple_subscribers() {
        let live = LiveSession::new();
        let mut a = live.subscribe();
        let mut b = live.subscribe();
        live.emit(TurnEvent::TextDelta("hi".into()));
        assert!(matches!(a.recv().await.unwrap(), TurnEvent::TextDelta(t) if t == "hi"));
        assert!(matches!(b.recv().await.unwrap(), TurnEvent::TextDelta(t) if t == "hi"));
    }

    #[test]
    fn turn_guard_blocks_second_start() {
        let live = LiveSession::new();
        assert!(live.try_begin_turn(), "first begin should succeed");
        assert!(!live.try_begin_turn(), "second begin must be rejected while running");
        live.end_turn();
        assert!(live.try_begin_turn(), "begin after end should succeed");
    }
}
  • Step 2: 运行确认失败

Run: cargo test -p atomcode-core live_session Expected: 编译失败(LiveSession 未定义)。

  • Step 3: 实现
//! 进程内「活动会话总线」:TUI 与 webui 共享同一活动会话的单一数据源 + 事件扇出。
//! 同进程共享内存,故无需跨进程/网络同步。

use std::sync::{Arc, Mutex};
use tokio::sync::{broadcast, mpsc};
use crate::conversation::Conversation;
use crate::turn::event::TurnEvent;

/// 任一视图提交的用户输入。
#[derive(Debug, Clone)]
pub struct UserInput {
    pub text: String,
    pub working_dir: Option<std::path::PathBuf>,
}

#[derive(Clone, Copy, PartialEq)]
pub enum TurnState { Idle, Running }

#[derive(Clone)]
pub struct LiveSession {
    conversation: Arc<Mutex<Conversation>>,
    events: broadcast::Sender<TurnEvent>,
    input_tx: mpsc::UnboundedSender<UserInput>,
    input_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<UserInput>>>>, // 协调器取走
    turn_state: Arc<Mutex<TurnState>>,
}

impl LiveSession {
    pub fn new() -> Self {
        let (events, _) = broadcast::channel(256);
        let (input_tx, input_rx) = mpsc::unbounded_channel();
        Self {
            conversation: Arc::new(Mutex::new(Conversation::default())),
            events,
            input_tx,
            input_rx: Arc::new(Mutex::new(Some(input_rx))),
            turn_state: Arc::new(Mutex::new(TurnState::Idle)),
        }
    }

    /// 视图订阅事件流(TUI 渲染 / webui SSE)。
    pub fn subscribe(&self) -> broadcast::Receiver<TurnEvent> { self.events.subscribe() }
    /// 协调器/handler 向所有视图广播一个事件。
    pub fn emit(&self, ev: TurnEvent) { let _ = self.events.send(ev); }
    /// 任一视图投递用户输入。
    pub fn submit(&self, input: UserInput) -> bool { self.input_tx.send(input).is_ok() }
    /// 协调器启动时取走唯一的输入接收端。
    pub fn take_input_rx(&self) -> Option<mpsc::UnboundedReceiver<UserInput>> {
        self.input_rx.lock().unwrap().take()
    }
    /// 单写者守卫:开始一个 turn(已在跑则返回 false)。
    pub fn try_begin_turn(&self) -> bool {
        let mut s = self.turn_state.lock().unwrap();
        if *s == TurnState::Running { return false; }
        *s = TurnState::Running; true
    }
    pub fn end_turn(&self) { *self.turn_state.lock().unwrap() = TurnState::Idle; }
    pub fn is_running(&self) -> bool { *self.turn_state.lock().unwrap() == TurnState::Running }
    /// 当前会话消息快照(webui 晚加入时先 replay)。
    pub fn snapshot(&self) -> Vec<crate::conversation::message::Message> {
        self.conversation.lock().unwrap().messages.clone()
    }
    pub fn conversation(&self) -> Arc<Mutex<Conversation>> { self.conversation.clone() }
}

impl Default for LiveSession { fn default() -> Self { Self::new() } }

Conversation 是否 Defaultmessages 字段名以 core 实际为准(daemon 已用 conv.messages.clone())。

  • Step 4: 测试通过

Run: cargo test -p atomcode-core live_session Expected: 2 测试 PASS。

  • Step 5: 提交
git add crates/atomcode-core/src/turn/live_session.rs crates/atomcode-core/src/turn/mod.rs
git commit -m "feat(core): LiveSession 进程内活动会话总线"

Task 19: turn 协调器(驱动总线上的 turn)

Files:

  • Modify: crates/atomcode-core/src/turn/live_session.rs(加 run_coordinator

  • Step 1: 实现协调器

/// 单一 turn 协调器:从 input 取消息,单写者跑 turn,事件发 broadcast。
/// turn 执行复用 TurnRunner——构造方式对齐 daemon `chat_stream`(provider/tools/ctx/permission 同源)。
pub async fn run_coordinator(
    live: LiveSession,
    mut build_runner: impl FnMut() -> crate::turn::runner::TurnRunner,
) {
    let Some(mut input_rx) = live.take_input_rx() else { return };
    while let Some(input) = input_rx.recv().await {
        if !live.try_begin_turn() {
            live.emit(TurnEvent::Warning("已有对话进行中,已忽略本次输入".into()));
            continue;
        }
        { let mut conv = live.conversation().lock().unwrap();
          conv.push_user_text(&input.text); }   // 方法名以 Conversation 实际 API 为准
        let (turn_tx, mut turn_rx) = mpsc::unbounded_channel::<TurnEvent>();
        let live2 = live.clone();
        let pump = tokio::spawn(async move {
            while let Some(ev) = turn_rx.recv().await { live2.emit(ev); }
        });
        let mut runner = build_runner();
        // runner.run(&mut conv, &system_prompt, &turn_tx, cancel) —— 签名对齐 daemon chat_stream
        let _ = pump.await;
        live.end_turn();
    }
}

本步「依现状对齐」最重:TurnRunner::run 精确签名、Conversation 追加/锁用法、system_prompt 构造, 全照 daemon chat_stream(Task 0 已迁入 lib.rs)抄同一套,只把事件 sink 从 SSE 换成 live.emit

  • Step 2: 编译

Run: cargo build -p atomcode-core Expected: 通过(签名对齐后)。

  • Step 3: 提交
git add crates/atomcode-core/src/turn/live_session.rs
git commit -m "feat(core): LiveSession turn 协调器(单写者 + 事件扇出)"

Task 20: server /live SSE + POST /live/message

Files:

  • Modify: crates/atomcode-daemon/src/lib.rs(AppState 持有 Option<LiveSession>;新路由)

  • Step 1: AppState 持有 LiveSession + 启动协调器

AppState 加 pub live: Arc<RwLock<Option<atomcode_core::turn::live_session::LiveSession>>>。 首次开启同步时创建 LiveSessiontokio::spawn(run_coordinator(...)),存入。

  • Step 2: GET /live(快照 replay + broadcast)
async fn live_stream(State(state): State<AppState>) -> impl IntoResponse {
    let live = state.live.read().await.clone();
    let stream = async_stream::stream! {
        if let Some(live) = live {
            for msg in live.snapshot() {
                yield Ok::<_, std::convert::Infallible>(sse_json(&serde_json::json!({
                    "type": "snapshot_message", "message": msg })));
            }
            let mut rx = live.subscribe();
            while let Ok(ev) = rx.recv().await { yield Ok(sse_json(&turn_event_to_json(&ev))); }
        }
    };
    Sse::new(stream).keep_alive(Default::default())
}

sse_json/turn_event_to_json 复用 Task 6 的 SSE 序列化逻辑,抽成函数共用。)

  • Step 3: POST /live/message(投 input_tx)
async fn live_message(State(state): State<AppState>, Json(req): Json<ChatRequest>) -> impl IntoResponse {
    if let Some(live) = state.live.read().await.clone() {
        if live.is_running() {
            return Json(serde_json::json!({ "success": false, "error": "turn_in_progress" }));
        }
        live.submit(atomcode_core::turn::live_session::UserInput {
            text: req.message, working_dir: req.working_dir,
        });
        Json(serde_json::json!({ "success": true }))
    } else {
        Json(serde_json::json!({ "success": false, "error": "no_live_session" }))
    }
}

路由(受 token 中间件):.route("/live", get(live_stream)).route("/live/message", post(live_message))

  • Step 4: 编译

Run: cargo build -p atomcode-daemon Expected: 通过。

  • Step 5: 提交
git add crates/atomcode-daemon/src/lib.rs
git commit -m "feat(daemon): /live SSE 快照+实时流 + /live/message 输入"

Task 21: TUI 同步模式(输入走总线 + 渲染订阅 broadcast)

Files:

  • Modify: crates/atomcode-tuix/src/event_loop/mod.rscommands.rs

  • Step 1: /webui sync 开启同步

webui 分支支持 sync 参数:开启时用 TUI 当前会话初始化共享 LiveSessionconversation, 启动协调器(若未启),开关状态存进 TUI state。

  • Step 2: 输入改道

同步开启时,TUI 发送逻辑(现走 AgentLoop/agent_handle)改为 live.submit(UserInput{...}); 读现有 handle_send_message 路径,加 if state.synced { live.submit } else { 现状 } 分叉。

  • Step 3: 渲染订阅 broadcast

同步开启时 spawn 任务 live.subscribe()TurnEvent 喂进 TUI 现有事件渲染入口(与 agent_handle 事件同一 render sink)。turn 进行中禁用输入框并提示「对方正在对话」。

最依赖现状:读 event_loop/mod.rs 里 agent 事件如何进入渲染(TurnEvent 处理处),把 broadcast 事件接到同一处。不改渲染本身,只多一个事件来源。

  • Step 4: 编译 + 端到端手测

Run: cargo build -p atomcode-tuix 手测:TUI /webui sync → 浏览器开同步 → 浏览器发消息 TUI 实时渲染;TUI 发消息浏览器实时渲染; turn 进行中两端输入禁用。 Expected: 双向实时同步生效。

  • Step 5: 提交
git add crates/atomcode-tuix/src
git commit -m "feat(tuix): /webui sync 同步模式(输入走总线+渲染订阅)"

Task 22: 前端同步开关 + /live 接入

Files:

  • Modify: webui/src/api.tswebui/src/app.tsxwebui/src/components/Chat.tsx

  • Step 1: api 加 /live

export function streamLive(onEvent: (e: any) => void, signal?: AbortSignal) {
  return streamGet('/live', onEvent, signal); // GET SSE,解析逻辑同 streamChat
}
export async function sendLive(message: string, workingDir?: string) {
  const r = await fetch('/live/message', { method: 'POST',
    headers: { 'content-type': 'application/json', ...authHeaders() },
    body: JSON.stringify({ message, working_dir: workingDir }) });
  return r.json();
}
  • Step 2: 同步开关 + 接入

顶栏加「🔗 同步 TUI 会话」开关。开启时 Chat 改用 streamLive(先收 snapshot_message 渲染历史、 再接增量),发送改用 sendLive;收到 turn_in_progress 或运行中事件时禁用输入并提示「对方正在对话」。 关闭时回到独立 /chat 模式。

  • Step 3: 构建 + 嵌入 + 手测

Run: cd webui && npm run build && cd .. && cargo build -p atomcode-daemon 手测:开关开启后与 TUI 双向实时同步;关闭后独立。

  • Step 4: 提交
git add webui/ crates/atomcode-daemon
git commit -m "feat(webui): 同步 TUI 会话开关 + /live 接入"

Task 23: 同步模式下的工具审批(两端可批,先到先得)

Files:

  • Modify: crates/atomcode-core/src/turn/live_session.rs 或协调器;crates/atomcode-tuixwebui

  • Step 1: 审批走 broadcast

协调器跑 turn 时权限请求作为 TurnEvent::ApprovalRequested 进 broadcast → TUI 与浏览器同时弹卡片。 决定回送到协调器持有的单一 response 通道(PermissionResponders 思路,key 为活动会话)。

  • Step 2: 先到先得

第一个到达的决定生效,置 decided 标记;后到的忽略。broadcast 一条 permission_resolved { call_id }, 两端收到后关闭各自卡片。

  • Step 3: 编译 + 手测

手测:同步态触发需审批工具,TUI 和浏览器同时弹卡片;任一端批准,另一端卡片自动消失,agent 继续。 Expected: 无双重批准、无卡死。

  • Step 4: 提交
git add crates/ webui/
git commit -m "feat(webui): 同步模式工具审批两端可批先到先得"

Self-Review 检查记录

  • Spec 覆盖:定位(M3/M4 入口+完整聊天) ✓;嵌入二进制(Task 3-4,嵌入主程序经 run_server 服务) ✓; 服务形态=合进主程序进程内(Task 0 库化 + Task 9 进程内启动器) ✓;daemon 后端复用(全程) ✓; 交互式权限(M2) ✓;本地 token 安全(M0) ✓;工作目录每会话独立+/fs/list(M4.5) ✓; 会话独立共享历史(Task 15 走 /sessions) ✓;Phase 2 可插拔鉴权(auth_token.rs 注释 + 收口) ✓; Origin 校验(复用现有 origin_is_allowed,Task 8) ✓;体积 feature 门控(spec 体积控制节,实现于 Cargo feature) ✓; 测试策略(各 backend Task 带单测 + 前端手测清单) ✓。
  • 依赖关系:daemon→core(单向,既有);tuix→daemon(新增,Task 9b);cli→daemon(新增,Task 10)。 均不成环(core 不依赖 daemon)。run_server 库化(Task 0) 是这一切的前置,必须最先做
  • 待实现者现场核对的点(非占位,「依现状对齐」):① Task 0 库化是机械迁移,逐个编译错误修、不改行为; ② axum 0.7 中间件签名以 activity_tracker_middleware(main.rs:732) 为准;③ ToolCall 字段名; ④ chat_streamyield/stream 宏形式与 ChatRequest 字段;⑤ VSCode 客户端兼容(Task 8 关键); ⑥ push_system_message 真实方法名 + match cmd 是否 async 上下文(Task 9b); ⑦ ServerOptswebui_tokens: Option<WebuiTokenStore> 字段贯穿 Task 0/2/9。
  • DRYensure_server_and_open 单一实现放 daemon 库,tuix 与 cli 共用(Task 9/9b/10),无重复。
  • 类型一致WebuiTokenStorePermissionRespondersServerOpts{webui_tokens}PermissionDecision(Allow|Ask|Deny)、SSE type:"permission_request"/chat/permissiondecision:"allow"|"deny"|"always_allow"/fs/list{path,dirs} 全程一致。
  • 分发修复:原计划假设 atomcode-daemon 二进制随主程序分发(实际 install.sh 只装主程序)—— 改为进程内库调用后,普通用户零额外下载即可 /webui
  • Phase 1.5(M6)实时同步:依赖进程内同进程前提(M0 库化 + M3 进程内 server);LiveSession 放 core(tuix/daemon 都依赖 core,无环)。最重的「依现状对齐」是 Task 19/21——TurnRunner::run 签名、Conversation API、TUI 渲染事件入口,均照 daemon chat_stream 与 tuix 现有 agent 事件路径抄。 默认值(已确认):webui 默认独立、开关同步;turn 进行中另端输入禁用;晚加入 replay 快照; 审批两端先到先得。M6 独立于 M0–M5,建议基础 webui 跑通后再做。