From 98da810a60e0fe13d603f4656ce95439995e4693 Mon Sep 17 00:00:00 2001 From: wt <123@qq.com> Date: Sat, 20 Jun 2026 02:49:01 +0800 Subject: [PATCH 1/2] feat: sync agent controls and host ports --- .claude/settings.local.json | 3 +- AGENTS.md | 32 ++ CLAUDE.md | 81 ++++ PROJECT_OVERVIEW.md | 361 ++++++++++++++++++ evcod/core/internal/api/router.go | 19 + evcod/core/internal/domain/models.go | 5 + evcod/core/internal/services/host.go | 66 ++++ .../core/internal/services/host_ports_test.go | 29 ++ evcod/core/internal/services/services.go | 81 ++++ evcod/dev.command | 0 evcod/webui/src/api.ts | 9 +- .../webui/src/components/ConversationView.tsx | 64 +++- .../webui/src/components/DirectoryPicker.tsx | 6 +- evcod/webui/src/components/HostWorkspace.tsx | 4 +- evcod/webui/src/types.ts | 5 + evcod_warp/src/native-agent.js | 196 +++++++++- evcod_warp/src/normalizer.js | 71 ++-- evcod_warp/src/opencode-tui.js | 49 ++- evcod_warp/test/native-agent.test.js | 321 +++++++++++++++- evcod_warp/test/normalizer.test.js | 41 +- evcod_warp/test/opencode-tui.test.js | 82 ++++ 21 files changed, 1453 insertions(+), 72 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 PROJECT_OVERVIEW.md create mode 100644 evcod/core/internal/services/host_ports_test.go mode change 100644 => 100755 evcod/dev.command diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e234900..a9c5125 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,8 @@ "permissions": { "allow": [ "Bash(node --check src/native-agent.js)", - "Bash(npm test *)" + "Bash(npm test *)", + "Bash(xargs cat)" ] } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..785dcab --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +This monorepo contains three active code areas. `evcod/core/` is the Go backend service, with the entry point in `cmd/evcod-core/` and internal packages under `internal/` for API routing, services, storage, platform integration, events, and config. `evcod/webui/` is the React 19 + Vite + TypeScript client; source lives in `src/`, reusable UI in `src/components/`, chat-specific code in `src/chat/`, browser scripts in `tests/`, and static files in `public/`. `evcod_warp/` is a Node.js ESM CLI bridge; runtime code is in `src/`, command entry points are in `bin/`, and Node tests are in `test/`. Documentation is mainly under `evcod/docs/`, `chat_diff/`, and Chinese review/design documents at the repo root. + +## Build, Test, and Development Commands + +- `evcod/dev.command`: start the local development environment, including core and WebUI. +- `evcod/test_scripts/run_full_tests.sh`: run the full pipeline: Go formatting/tests/build, core smoke tests, warp tests, WebUI build, and browser flows. +- `evcod/test_scripts/run_full_tests.sh --no-browser`: run the pipeline without Playwright browser flows. +- `go -C evcod/core test ./...`: run all Go tests. +- `go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core`: build the core binary. +- `npm --prefix evcod/webui run build`: type-check and build the WebUI. +- `npm --prefix evcod/webui run dev`: start Vite on `127.0.0.1`. +- `npm --prefix evcod_warp test`: run Node tests for the warp CLI. + +## Coding Style & Naming Conventions + +Format Go with `gofmt`; use package-local tests named `*_test.go`. TypeScript and React files use ESM imports, functional components, and existing component naming such as `ConversationView.tsx`. Keep Node code in `evcod_warp` as ESM and require Node `>=22`. Match the surrounding documentation language; many docs and comments are Chinese. + +## Testing Guidelines + +Place Go tests beside the package they cover. WebUI browser flows are standalone Node/Playwright scripts in `evcod/webui/tests/` and may require `EVCOD_CORE_URL`, `EVCOD_WEB_URL`, and `EVCOD_API_KEY`. Warp tests use the built-in `node --test` runner and follow `*.test.js` naming. + +## Commit & Pull Request Guidelines + +Recent commits mostly use Conventional Commit style, for example `feat(core,webui,warp): ...`, `fix: ...`, `perf: ...`, and `test(webui): ...`. Prefer scoped, imperative subjects. Pull requests should summarize behavior changes, list validation commands run, link related issues or design docs, and include screenshots or recordings for visible WebUI changes. + +## Security & Configuration Tips + +Core requests require API keys. Use `go -C evcod/core run ./cmd/evcod-core key print` to create or print the default key and `key rotate` when credentials may be exposed. Avoid committing local state files, generated archives, or secrets. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..238bc2a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository layout + +This is a multi-module monorepo for **evcod**, a local-first remote terminal/coding-assistant tool. Three code modules plus Chinese design docs: + +- `evcod/core/` — Go 1.26 backend service (`evcod-core` binary). Auth, projects, files, Git, workspace/worktrees, terminals (PTY), conversations, agent sessions, host metrics, and local state persistence. Serves both REST + WebSocket and (optionally) the built WebUI. +- `evcod/webui/` — React 19 + Vite 6 + TypeScript browser client (`evcod-webui`). Connects to one or more cores; provides terminals, file/Git panels, host dashboard, and chat. State via zustand; terminals via xterm; code editing via Monaco. +- `evcod_warp/` — Node.js (>=22, ESM) CLI `evcod-warp`. Bridges external AI agents (`claude`, `codex`, `gemini`, `qwen`, `opencode`, plus `fake` test fixture) to a running core, mirroring their conversation into the web chat. +- `evcod/docs/`, `chat_diff/`, `审查报告/`, `其他/`, `Chat 对齐文档.md` — Chinese design/architecture/review documents. API contract lives in `evcod/docs/api/` (see `rpc-protocol.md`, `overview.md`, `openapi.json`). + +## Common commands + +All commands below assume the repo root unless noted. The Go module path is `evcod/core`; use `go -C ` rather than `cd`. + +### Run the full dev environment +```bash +evcod/dev.command # builds core, starts core (:10065) + webui dev (:10066), prints API key +``` + +### Full test/build pipeline (the CI equivalent) +```bash +evcod/test_scripts/run_full_tests.sh # gofmt, go test, build, core API smoke, warp tests, webui build + browser E2E +evcod/test_scripts/run_full_tests.sh --no-browser # skip the Playwright browser flows +``` + +### Core (Go) +```bash +go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core +go -C evcod/core test ./... # all tests +go -C evcod/core test ./internal/services -run TestAgentCatalog # single test +gofmt -w evcod/core # format (run before committing Go) +go -C evcod/core run ./cmd/evcod-core key print # print/create default API key +go -C evcod/core run ./cmd/evcod-core key rotate # revoke all keys, create a new one +go -C evcod/core run ./cmd/evcod-core serve # run server (default bind 127.0.0.1:4865) +``` + +### WebUI (Node) +```bash +npm --prefix evcod/webui ci # install +npm --prefix evcod/webui run dev # vite dev server +npm --prefix evcod/webui run build # tsc -b && vite build +# Browser E2E (Playwright-driven plain node scripts; require a running core + webui): +EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke +# also: test:full-flow, test:feature-flow, test:agent-pane, test:agent-all +``` + +### Warp (Node) +```bash +npm --prefix evcod_warp test # node --test, all test/*.test.js +node --test evcod_warp/test/normalizer.test.js # single test file +node evcod_warp/bin/evcod-warp.js --list # list available agent backends +``` + +## Architecture + +### Core service (Go) +Layered, dependency flows inward: `cmd/evcod-core/main.go` → `internal/app` → `internal/api` (HTTP/WS router) → `internal/services` → `internal/store` + `internal/platform` + `internal/events`. + +- **`app.New` wiring**: opens the store, creates the events hub, constructs `services.Services` (a struct aggregating `Keys`, `Projects`, `Files`, `Git`, `Terminal`, `Chat`, `Agent`, `Host`, workspace). `Serve` ensures an API key, exports `EVCOD_CORE_URL`/`EVCOD_API_KEY` into the process env (so a colocated warp/agent can find the core), starts the host metrics loop and the scheduled chat queue, then serves HTTP. +- **Store (`internal/store/store.go`)**: persistence is a **single JSON file** loaded fully into memory behind a `sync.RWMutex` — there is no SQL database despite the `--db` flag naming. The `state` struct is the entire schema; every entity list (projects, worktrees, conversations, messages, agent sessions/turns, timeline events, terminal panes, host metrics, audit logs, etc.) lives there and is rewritten on change. Keep this in mind for performance and concurrency. +- **API router (`internal/api/router.go`)**: a single `http.ServeMux`. `ServeHTTP` applies CORS, optionally serves the built WebUI for non-`/api`/`/ws` GETs (`serveWebUI`, path-traversal guarded), then authenticates every `/api`/`/ws` request by bearer token (or `?token=` for browser WebSockets) and checks scope before dispatching. Two WebSocket endpoints: `/ws/rpc` (client RPC) and `/ws/relay` (agent/relay). +- **Platform abstraction (`internal/platform`)**: `NewRuntime()` factory selects an implementation; `common/` holds cross-platform defaults, `mac/` and `win/` hold OS specifics (PTY, default state path, host metrics). Add OS-specific behavior here, not in services. +- **Events hub (`internal/events/hub.go`)**: in-process pub/sub. Services publish events (terminal output, chat/timeline updates, host metrics) that the WS layer fans out to subscribers. + +### WS RPC protocol +JSON envelopes (`type: request|response|event`) inspired by muxy — see `evcod/docs/api/rpc-protocol.md` for the method table (`terminal.*`, `conversation.*`, etc.). REST endpoints and the RPC protocol are two parallel surfaces over the same services; keep both in sync when adding capabilities, and update `evcod/docs/api/` + `openapi.json`. + +### Agent bridge (warp) +`evcod_warp` connects to a core via `CoreBridge` (`src/core-bridge.js`: REST for conversations/messages, WS for events). `src/cli.js` routes by agent type: **native agents** (`claude`, `codex`, `gemini`, `qwen`, `opencode`) run their real TUI inside a core terminal pane and mirror output to web chat (`native-agent.js`, `opencode-tui.js`); only `fake` uses the legacy readline `SessionController` scheme. Backends live in `src/backends/`, transports (jsonl/jsonrpc) in `src/transports/`, output normalization in `normalizer.js`. The agent catalog the WebUI shows is computed server-side in `core/internal/services/agent_catalog.go` by probing for agent binaries and config (env-var overrides like `EVCOD_CLAUDE_BIN`/`EVCOD_CLAUDE_MODEL`). + +## Configuration & auth +- Core config (`internal/config`): `EVCOD_BIND` (default `127.0.0.1:4865`), `EVCOD_DB` (state JSON path), `EVCOD_WEBUI_DIR` (serve built WebUI). Flags `--bind`, `--db`, `--webui-dir` override. +- Auth: every request needs an API key. REST/non-browser WS use `Authorization: Bearer evcod_xxx`; browser WebSockets pass `?token=evcod_xxx`. The first server start auto-creates a default key; `key rotate` revokes all keys. + +## Conventions +- `gofmt` is enforced by the test pipeline — format Go before committing. +- Most documentation and many code comments are in Chinese; match the surrounding language when editing docs. +- Browser E2E "tests" in `webui/tests/` and `test_scripts/*.mjs` are standalone node scripts (Playwright-driven), not a unit-test framework — they need a live core + webui and the `EVCOD_CORE_URL`/`EVCOD_WEB_URL`/`EVCOD_API_KEY` env vars. diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md new file mode 100644 index 0000000..40f5183 --- /dev/null +++ b/PROJECT_OVERVIEW.md @@ -0,0 +1,361 @@ +# evcod 项目完整介绍 + +> 本文档面向第一次接触本仓库的工程师 / 协作者,完整介绍 **evcod** 的定位、架构、模块职责、数据模型、接口协议、运行与测试方式。 +> +> 更新时间:2026-06-20 + +--- + +## 1. 项目定位 + +**evcod** 是一个**本地优先(local-first)的远程终端 / 编码助手工具**。核心思路:在你的本机(或局域网内某台机器)常驻一个内核服务,浏览器作为客户端连接它,从而获得一套"随处可访问"的远程开发工作台——远程终端、项目 / 文件 / Git 管理、主机监控仪表盘,以及把外部 AI 编码 Agent(Claude、Codex、Gemini 等)的对话镜像进网页聊天。 + +设计上把能力拆成三个独立产品面 / 模块: + +| 模块 | 目录 | 语言 / 运行时 | 角色 | +|------|------|---------------|------| +| **core** | `evcod/core/` | Go 1.26(`evcod-core` 二进制) | 常驻内核服务:鉴权、项目、文件、Git、工作区 / worktree、终端(PTY)、会话、Agent 会话、主机指标、本地状态持久化。同时提供 REST + WebSocket,并可托管已构建的 WebUI。 | +| **webui** | `evcod/webui/` | React 19 + Vite 6 + TypeScript(`evcod-webui`) | 浏览器客户端:连接一个或多个 core,提供终端、文件 / Git 面板、主机仪表盘、聊天。 | +| **warp** | `evcod_warp/` | Node.js ≥22(ESM,CLI `evcod-warp`) | Agent 桥:把外部 AI Agent(`claude` / `codex` / `gemini` / `qwen` / `opencode`,以及测试用 `fake`)接入运行中的 core,把它们的对话镜像到网页聊天。 | + +此外还有大量中文设计 / 评审文档(见第 9 节)。当前架构参考了 MUXY 的远程终端 / 工作区思路,并在 Web UI 上实现了类似桌面工具的多栏工作台布局。 + +--- + +## 2. 仓库总览 + +```text +evcod_all/ +├── CLAUDE.md # 给 Claude Code 的项目指引(也适合人快速上手) +├── Chat 对齐文档.md # 聊天 / 会话对齐设计文档 +├── chat_diff/ # 聊天相关 diff / 对齐记录 +├── 审查报告/ # 评审报告(中文) +├── 其他/ # 杂项文档 +├── start-evcod.bat # Windows 启动入口 +└── evcod/ + ├── README.md + ├── dev.command # macOS 一键开发环境(构建 core + 起 core/webui) + ├── start.bat / start-dev.bat + ├── core/ # Go 内核服务 + │ ├── cmd/evcod-core/ # main 入口(serve / key print / key rotate) + │ └── internal/ + │ ├── app/ # App.New 装配、Serve 生命周期 + │ ├── config/ # 配置(环境变量 + flag) + │ ├── api/ # HTTP/WS 路由(单一 ServeMux) + │ ├── services/ # 业务服务层 + │ ├── store/ # 单 JSON 文件持久化 + │ ├── domain/ # 领域模型(纯数据结构) + │ ├── events/ # 进程内事件 hub(pub/sub) + │ └── platform/ # 平台抽象(common / mac / win) + ├── webui/ # React 浏览器客户端 + │ ├── src/ # 应用源码 + │ └── tests/ # Playwright 驱动的浏览器 E2E(独立 node 脚本) + ├── docs/ # API 契约 + 中文项目文档 + │ └── api/ # rpc-protocol.md / overview.md / openapi.json 等 + ├── scripts/ + ├── test_scripts/ # run_full_tests.sh(CI 等价物)+ 各类 .mjs + └── gomoku-game/ # 示例 / 演示项目 +└── evcod_warp/ + ├── bin/evcod-warp.js # CLI 入口 + ├── src/ + │ ├── cli.js # 按 agent 类型路由 + │ ├── core-bridge.js # 连接 core(REST + WS) + │ ├── native-agent.js # 原生 Agent 在终端 pane 内运行并镜像 + │ ├── opencode-*.js # opencode 专用 server / tui + │ ├── session-controller.js # 旧式 readline 方案(仅 fake) + │ ├── normalizer.js # 输出归一化 + │ ├── registry.js / types.js / tool-detail.js + │ ├── backends/ # claude.js / codex.js / opencode.js / fake.js + │ └── transports/ # jsonl.js / jsonrpc.js / factory.js + └── test/ # node --test 单元测试 +``` + +--- + +## 3. Core 服务(Go) + +### 3.1 分层架构 + +依赖**自外向内单向流动**: + +``` +cmd/evcod-core/main.go + → internal/app (装配 + 生命周期) + → internal/api (HTTP/WS 路由) + → internal/services (业务逻辑) + → internal/store (持久化) + + internal/platform (平台抽象) + + internal/events (事件 hub) +``` + +### 3.2 启动与装配(`app.New` / `Serve`) + +- `app.New`:创建平台 `Runtime` → 打开 store(默认状态路径来自 runtime)→ 创建 events hub → 构造 `services.Services`(聚合 `Keys`、`Projects`、`Files`、`Git`、`Terminal`、`Chat`、`Agent`、`Host`、`Workspace`)。 +- `Serve`: + 1. 确保存在 API key(首次启动自动创建默认 key); + 2. 把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 写入进程环境变量——**这样同机运行的 warp / agent 能自动发现 core**; + 3. 启动主机指标采集循环(`Host.Start`); + 4. 启动定时聊天队列(`Chat.StartScheduledQueue`); + 5. 起 HTTP server(带 10s ReadHeaderTimeout),监听 ctx 取消优雅关闭。 + +入口命令(`main.go`): +- `serve`(默认)——启动服务; +- `key print`——打印 / 创建默认 API key; +- `key rotate`——吊销所有 key 并新建一个。 + +### 3.3 Store —— 单 JSON 文件持久化(重点) + +`internal/store/store.go`:**整个持久化就是一个 JSON 文件,全量加载进内存,由 `sync.RWMutex` 保护**。尽管 config 里有 `--db` flag,但**并没有 SQL 数据库**。 + +- `state` 结构体即完整 schema;每一类实体(projects、worktrees、conversations、messages、agent sessions/turns、timeline events、terminal panes、host metrics、audit logs、device tokens……)都是其中的列表。 +- 任何变更都会**重写整个文件**。 +- ⚠️ 由此带来的工程约束:注意性能(全量序列化)与并发(全局锁);大量小写入要谨慎。 + +### 3.4 API 路由(`internal/api/router.go`) + +单一 `http.ServeMux`,`ServeHTTP` 处理顺序: + +1. 施加 CORS; +2. 对非 `/api`、非 `/ws` 的 GET,可选地托管已构建的 WebUI(`serveWebUI`,带路径穿越防护); +3. 对每个 `/api`、`/ws` 请求做 **bearer token 鉴权**(浏览器 WebSocket 用 `?token=`),并检查 scope,再分发。 + +两个 WebSocket 端点: +- `/ws/rpc` —— 客户端 RPC(WebUI ↔ core); +- `/ws/relay` —— Agent / relay(warp ↔ core)。 + +**主要 REST 端点**(按域分组): + +| 域 | 端点 | +|----|------| +| 系统 / 设置 | `/api/system`、`/api/settings/runtime` | +| 主机监控 | `/api/host/overview`、`/host/performance`、`/host/history`、`/host/interfaces`、`/host/processes`、`/host/ports`、`/host/files/list` | +| 审计 | `/api/audit/logs` | +| 设备令牌 | `/api/tokens`、`/api/tokens/{id}` | +| 项目 | `/api/projects`、`/api/projects/{id}` | +| Worktree | `/api/worktrees`、`/worktrees/refresh`、`/worktrees/{id}` | +| 工作区 Tab | `/api/workspace/tabs`、`/workspace/tabs/{id}` | +| 文件 | `/api/files/list`、`read`、`write`、`search`、`browse-directories`、`create`、`mkdir`、`rename`、`delete` | +| Git | `/api/git/status`、`diff`、`diff-content`、`log`、`branches`、`checkout`、`branch`、`stage`、`unstage`、`discard`、`commit`、`pull`、`push`、`stash`、`stash/apply` | +| 终端 | `/api/terminal/panes`、`/terminal/panes/{id}` | +| Agent | `/api/agent/catalog`、`history`、`history/import`、`launch`、`sessions`、`sessions/{id}` | +| 会话 | `/api/conversations`、`/conversations/{id}` | + +> REST 与 WS RPC 是同一套服务的**两个并行表面**;新增能力时两边都要保持同步,并更新 `evcod/docs/api/` 与 `openapi.json`。 + +### 3.5 平台抽象(`internal/platform`) + +`NewRuntime()` 工厂选择实现: +- `common/` —— 跨平台默认; +- `mac/` —— macOS 特定(PTY、默认状态路径、主机指标); +- `win/` —— Windows 特定。 + +OS 相关行为应放进这里,**不要写进 services**。 + +### 3.6 事件 hub(`internal/events/hub.go`) + +进程内 pub/sub。services 发布事件(终端输出、聊天 / timeline 更新、主机指标),WS 层把事件扇出给订阅者。 + +### 3.7 服务层(`internal/services`) + +`Services` 聚合结构(`services.go`)持有所有子服务: + +- **KeyService** —— API key 生命周期、设备令牌(创建 / 吊销 / scope 归一化)、token 鉴权。 +- **ProjectService** —— 项目增删查。 +- **FileService** —— 文件列举 / 读写 / 搜索 / 重命名 / 删除、目录浏览。 +- **GitService** —— status / diff / log / branch / stage / commit / pull / push / stash 等。 +- **TerminalService** —— PTY 终端 pane(用 `github.com/aymanbagabas/go-pty`),快照 / 输入 / resize / 关闭。 +- **ChatService** —— 会话、消息、timeline 事件、定时消息队列。 +- **AgentService** —— Agent 会话、turn、锁(lockOwner/lockVersion)、provider session 关联。 +- **WorkspaceService** —— worktree、工作区 tab。 +- **HostService** —— 主机概览、性能采样、历史指标(分桶保留)、进程 / 端口 / 接口、主机文件浏览。 +- **agent_catalog.go** —— **服务端探测** agent 二进制与配置(支持 `EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL` 等 env 覆盖),算出 WebUI 展示的 Agent 目录。 + +--- + +## 4. WS RPC 协议 + +JSON 信封,`type: request | response | event`,灵感来自 muxy。方法表见 `evcod/docs/api/rpc-protocol.md`,代表性方法: + +- 终端:`terminal.create` / `terminal.list` / `terminal.input` / `terminal.resize` / `terminal.snapshot` / `terminal.close` +- 会话:`conversation.create` / `conversation.list` / `conversation.send` +- 项目:`project.list` +- 消息 / turn 流:`message.stream`、`turn.completed` +- 工具调用:`tool.call` / `tool.result`、`confirmation.requested` + +--- + +## 5. 领域模型(`internal/domain/models.go`) + +纯数据结构(JSON 标签),核心实体: + +- **Project / Worktree / WorkspaceTab** —— 项目、Git worktree、工作区标签页(kind / title / pinned / color)。 +- **TerminalPane** —— 终端 pane(cwd / rows / cols / status)。 +- **Conversation / ChatMessage** —— 会话与消息(role / kind / content / seq / turnId / source / status / payload)。 +- **AgentTurn** —— Agent 一次回合的完整状态机时间戳(requested / consumed / accepted / invoked / completed / failed / cancelled)+ attemptId / consumerId / providerSessionId。 +- **TimelineEvent** —— 带 seq + epoch 的时间线事件。 +- **PermissionRequest** —— 工具调用授权请求(toolName / action / resources / risk / choices / status)。 +- **QueuedMessage** —— 定时 / 排队消息。 +- **AuditLogEntry** —— 审计日志(action / category / actor / remoteAddr / outcome / metadata)。 +- **DeviceToken / TokenAuth** —— 设备令牌与鉴权结果(scopes / revoked / tokenHash)。 +- **AgentSession / AgentModel / AgentCatalogEntry / AgentHistoryItem** —— Agent 会话、模型、目录条目、历史导入项。 +- **Host\*** —— 主机概览、资源用量、磁盘分区 / IO、网络 IO、性能样本、历史分桶、网卡、进程、端口、文件。 +- **Git\*** —— 文件状态、status、branch、commit、stash。 +- **FileEntry / DirectoryListing / Event** 等。 + +> 这些模型直接定义了 REST/WS 的 JSON 形状,是前后端契约的事实来源。 + +--- + +## 6. WebUI(React 19 + Vite 6 + TypeScript) + +`evcod/webui/src/`: + +- **入口 / 框架**:`main.tsx`、`App.tsx`、`styles.css`。 +- **状态**:`store.ts`(zustand)、`types.ts`、`api.ts`(REST/WS 客户端)、`lib.ts`。 +- **终端**:`TerminalView.tsx` + `@xterm/xterm`(含 fit / serialize addon)。 +- **代码编辑**:`MonacoEditors.tsx`、`FileEditorWorkspace.tsx` + `monaco-editor`。 +- **面板组件**(`components/`): + - `Sidebar.tsx`、`RightPanel.tsx`、`WorkspaceStrip.tsx`、`PaneTabs.tsx` —— 多栏工作台布局; + - `FilesPanel.tsx`、`DirectoryPicker.tsx` —— 文件管理; + - `GitPanel.tsx` —— Git 面板; + - `HostWorkspace.tsx` —— 主机仪表盘; + - `ConversationView.tsx` + `components/chat/` —— 聊天 / 对话; + - `SettingsPage.tsx` —— 设置。 +- **聊天流处理**(`src/chat/`):`chatStreamAdapter.ts`、`streamTypes.ts`、`timelineRenderState.ts`、`toolDetail.ts` —— 把 core 的 timeline/message 事件渲染成聊天 UI(含工具调用详情)。 +- **辅助**:`markdown.tsx`、`messageParts.ts`、`fileLinks.ts`、`feedback.tsx`。 + +依赖关键:`react@19`、`zustand@5`、`@xterm/xterm@5.5`、`monaco-editor@0.55`、`lucide-react`、`vite@6`。 + +--- + +## 7. Warp —— Agent 桥(Node.js) + +把外部 AI Agent 接入 core,并把它们的对话镜像进网页聊天。 + +- **`CoreBridge`(`src/core-bridge.js`)**:REST 处理 conversations/messages,WS 接收事件。 +- **`src/cli.js`**:按 agent 类型路由: + - **原生 agents**(`claude` / `codex` / `gemini` / `qwen` / `opencode`):在 core 的终端 pane 内运行其**真实 TUI**,并把输出镜像到网页聊天(`native-agent.js`、`opencode-tui.js`); + - 只有 **`fake`** 走旧式 readline `SessionController` 方案(测试夹具)。 +- **`src/backends/`**:各 agent 后端(`claude.js`、`codex.js`、`opencode.js`、`fake.js`,由 `index.js` 注册)。 +- **`src/transports/`**:传输层 —— `jsonl.js`(行分隔 JSON)、`jsonrpc.js`,由 `factory.js` 选择。 +- **`normalizer.js`**:输出归一化;**`tool-detail.js`**:工具调用详情。 +- CLI:`node evcod_warp/bin/evcod-warp.js --list` 列出可用后端。 + +> WebUI 展示的 Agent 目录由 core 端 `agent_catalog.go` 探测计算(不是 warp),warp 负责实际拉起并桥接。 + +--- + +## 8. 配置与鉴权 + +### 配置(`internal/config`) + +| 项 | 环境变量 | flag | 默认 | +|----|----------|------|------| +| 绑定地址 | `EVCOD_BIND` | `--bind` | `127.0.0.1:4865` | +| 状态文件 | `EVCOD_DB` | `--db` | runtime 默认路径 | +| WebUI 目录 | `EVCOD_WEBUI_DIR` | `--webui-dir` | (不托管) | + +Agent 相关 env 覆盖:`EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL`(其余 agent 同理)。 + +> 注意:`dev.command` 中 core 跑 `:10065`、webui dev 跑 `:10066`,与上面的默认 `4865` 不同(开发脚本显式覆盖)。 + +### 鉴权 + +- 每个请求都需要 API key。 +- REST / 非浏览器 WS:`Authorization: Bearer evcod_xxx`。 +- 浏览器 WebSocket:`?token=evcod_xxx`。 +- 首次启动自动创建默认 key;`key rotate` 吊销所有 key。 +- 设备令牌支持 scope;审计日志记录访问。 + +--- + +## 9. 文档 + +- **`evcod/docs/api/`** —— API 契约(事实来源):`overview.md`、`rpc-protocol.md`、`auth.md`、`conversation.md`、`files.md`、`terminal.md`、`workspace.md`、`errors.md`、`openapi.json`。 +- **`evcod/docs/project-overview.zh-CN.md`** —— 中文项目状态文档。 +- 仓库根:`Chat 对齐文档.md`、`chat_diff/`(聊天对齐)、`审查报告/`(评审报告)、`其他/`。 +- 多数文档与不少代码注释是**中文**——编辑文档时请匹配周围语言。 + +--- + +## 10. 构建、运行与测试 + +### 一键开发环境(macOS) + +```bash +evcod/dev.command # 构建 core,起 core(:10065) + webui dev(:10066),并打印 API key +``` + +### 完整测试 / 构建流水线(CI 等价物) + +```bash +evcod/test_scripts/run_full_tests.sh # gofmt + go test + 构建 + core API 烟测 + warp 测试 + webui 构建 + 浏览器 E2E +evcod/test_scripts/run_full_tests.sh --no-browser # 跳过 Playwright 浏览器流程 +``` + +### Core(Go,模块路径 `evcod/core`,用 `go -C ` 而非 cd) + +```bash +go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core +go -C evcod/core test ./... +go -C evcod/core test ./internal/services -run TestAgentCatalog # 单测 +gofmt -w evcod/core # 提交前格式化(流水线强制) +go -C evcod/core run ./cmd/evcod-core key print # 打印/创建默认 key +go -C evcod/core run ./cmd/evcod-core key rotate +go -C evcod/core run ./cmd/evcod-core serve # 默认 127.0.0.1:4865 +``` + +### WebUI(Node) + +```bash +npm --prefix evcod/webui ci +npm --prefix evcod/webui run dev # vite dev server +npm --prefix evcod/webui run build # tsc -b && vite build +# 浏览器 E2E(Playwright 驱动的独立 node 脚本,需要在跑的 core + webui): +EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke +# 另有 test:full-flow / test:feature-flow / test:agent-pane / test:agent-all +``` + +### Warp(Node ≥22) + +```bash +npm --prefix evcod_warp test # node --test,全部 test/*.test.js +node --test evcod_warp/test/normalizer.test.js # 单测文件 +node evcod_warp/bin/evcod-warp.js --list # 列出可用 agent 后端 +``` + +--- + +## 11. 关键约定与注意事项 + +1. **gofmt 强制**:提交 Go 前必须格式化(流水线会检查)。 +2. **REST 与 WS RPC 双表面同步**:新增能力两边都要改,并更新 `docs/api/` + `openapi.json`。 +3. **持久化是单 JSON 文件 + 全局锁**:警惕性能与并发,避免高频小写入。 +4. **OS 特定逻辑放 `platform/`**,不要写进 services。 +5. **浏览器 "tests" 不是单测框架**:`webui/tests/`、`test_scripts/*.mjs` 是 Playwright 驱动的独立 node 脚本,需要 live core + webui,以及 `EVCOD_CORE_URL` / `EVCOD_WEB_URL` / `EVCOD_API_KEY` 环境变量。 +6. **Agent 目录由 core 探测**(`agent_catalog.go`),warp 负责拉起与桥接;二者职责不同。 +7. **文档语言**:中文为主,编辑时匹配周围语言。 + +--- + +## 12. 一次完整的数据流(示意) + +以"在网页里和 Claude 对话写代码"为例: + +``` +浏览器(WebUI) + │ REST: 创建项目 / 选目录 / 打开 worktree + │ WS /ws/rpc: conversation.create, conversation.send + ▼ +core (services.Chat / Agent) ──写入──► store(JSON) ──发布──► events hub + ▲ │ + │ WS /ws/relay │ 扇出事件 + │ ▼ +evcod-warp (CoreBridge) 浏览器实时收到 + │ 在 core 的终端 pane 内运行真实 claude TUI message.stream / timeline + │ normalizer 归一化输出 → 镜像回 core → 网页聊天 + ▼ +claude / codex / gemini ...(外部 AI Agent 二进制) +``` + +core 在 `Serve` 时把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 注入环境,所以同机的 warp 无需额外配置即可发现并连上 core。 diff --git a/evcod/core/internal/api/router.go b/evcod/core/internal/api/router.go index c1c24e3..d816fce 100644 --- a/evcod/core/internal/api/router.go +++ b/evcod/core/internal/api/router.go @@ -1296,6 +1296,9 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) { AgentType string `json:"agentType"` Model string `json:"model"` PermissionMode string `json:"permissionMode"` + Effort string `json:"effort"` + FastMode *bool `json:"fastMode"` + PlanMode *bool `json:"planMode"` } if !decode(w, req, &body) { return @@ -1314,6 +1317,13 @@ func (r *Router) agentLaunch(w http.ResponseWriter, req *http.Request) { writeResult(w, nil, err) return } + // Persist the chosen model/permission mode on the session (without firing a + // model-change inject — the agent picks these up via its launch args). + if strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil { + if updated, cfgErr := r.services.Agent.SetConfig(session.ID, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, "launch"); cfgErr == nil { + session = updated + } + } args := []string{ "evcod-warp", session.AgentType, @@ -1374,11 +1384,20 @@ func (r *Router) agentSessionByID(w http.ResponseWriter, req *http.Request) { Status string `json:"status"` StatusReason string `json:"statusReason"` ProviderSessionID string `json:"providerSessionId"` + Model string `json:"model"` + PermissionMode string `json:"permissionMode"` + Effort string `json:"effort"` + FastMode *bool `json:"fastMode"` + PlanMode *bool `json:"planMode"` + ConfigSource string `json:"configSource"` } if !decode(w, req, &body) { return } session, err := r.services.Agent.Update(id, body.PaneID, body.AgentType, body.Status, body.StatusReason, body.ProviderSessionID) + if err == nil && (strings.TrimSpace(body.Model) != "" || strings.TrimSpace(body.PermissionMode) != "" || strings.TrimSpace(body.Effort) != "" || body.FastMode != nil || body.PlanMode != nil) { + session, err = r.services.Agent.SetConfig(id, body.Model, body.PermissionMode, body.Effort, body.FastMode, body.PlanMode, body.ConfigSource) + } writeResult(w, session, err) case req.Method == http.MethodPost && action == "lock": var body struct { diff --git a/evcod/core/internal/domain/models.go b/evcod/core/internal/domain/models.go index e8effa1..df2a49a 100644 --- a/evcod/core/internal/domain/models.go +++ b/evcod/core/internal/domain/models.go @@ -174,6 +174,11 @@ type AgentSession struct { ConversationID string `json:"conversationId"` PaneID *string `json:"paneId,omitempty"` AgentType string `json:"agentType"` + Model string `json:"model,omitempty"` + PermissionMode string `json:"permissionMode,omitempty"` + Effort string `json:"effort,omitempty"` + FastMode bool `json:"fastMode,omitempty"` + PlanMode bool `json:"planMode,omitempty"` Status string `json:"status"` StatusReason string `json:"statusReason,omitempty"` ProviderSessionID string `json:"providerSessionId,omitempty"` diff --git a/evcod/core/internal/services/host.go b/evcod/core/internal/services/host.go index 051bb5a..774a9c7 100644 --- a/evcod/core/internal/services/host.go +++ b/evcod/core/internal/services/host.go @@ -994,6 +994,25 @@ func parseWindowsNetstat(output, protocol string, processNames map[int]string) [ } func unixPorts(processNames map[int]string) ([]domain.HostPort, error) { + if goruntime.GOOS == "darwin" { + ports, err := lsofPorts(processNames) + if err != nil { + return []domain.HostPort{}, nil + } + return ports, nil + } + ports, err := ssPorts(processNames) + if err == nil { + return ports, nil + } + ports, err = lsofPorts(processNames) + if err != nil { + return []domain.HostPort{}, nil + } + return ports, nil +} + +func ssPorts(processNames map[int]string) ([]domain.HostPort, error) { out, err := runHostCommand(3500*time.Millisecond, "ss", "-tunlp") if err != nil { return nil, err @@ -1025,6 +1044,53 @@ func unixPorts(processNames map[int]string) ([]domain.HostPort, error) { return ports, nil } +func lsofPorts(processNames map[int]string) ([]domain.HostPort, error) { + out, err := runHostCommand(3500*time.Millisecond, "lsof", "-nP", "-iTCP", "-iUDP") + if err != nil { + return nil, err + } + return parseLsofPorts(out, processNames), nil +} + +func parseLsofPorts(output string, processNames map[int]string) []domain.HostPort { + ports := []domain.HostPort{} + for _, line := range strings.Split(output, "\n") { + fields := strings.Fields(line) + if len(fields) < 9 || fields[0] == "COMMAND" { + continue + } + protocol := strings.ToUpper(fields[7]) + if protocol != "TCP" && protocol != "UDP" { + continue + } + nameField := strings.Join(fields[8:], " ") + state := "" + if idx := strings.LastIndex(nameField, " ("); idx >= 0 && strings.HasSuffix(nameField, ")") { + state = strings.TrimSuffix(strings.TrimPrefix(nameField[idx+1:], "("), ")") + nameField = strings.TrimSpace(nameField[:idx]) + } + local := strings.TrimSpace(strings.SplitN(nameField, "->", 2)[0]) + localAddress, localPort := splitAddressPort(local) + if localPort == "" || localPort == "*" { + continue + } + pid, _ := strconv.Atoi(fields[1]) + processName := processNames[pid] + if processName == "" { + processName = fields[0] + } + ports = append(ports, domain.HostPort{ + Protocol: protocol, + LocalAddress: localAddress, + LocalPort: localPort, + State: state, + PID: pid, + ProcessName: processName, + }) + } + return ports +} + func parseSSProcess(value string) (int, string) { name := "" pid := 0 diff --git a/evcod/core/internal/services/host_ports_test.go b/evcod/core/internal/services/host_ports_test.go new file mode 100644 index 0000000..ba2823a --- /dev/null +++ b/evcod/core/internal/services/host_ports_test.go @@ -0,0 +1,29 @@ +package services + +import "testing" + +func TestParseLsofPorts(t *testing.T) { + output := `COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +rapportd 494 fu 6u IPv4 0x1 0t0 TCP *:50847 (LISTEN) +evcod 1234 fu 20u IPv4 0x2 0t0 TCP 127.0.0.1:10065 (LISTEN) +identity 503 fu 10u IPv4 0x3 0t0 UDP *:* +sharing 517 fu 4u IPv4 0x4 0t0 UDP *:5353 +remote 600 fu 9u IPv6 0x5 0t0 TCP [fe80::1]:50847->[fe80::2]:52609 (ESTABLISHED) +` + ports := parseLsofPorts(output, map[int]string{1234: "evcod-core"}) + if len(ports) != 4 { + t.Fatalf("unexpected port count: got %d %#v", len(ports), ports) + } + if ports[0].Protocol != "TCP" || ports[0].LocalAddress != "*" || ports[0].LocalPort != "50847" || ports[0].State != "LISTEN" || ports[0].PID != 494 || ports[0].ProcessName != "rapportd" { + t.Fatalf("unexpected first port: %#v", ports[0]) + } + if ports[1].ProcessName != "evcod-core" || ports[1].LocalAddress != "127.0.0.1" || ports[1].LocalPort != "10065" { + t.Fatalf("unexpected process-name fallback: %#v", ports[1]) + } + if ports[2].Protocol != "UDP" || ports[2].LocalPort != "5353" { + t.Fatalf("unexpected udp port: %#v", ports[2]) + } + if ports[3].State != "ESTABLISHED" || ports[3].LocalAddress != "fe80::1" || ports[3].LocalPort != "50847" { + t.Fatalf("unexpected established ipv6 port: %#v", ports[3]) + } +} diff --git a/evcod/core/internal/services/services.go b/evcod/core/internal/services/services.go index b9055f3..dd11b8e 100644 --- a/evcod/core/internal/services/services.go +++ b/evcod/core/internal/services/services.go @@ -2193,6 +2193,87 @@ func (s *AgentService) Update(id, paneID, agentType, status, statusReason, provi return session, nil } +// SetConfig persists the selected model and turn controls on a session and +// announces the change so the running agent (warp) can sync it into the native +// CLI. Empty string values and nil booleans leave the corresponding field +// untouched. `source` records where the change came from ("web" or "terminal") +// so consumers can avoid echo loops. +func (s *AgentService) SetConfig(id, model, permissionMode, effort string, fastMode, planMode *bool, source string) (domain.AgentSession, error) { + model = strings.TrimSpace(model) + permissionMode = strings.TrimSpace(permissionMode) + effort = strings.TrimSpace(effort) + if source == "" { + source = "web" + } + modelChanged := false + configChanged := false + session, err := s.store.UpdateAgentSession(id, func(session *domain.AgentSession) error { + if model != "" && model != session.Model { + session.Model = model + modelChanged = true + configChanged = true + } + if permissionMode != "" && permissionMode != session.PermissionMode { + session.PermissionMode = permissionMode + configChanged = true + } + if effort != "" && effort != session.Effort { + session.Effort = effort + configChanged = true + } + if fastMode != nil && *fastMode != session.FastMode { + session.FastMode = *fastMode + configChanged = true + } + if planMode != nil && *planMode != session.PlanMode { + session.PlanMode = *planMode + configChanged = true + } + session.UpdatedAt = store.Now() + return nil + }) + if err != nil { + return domain.AgentSession{}, err + } + s.publishSession("agent.session.updated", session) + if configChanged || modelChanged { + paneID := "" + if session.PaneID != nil { + paneID = *session.PaneID + } + payload := map[string]any{ + "sessionId": session.ID, + "conversationId": session.ConversationID, + "paneId": paneID, + "agentType": session.AgentType, + "model": session.Model, + "permissionMode": session.PermissionMode, + "effort": session.Effort, + "fastMode": session.FastMode, + "planMode": session.PlanMode, + "source": source, + "timestamp": store.Now(), + } + s.events.Publish("agent.config.changed", payload) + } + if modelChanged { + paneID := "" + if session.PaneID != nil { + paneID = *session.PaneID + } + s.events.Publish("agent.model.changed", map[string]any{ + "sessionId": session.ID, + "conversationId": session.ConversationID, + "paneId": paneID, + "agentType": session.AgentType, + "model": session.Model, + "source": source, + "timestamp": store.Now(), + }) + } + return session, nil +} + func (s *AgentService) AcquireLock(id, owner, turnID string, expectedVersion *int64, providerSessionID string) (domain.AgentSession, error) { if strings.TrimSpace(owner) == "" { return domain.AgentSession{}, errors.New("lock owner is required") diff --git a/evcod/dev.command b/evcod/dev.command old mode 100644 new mode 100755 diff --git a/evcod/webui/src/api.ts b/evcod/webui/src/api.ts index f1cc1af..41918d0 100644 --- a/evcod/webui/src/api.ts +++ b/evcod/webui/src/api.ts @@ -507,14 +507,19 @@ export class CoreApi { }); } - launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string }) { + launchAgent(input: { conversationId: string; paneId: string; agentType: string; model?: string; permissionMode?: string; effort?: string; fastMode?: boolean; planMode?: boolean }) { return this.request<{ session: AgentSession; command: string }>('/api/agent/launch', { method: 'POST', body: JSON.stringify(input), }); } - updateAgentSession(id: string, patch: Partial>) { + updateAgentSession( + id: string, + patch: Partial> & { + configSource?: 'web' | 'terminal' | 'launch'; + }, + ) { return this.request(`/api/agent/sessions/${id}`, { method: 'PUT', body: JSON.stringify(patch), diff --git a/evcod/webui/src/components/ConversationView.tsx b/evcod/webui/src/components/ConversationView.tsx index 1abff61..97308e8 100644 --- a/evcod/webui/src/components/ConversationView.tsx +++ b/evcod/webui/src/components/ConversationView.tsx @@ -206,12 +206,23 @@ export function ConversationView(props: { useEffect(() => { if (session?.agentType) { setAgentType(session.agentType); - setModel(defaultModelForAgent(session.agentType)); + // Prefer the model persisted on the session so the selector reflects the + // live agent (and stays in sync after a model change) instead of resetting + // to the agent default on every session update. + setModel(session.model?.trim() ? session.model : defaultModelForAgent(session.agentType)); + setPermissionMode(session.permissionMode?.trim() ? session.permissionMode : 'full-access'); + setEffort(session.effort?.trim() ? session.effort : 'extra-high'); + setFastMode(Boolean(session.fastMode)); + setPlanMode(Boolean(session.planMode)); return; } const nextAgent = activeCore?.defaultAgentType ?? frontendSettings.defaultAgentType; setAgentType(nextAgent); setModel(defaultModelForAgent(nextAgent)); + setPermissionMode('full-access'); + setEffort('extra-high'); + setFastMode(false); + setPlanMode(false); }, [activeCore?.defaultAgentType, activeCore?.defaultOpenCodeModel, frontendSettings.defaultAgentType, frontendSettings.defaultOpenCodeModel, session]); useEffect(() => { @@ -338,6 +349,9 @@ export function ConversationView(props: { agentType, model: model.trim() ? model : undefined, permissionMode, + effort, + fastMode, + planMode, }); const next = launched.session; await catchUpTimeline(conversationId); @@ -558,6 +572,44 @@ export function ConversationView(props: { setModel(defaultModelForAgent(nextAgent)); } + function handleModelChange(nextModel: string) { + setModel(nextModel); + // When a session is already running, push the change so the core records it + // and warp types the model switch into the native CLI (two-way model sync). + const live = session?.id && session.status !== 'closed' && session.status !== 'unbound'; + if (props.api && live && nextModel.trim() && nextModel !== session?.model) { + props.api + .updateAgentSession(session!.id, { model: nextModel, configSource: 'web' }) + .catch((error) => toastError(error, 'Could not update model')); + } + } + + function updateSessionControls(patch: Partial>) { + const live = session?.id && session.status !== 'closed' && session.status !== 'unbound'; + if (!props.api || !live) return; + props.api.updateAgentSession(session!.id, { ...patch, configSource: 'web' }).catch((error) => toastError(error, 'Could not update controls')); + } + + function handlePermissionModeChange(nextPermissionMode: string) { + setPermissionMode(nextPermissionMode); + if (nextPermissionMode !== session?.permissionMode) updateSessionControls({ permissionMode: nextPermissionMode }); + } + + function handleEffortChange(nextEffort: string) { + setEffort(nextEffort); + if (nextEffort !== session?.effort) updateSessionControls({ effort: nextEffort }); + } + + function handleFastModeChange(nextFastMode: boolean) { + setFastMode(nextFastMode); + if (nextFastMode !== Boolean(session?.fastMode)) updateSessionControls({ fastMode: nextFastMode }); + } + + function handlePlanModeChange(nextPlanMode: boolean) { + setPlanMode(nextPlanMode); + if (nextPlanMode !== Boolean(session?.planMode)) updateSessionControls({ planMode: nextPlanMode }); + } + return (
@@ -617,15 +669,15 @@ export function ConversationView(props: { agentOptions={agentOptions} modelOptions={modelOptions} onAgentTypeChange={changeAgentType} - onModelChange={setModel} + onModelChange={handleModelChange} effort={effort} - onEffortChange={setEffort} + onEffortChange={handleEffortChange} permissionMode={permissionMode} - onPermissionModeChange={setPermissionMode} + onPermissionModeChange={handlePermissionModeChange} fastMode={fastMode} - onFastModeChange={setFastMode} + onFastModeChange={handleFastModeChange} planMode={planMode} - onPlanModeChange={setPlanMode} + onPlanModeChange={handlePlanModeChange} conversationSummary={conversationSummary} contextSummary={contextSummary} usageSummary={usageSummary} diff --git a/evcod/webui/src/components/DirectoryPicker.tsx b/evcod/webui/src/components/DirectoryPicker.tsx index 3122862..3de20a5 100644 --- a/evcod/webui/src/components/DirectoryPicker.tsx +++ b/evcod/webui/src/components/DirectoryPicker.tsx @@ -102,7 +102,7 @@ export function DirectoryPicker(props: {
{crumbs.length === 0 ? Default locations : null} {crumbs.map((crumb, index) => ( - + {index < crumbs.length - 1 ? / : null} @@ -149,8 +149,8 @@ export function DirectoryPicker(props: { No sub-folders here.
) : null} - {listing?.entries.map((entry) => ( - ) : null} - {(listing?.entries ?? []).map((entry) => ( - entry.isDir && void load(entry.path)} /> + {(listing?.entries ?? []).map((entry, index) => ( + entry.isDir && void load(entry.path)} /> ))}
diff --git a/evcod/webui/src/types.ts b/evcod/webui/src/types.ts index 23ccaaa..5fc2841 100644 --- a/evcod/webui/src/types.ts +++ b/evcod/webui/src/types.ts @@ -163,6 +163,11 @@ export type AgentSession = { conversationId: string; paneId?: string; agentType: string; + model?: string; + permissionMode?: string; + effort?: string; + fastMode?: boolean; + planMode?: boolean; status: string; statusReason?: string; providerSessionId?: string; diff --git a/evcod_warp/src/native-agent.js b/evcod_warp/src/native-agent.js index 749db74..5e93532 100644 --- a/evcod_warp/src/native-agent.js +++ b/evcod_warp/src/native-agent.js @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { basename, extname, join, normalize, resolve } from 'node:path'; +import { basename, extname, join, normalize, resolve, sep } from 'node:path'; import { homedir, platform } from 'node:os'; import { normalizeClaudeTranscriptLine, @@ -41,7 +41,11 @@ export class NativeAgentController { this.projectPath = projectPath; this.projectId = projectId; this.model = model; + this.lastSyncedModel = String(model ?? '').trim() || undefined; this.permissionMode = normalizePermissionMode(permissionMode); + this.effort = undefined; + this.fastMode = false; + this.planMode = false; this.passthroughArgs = Array.isArray(passthroughArgs) ? passthroughArgs : []; this.homeDir = homeDir; this.env = env; @@ -55,6 +59,7 @@ export class NativeAgentController { this.turnCompletionWaiters = new Map(); this.implicitCompletionTimers = new Map(); this.emitted = new Map(); + this.ownershipCache = new Map(); this.fallbackMessageSeq = 0; this.transcriptEpoch = 0; this.lastKnownSize = 0; @@ -208,6 +213,14 @@ export class NativeAgentController { } else if (event.event === 'agent.cancel.requested') { if (this.paneId) await this.core.inputPane(this.paneId, '\x03').catch(() => undefined); await this.#completeActiveTurn('canceled').catch(() => undefined); + } else if (event.event === 'agent.config.changed') { + await this.#applySessionConfigEvent(event.payload); + } else if (event.event === 'agent.model.changed') { + // The web UI changed the model on a running session: type the provider's + // model-switch command into the native TUI so both sides stay in sync. + // Only act on web-originated changes (source "launch"/"terminal" are + // already reflected by the CLI and must not be re-injected). + await this.#applySessionConfigEvent(event.payload, { modelOnly: true }); } } @@ -269,6 +282,22 @@ export class NativeAgentController { await this.#rediscoverTranscript(); return; } + let grew = true; + try { + grew = statSync(this.transcriptPath).size !== this.lastKnownSize; + } catch { + grew = true; + } + await this.#syncCurrentTranscript(); + // When the current transcript is idle, the agent may have rotated to a new + // file (claude `/clear`, a fresh codex rollout). Follow that file so messages + // produced after the rotation are not lost. + if (!grew && this.#followRotation()) { + await this.#syncCurrentTranscript(); + } + } + + async #syncCurrentTranscript() { if (this.agentConfig.transcriptFormat === 'json') { await this.#syncJsonTranscript(); } else { @@ -276,6 +305,51 @@ export class NativeAgentController { } } + // Switch to a newer transcript that belongs to this session. Only files we can + // attribute to this agent are eligible: those under a project-scoped search + // root, or (codex, whose sessions dir is global) whose session_meta cwd matches + // this project. Returns true when the active transcript was switched. + #followRotation() { + if (this.stopped || !this.agentConfig) return false; + let currentMtime = 0; + try { + currentMtime = statSync(this.transcriptPath).mtimeMs; + } catch { + currentMtime = 0; + } + let best; + for (const file of snapshotTranscriptFiles(this.agentConfig).values()) { + if (file.path === this.transcriptPath || file.mtimeMs <= currentMtime) continue; + if (!this.#isOwnedTranscript(file.path)) continue; + if (!best || file.mtimeMs > best.mtimeMs) best = file; + } + if (!best) return false; + this.transcriptPath = best.path; + this.lastKnownSize = 0; + this.lastMessageCount = 0; + this.partialLine = ''; + this.fallbackMessageSeq = 0; + this.transcriptEpoch++; + return true; + } + + #isOwnedTranscript(path) { + const cached = this.ownershipCache.get(path); + if (cached !== undefined) return cached; + let owned = false; + for (const root of this.agentConfig.searchRoots ?? []) { + if (root.scoped && isPathInside(path, root.dir)) { + owned = true; + break; + } + } + if (!owned && this.agentType === 'codex') { + owned = codexTranscriptCwd(path) === resolve(this.projectPath); + } + this.ownershipCache.set(path, owned); + return owned; + } + async #rediscoverTranscript() { if (!this.agentConfig || this.stopped) return; const transcript = await this.#discoverTranscript(this.discoveryBaseline ?? new Map(), 1); @@ -351,6 +425,10 @@ export class NativeAgentController { const partKey = messageKey(message, fallbackKey); if (this.emitted.has(partKey)) continue; this.emitted.set(partKey, true); + if (message.kind === 'session_config') { + await this.#applyCliSessionConfig(message.payload); + continue; + } if (message.role === 'user') { await this.#emitUserMessage(message); continue; @@ -361,6 +439,53 @@ export class NativeAgentController { } } + // Mirror a CLI-side config change (e.g. codex `/model` typed in the TUI) back + // to the core so the web UI's selector follows it. Tagged source "terminal" + // so the core does not echo it back as a model-switch keystroke. + async #applyCliSessionConfig(config) { + const model = String(config?.model ?? '').trim(); + const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode); + const effort = String(config?.effort ?? '').trim(); + const patch = { configSource: 'terminal' }; + if (model && model !== this.lastSyncedModel) { + this.lastSyncedModel = model; + patch.model = model; + } + if (permissionMode && permissionMode !== this.permissionMode) { + this.permissionMode = permissionMode; + patch.permissionMode = permissionMode; + } + if (effort && effort !== this.effort) { + this.effort = effort; + patch.effort = effort; + } + if (Object.keys(patch).length === 1 || !this.session?.id) return; + if (typeof this.core.updateSession !== 'function') return; + this.session = await this.core.updateSession(this.session.id, patch).catch(() => this.session); + } + + async #applySessionConfigEvent(config, { modelOnly = false } = {}) { + const source = String(config?.source ?? 'web'); + const model = String(config?.model ?? '').trim(); + const permissionMode = config?.permissionMode == null || config?.permissionMode === '' ? '' : normalizePermissionMode(config.permissionMode); + const effort = String(config?.effort ?? '').trim(); + const shouldSwitchModel = source === 'web' && model && model !== this.lastSyncedModel; + if (model) { + this.model = model; + this.lastSyncedModel = model; + } + if (!modelOnly) { + if (permissionMode) this.permissionMode = permissionMode; + if (effort) this.effort = effort; + if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode; + if (typeof config?.planMode === 'boolean') this.planMode = config.planMode; + } + if (shouldSwitchModel && this.paneId) { + const input = nativeModelSwitchInput(this.agentType, model); + if (input) await this.core.inputPane(this.paneId, input).catch(() => undefined); + } + } + async #emitUserMessage(message) { const content = String(message.content ?? ''); const normalized = normalizePrompt(content); @@ -538,7 +663,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_CLAUDE_BIN ?? env.CLAUDE_BIN ?? 'claude', args: [...permissionArgs, ...modelArgs, '--add-dir', projectPath, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: false }], + searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeClaudeTranscriptLine, @@ -554,7 +679,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_CODEX_BIN ?? env.CODEX_BIN ?? 'codex', args: [...modelArgs, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: true }], + searchRoots: [{ dir: transcriptDir, recursive: true, scoped: false }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeCodexTranscriptLine, @@ -572,8 +697,8 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en args: [...modelArgs, ...extraArgs], ensureDirs: [projectChatDir], searchRoots: [ - { dir: projectChatDir, recursive: false }, - { dir: join(geminiRoot, 'tmp'), recursive: true }, + { dir: projectChatDir, recursive: false, scoped: true }, + { dir: join(geminiRoot, 'tmp'), recursive: true, scoped: false }, ], extensions: ['.json'], transcriptFormat: 'json', @@ -589,7 +714,7 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en command: env.EVCOD_QWEN_BIN ?? env.QWEN_BIN ?? 'qwen', args: [...modelArgs, ...extraArgs], ensureDirs: [transcriptDir], - searchRoots: [{ dir: transcriptDir, recursive: false }], + searchRoots: [{ dir: transcriptDir, recursive: false, scoped: true }], extensions: ['.jsonl'], transcriptFormat: 'jsonl', parseTranscriptLine: normalizeQwenTranscriptLine, @@ -603,6 +728,19 @@ export function getAgentConfig(agentType, projectPath, { homeDir = homedir(), en } } +// Keystrokes that switch the active model inside a native agent TUI. Best-effort +// and consistent with how prompts/permissions are injected: claude/codex/gemini/ +// qwen all expose a `/model ` slash command in their interactive UI. +export function nativeModelSwitchInput(agentType, model) { + const trimmed = String(model ?? '').trim(); + if (!trimmed) return ''; + const normalized = String(agentType ?? '').toLowerCase(); + if (['claude', 'codex', 'gemini', 'qwen'].includes(normalized)) { + return terminalSubmit(`/model ${trimmed}`); + } + return ''; +} + function nativeModelArgs(agentType, model) { const trimmed = String(model ?? '').trim(); if (!trimmed) return []; @@ -618,7 +756,38 @@ function nativeModelArgs(agentType, model) { } export function encodeProjectPath(path) { - return resolve(path).replace(/[^a-zA-Z0-9]/g, '-'); + const raw = String(path ?? ''); + const absolute = /^[a-zA-Z]:[\\/]/.test(raw) ? raw : resolve(raw); + return absolute.replace(/[^a-zA-Z0-9]/g, '-'); +} + +function isPathInside(path, dir) { + const base = resolve(dir); + const target = resolve(path); + return target === base || target.startsWith(base + sep); +} + +// Read the cwd a codex rollout was started in (recorded in its session_meta +// record) so a globally-stored rollout can be attributed to a project. +function codexTranscriptCwd(path) { + try { + for (const raw of readFileSync(path, 'utf8').split(/\r?\n/)) { + if (!raw.trim()) continue; + let record; + try { + record = JSON.parse(raw); + } catch { + continue; + } + const cwd = record?.payload?.cwd ?? record?.cwd; + if (record?.type === 'session_meta' || cwd) { + return cwd ? resolve(String(cwd)) : ''; + } + } + } catch { + // Unreadable file: treat as unattributable. + } + return ''; } function snapshotTranscriptFiles(config) { @@ -706,8 +875,19 @@ function agentPromptFromMessage(message, payload) { return String(payload?.agentContent ?? message?.payload?.agentContent ?? message?.content ?? ''); } +// Inject a chat prompt into a native agent's TUI and submit it. A bare CR +// submits the current input, so a multi-line prompt sent as "a\rb\r" would be +// submitted line-by-line (splitting one prompt into several and breaking the +// web-echo dedup). Modern agent TUIs (claude/codex/gemini/qwen) enable +// bracketed paste, so wrap multi-line text in paste markers — newlines inside a +// paste are treated as literal newlines — then send a single trailing CR to +// submit the whole prompt at once. Single-line prompts keep the simple path. function terminalSubmit(value) { - return `${String(value ?? '').replace(/\r?\n/g, '\r')}\r`; + const text = String(value ?? ''); + if (/[\r\n]/.test(text)) { + return `\x1b[200~${text.replace(/\r\n/g, '\n')}\x1b[201~\r`; + } + return `${text}\r`; } function nativePermissionResponseInput(agentType, { response, allow, permission = {} } = {}) { diff --git a/evcod_warp/src/normalizer.js b/evcod_warp/src/normalizer.js index d226383..8d6d869 100644 --- a/evcod_warp/src/normalizer.js +++ b/evcod_warp/src/normalizer.js @@ -157,6 +157,20 @@ export function normalizeCodexTranscriptLine(line) { const type = payload.type; const special = normalizeSpecialMessage(payload, `codex:${type ?? line.type ?? 'event'}:${payload.id ?? line.timestamp ?? ''}`); if (special) return [special]; + // Codex records the active model (and approval/sandbox) per turn in a + // turn_context record. Surface it as a session_config signal so the controller + // can mirror a CLI-side model switch back to the web UI (CLI -> UI sync). + if (line.type === 'turn_context') { + const model = String(payload.model ?? payload.collaboration_mode?.settings?.model ?? '').trim(); + const permissionMode = codexPermissionMode(payload.approval_policy ?? payload.approvalPolicy ?? payload.collaboration_mode?.settings?.approval_policy); + const effort = String(payload.reasoning_effort ?? payload.reasoningEffort ?? payload.collaboration_mode?.settings?.reasoning_effort ?? '').trim(); + const config = {}; + if (model) config.model = model; + if (permissionMode) config.permissionMode = permissionMode; + if (effort) config.effort = effort; + if (Object.keys(config).length === 0) return []; + return [{ kind: 'session_config', status: 'completed', payload: config, key: `codex:turn_context:${payload.turn_id ?? line.timestamp ?? model ?? ''}` }]; + } if (line.type === 'response_item') { if (type === 'message') { const text = extractCodexText(payload.content); @@ -234,31 +248,15 @@ export function normalizeCodexTranscriptLine(line) { const usage = payload.info?.last_token_usage ?? payload.info?.total_token_usage ?? payload.info ?? payload; return [{ kind: 'usage', status: 'completed', payload: usage, key: `codex:usage:${line.timestamp ?? JSON.stringify(usage).slice(0, 80)}` }]; } - if (line.type === 'event_msg' && type === 'user_message') { - const content = codexUserMessageText(payload); - return content.trim() && !shouldIgnoreSyntheticCodexUserMessage(content) - ? [{ - role: 'user', - kind: 'text', - content, - status: 'completed', - payload, - key: `codex:user:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`, - }] - : []; - } - if (line.type === 'event_msg' && isCodexAssistantMessageType(type)) { - const content = extractCodexText(payload.message ?? payload.text ?? payload.content ?? payload.delta); - return content - ? [{ - role: 'assistant', - kind: 'text', - content, - status: 'completed', - payload, - key: `codex:${type}:${payload.id ?? line.timestamp ?? content.slice(0, 40)}`, - }] - : []; + // Codex writes every user/assistant message to the rollout twice: once as a + // durable `response_item` (handled above) and once as an `event_msg` + // (`user_message` / `agent_message`) for live streaming. Mirroring both would + // duplicate every message in the web chat, so the durable `response_item` is + // the single source of truth for message text and these `event_msg` variants + // are ignored. Reasoning is the exception: `response_item` reasoning summaries + // are often empty/encrypted, so readable reasoning only arrives via `event_msg`. + if (line.type === 'event_msg' && (type === 'user_message' || isCodexAssistantMessageType(type))) { + return []; } if (line.type === 'event_msg' && isCodexReasoningType(type)) { const content = extractCodexText(payload.delta ?? payload.text ?? payload.message ?? payload.reasoning ?? payload.content); @@ -285,24 +283,21 @@ export function normalizeCodexTranscriptLine(line) { return []; } -function codexUserMessageText(payload) { - if (payload.message != null) return String(payload.message); - if (payload.text != null) return String(payload.text); - if (payload.content != null) return extractCodexText(payload.content); - if (Array.isArray(payload.text_elements)) { - return payload.text_elements - .map((part) => (typeof part === 'string' ? part : part.text ?? part.content ?? part.message ?? '')) - .filter(Boolean) - .join('\n'); - } - return ''; -} - function shouldIgnoreSyntheticCodexUserMessage(content) { const normalized = String(content ?? '').trim(); return normalized.startsWith('# AGENTS.md instructions') || normalized.startsWith(''); } +function codexPermissionMode(value) { + const normalized = String(value ?? '').trim().toLowerCase(); + if (!normalized) return ''; + if (['never', 'on-request', 'on_request', 'ask', 'ask-first', 'ask_first'].includes(normalized)) return 'ask-first'; + if (['read-only', 'read_only', 'readonly'].includes(normalized)) return 'read-only'; + if (['on-failure', 'on_failure', 'untrusted'].includes(normalized)) return 'ask-first'; + if (['always', 'full-access', 'full_access', 'danger-full-access'].includes(normalized)) return 'full-access'; + return ''; +} + function isCodexAssistantMessageType(type) { return [ 'agent_message', diff --git a/evcod_warp/src/opencode-tui.js b/evcod_warp/src/opencode-tui.js index 2339ae5..2ce15f2 100644 --- a/evcod_warp/src/opencode-tui.js +++ b/evcod_warp/src/opencode-tui.js @@ -22,7 +22,12 @@ export class OpencodeTuiController { this.paneId = paneId; this.projectId = projectId; this.cwd = cwd; - this.model = model; + this.model = parseOpencodeModel(model) ?? model; + this.modelKey = opencodeModelKey(this.model); + this.permissionMode = undefined; + this.effort = undefined; + this.fastMode = false; + this.planMode = false; this.attach = attach; this.server = undefined; this.serverProc = undefined; @@ -118,6 +123,8 @@ export class OpencodeTuiController { if (message?.source === 'agent' || source === 'agent' || source === 'terminal') return; const turnId = String(event.payload.turnId ?? message?.turnId ?? ''); await this.enqueuePrompt(agentPromptFromMessage(message, event.payload), turnId, source); + } else if (event.event === 'agent.config.changed' || event.event === 'agent.model.changed') { + await this.#applySessionConfigEvent(event.payload, { modelOnly: event.event === 'agent.model.changed' }); } else if (event.event === 'agent.cancel.requested') { if (this.currentTurnId) this.cancelledTurns.add(this.currentTurnId); if (this.sessionId) await this.server.abort(this.sessionId); @@ -378,6 +385,28 @@ export class OpencodeTuiController { }); } + async #applySessionConfigEvent(config, { modelOnly = false } = {}) { + const source = String(config?.source ?? 'web'); + const model = parseOpencodeModel(config?.model); + const nextModelKey = opencodeModelKey(model); + const shouldSwitchModel = source === 'web' && model && nextModelKey !== this.modelKey; + if (model) { + this.model = model; + this.modelKey = nextModelKey; + } + if (!modelOnly) { + const permissionMode = String(config?.permissionMode ?? '').trim(); + const effort = String(config?.effort ?? '').trim(); + if (permissionMode) this.permissionMode = permissionMode; + if (effort) this.effort = effort; + if (typeof config?.fastMode === 'boolean') this.fastMode = config.fastMode; + if (typeof config?.planMode === 'boolean') this.planMode = config.planMode; + } + if (shouldSwitchModel && this.paneId && typeof this.core.inputPane === 'function') { + await this.core.inputPane(this.paneId, terminalSubmit(`/model ${model.providerID}/${model.modelID}`)).catch(() => undefined); + } + } + async #completeActiveTurn(status) { if (!this.currentTurnId || this.completedTurns.has(this.currentTurnId)) return; const turnId = this.currentTurnId; @@ -426,6 +455,24 @@ function quote(value) { return `"${value.replace(/(["\\])/g, '\\$1')}"`; } +function parseOpencodeModel(value) { + if (value && typeof value === 'object' && value.providerID && value.modelID) { + return { providerID: String(value.providerID), modelID: String(value.modelID) }; + } + const text = String(value ?? '').trim(); + const slash = text.indexOf('/'); + if (slash <= 0 || slash === text.length - 1) return undefined; + return { providerID: text.slice(0, slash), modelID: text.slice(slash + 1) }; +} + +function opencodeModelKey(model) { + return model?.providerID && model?.modelID ? `${model.providerID}/${model.modelID}` : ''; +} + +function terminalSubmit(value) { + return `${value}\r`; +} + function normalizePrompt(value) { return String(value ?? '').replace(/\r\n/g, '\n').trim(); } diff --git a/evcod_warp/test/native-agent.test.js b/evcod_warp/test/native-agent.test.js index 8efd9e7..6817e90 100644 --- a/evcod_warp/test/native-agent.test.js +++ b/evcod_warp/test/native-agent.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; +import { existsSync, utimesSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { NativeAgentController, encodeProjectPath, getAgentConfig } from '../src/native-agent.js'; @@ -427,6 +427,35 @@ for (const agentType of ['claude', 'codex', 'qwen']) { }); } +test('submits multi-line web prompts as a single bracketed paste, not line-by-line', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-multiline-')); + const calls = []; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'claude', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.inputReadyAt = 0; + + const single = await controller.promptFromChat('just one line', 'turn-1', 'web'); + assert.equal(single, true); + assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', 'just one line\r']); + + await controller.promptFromChat('line one\nline two\nline three', 'turn-2', 'web'); + // Wrapped in bracketed paste so the TUI keeps the newlines literal, then one CR. + assert.deepEqual(calls.at(-1), ['inputPane', 'pane-1', '\x1b[200~line one\nline two\nline three\x1b[201~\r']); +}); + test('mirrors Codex response_item user and event_msg assistant records into CORE', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); @@ -477,7 +506,7 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE JSON.stringify({ type: 'response_item', timestamp: 'u1', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'from response item' }] } }), JSON.stringify({ type: 'event_msg', timestamp: 'r1', payload: { type: 'agent_reasoning_delta', delta: 'inspect ' } }), JSON.stringify({ type: 'event_msg', timestamp: 'r2', payload: { type: 'agent_reasoning_delta', delta: 'state' } }), - JSON.stringify({ type: 'event_msg', timestamp: 'a1', payload: { type: 'agent_message', message: 'answer from event' } }), + JSON.stringify({ type: 'response_item', timestamp: 'a1', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer from event' }] } }), JSON.stringify({ type: 'event_msg', timestamp: 'done1', payload: { type: 'task_complete', turn_id: 'codex-turn' } }), '', ].join('\n'), @@ -500,6 +529,72 @@ test('mirrors Codex response_item user and event_msg assistant records into CORE await controller.stop(); }); +test('mirrors a CLI-side codex model switch back to the core as a terminal config change', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const transcriptPath = join(projectPath, 'codex-model.jsonl'); + const calls = []; + + const core = { + async updateSession(id, patch) { + calls.push(['updateSession', id, patch]); + return { id, ...patch }; + }, + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `message-${calls.length}`, ...message }; + }, + async sendUserMessage(conversationId, content, source) { + return { messages: [{ id: 'u', role: 'user', turnId: 't', content, source }] }; + }, + async acquireLock(id, owner, turnId) { + return { id, lockOwner: owner, lockTurnId: turnId }; + }, + async releaseLock(id, owner, status) { + return { id, status }; + }, + async completeTurn() {}, + }; + + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'codex', + projectPath, + homeDir, + model: 'gpt-5', + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = transcriptPath; + controller.session = { id: 'session-1' }; + + await writeFile( + transcriptPath, + [ + // Same model as launch -> no redundant push. + JSON.stringify({ type: 'turn_context', timestamp: 'tc1', payload: { turn_id: 't1', cwd: projectPath, model: 'gpt-5' } }), + // User switched the model in the TUI -> push back to core (source terminal). + JSON.stringify({ type: 'turn_context', timestamp: 'tc2', payload: { turn_id: 't2', cwd: projectPath, model: 'gpt-5.5' } }), + '', + ].join('\n'), + 'utf8', + ); + await controller.syncOnce(); + + const updates = calls.filter((call) => call[0] === 'updateSession'); + assert.equal(updates.length, 1); + assert.deepEqual(updates[0], ['updateSession', 'session-1', { model: 'gpt-5.5', configSource: 'terminal' }]); + // session_config signals must not leak into the chat as messages. + assert.equal(calls.filter((call) => call[0] === 'createAgentMessage').length, 0); + + await controller.stop(); +}); + test('native agent cancel sends Ctrl-C to the native terminal and releases the turn', async () => { const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); @@ -554,6 +649,117 @@ test('native agent cancel sends Ctrl-C to the native terminal and releases the t assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']); }); +test('web model changes are injected into the native TUI, terminal-origin ones are not', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-model-')); + const calls = []; + let relayHandler; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'codex', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.session = { id: 'session-1' }; + controller.subscribe(); + + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' }, + }); + // Core also emits the broader config event for the same model change; the + // native TUI should receive one slash command, not two. + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5-codex', source: 'web' }, + }); + // A change the CLI already applied (launch/terminal) must not be re-typed. + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'gpt-5', source: 'launch' }, + }); + // A change for a different conversation must be ignored. + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'other', model: 'gpt-4', source: 'web' }, + }); + await new Promise((resolveWait) => setTimeout(resolveWait, 0)); + + assert.deepEqual(calls, [['inputPane', 'pane-1', '/model gpt-5-codex\r']]); +}); + +test('native agent consumes full session config changes without echo loops', async () => { + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-config-')); + const calls = []; + let relayHandler; + const core = { + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + agentType: 'claude', + projectPath, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.session = { id: 'session-1' }; + controller.subscribe(); + + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { + conversationId: 'conversation-1', + model: 'claude-sonnet-4-6', + permissionMode: 'ask-first', + effort: 'high', + fastMode: true, + planMode: true, + source: 'web', + }, + }); + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { conversationId: 'conversation-1', model: 'claude-opus-4-8', source: 'terminal' }, + }); + await new Promise((resolveWait) => setTimeout(resolveWait, 0)); + + assert.equal(controller.model, 'claude-opus-4-8'); + assert.equal(controller.permissionMode, 'ask-first'); + assert.equal(controller.effort, 'high'); + assert.equal(controller.fastMode, true); + assert.equal(controller.planMode, true); + assert.deepEqual(calls, [['inputPane', 'pane-1', '/model claude-sonnet-4-6\r']]); +}); + test('native permission responses are mapped to provider TUI input', async () => { const cases = [ { agentType: 'claude', allow: '1\r', deny: '2\r' }, @@ -968,6 +1174,117 @@ test('starts a new terminal-origin turn without inheriting the previous active t await controller.stop(); }); +test('follows a transcript rotation within a scoped project dir without losing messages', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const transcriptDir = join(homeDir, '.claude', 'projects', encodeProjectPath(projectPath)); + await mkdir(transcriptDir, { recursive: true }); + const fileA = join(transcriptDir, 'a.jsonl'); + const fileB = join(transcriptDir, 'b.jsonl'); + const calls = []; + const core = { + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `m${calls.length}`, ...message }; + }, + async completeTurn() {}, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'c1', + paneId: 'p1', + agentType: 'claude', + projectPath, + homeDir, + env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' }, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('claude', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = fileA; + + await writeFile(fileA, JSON.stringify({ type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'answer A' }] } }) + '\n', 'utf8'); + utimesSync(fileA, new Date(Date.now() - 10000), new Date(Date.now() - 10000)); + await controller.syncOnce(); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer A')); + + // A fresh session file appears (rotation) while file A goes idle. + await writeFile(fileB, JSON.stringify({ type: 'assistant', uuid: 'b1', message: { content: [{ type: 'text', text: 'answer B' }] } }) + '\n', 'utf8'); + utimesSync(fileB, new Date(Date.now() + 10000), new Date(Date.now() + 10000)); + await controller.syncOnce(); + + assert.equal(controller.transcriptPath, fileB); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer B')); + + await controller.stop(); +}); + +test('does not follow a newer codex rollout belonging to a different project', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'evcod-native-home-')); + const projectPath = await mkdtemp(join(tmpdir(), 'evcod-native-project-')); + const sessionsDir = join(homeDir, '.codex', 'sessions'); + await mkdir(sessionsDir, { recursive: true }); + const ours = join(sessionsDir, 'ours.jsonl'); + const foreign = join(sessionsDir, 'foreign.jsonl'); + const calls = []; + const core = { + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `m${calls.length}`, ...message }; + }, + async completeTurn() {}, + close() {}, + }; + const controller = new NativeAgentController({ + core, + conversationId: 'c1', + paneId: 'p1', + agentType: 'codex', + projectPath, + homeDir, + env: { EVCOD_NATIVE_IMPLICIT_TURN_COMPLETE_MS: '-1' }, + spawnImpl() { + throw new Error('spawn should not run in this test'); + }, + }); + controller.agentConfig = getAgentConfig('codex', projectPath, { homeDir }); + controller.parseTranscriptLine = controller.agentConfig.parseTranscriptLine; + controller.transcriptPath = ours; + + await writeFile( + ours, + [ + JSON.stringify({ type: 'session_meta', payload: { cwd: projectPath } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer ours' }] } }), + '', + ].join('\n'), + 'utf8', + ); + utimesSync(ours, new Date(Date.now() - 10000), new Date(Date.now() - 10000)); + await controller.syncOnce(); + assert(calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer ours')); + + await writeFile( + foreign, + [ + JSON.stringify({ type: 'session_meta', payload: { cwd: '/some/other/project' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer foreign' }] } }), + '', + ].join('\n'), + 'utf8', + ); + utimesSync(foreign, new Date(Date.now() + 10000), new Date(Date.now() + 10000)); + await controller.syncOnce(); + + assert.equal(controller.transcriptPath, ours); + assert(!calls.some((call) => call[0] === 'createAgentMessage' && call[2].content === 'answer foreign')); + + await controller.stop(); +}); + function transcriptRecords(agentType, userContent, assistantContent, suffix) { if (agentType === 'claude') { return [ diff --git a/evcod_warp/test/normalizer.test.js b/evcod_warp/test/normalizer.test.js index b5f7485..16e06af 100644 --- a/evcod_warp/test/normalizer.test.js +++ b/evcod_warp/test/normalizer.test.js @@ -259,14 +259,14 @@ test('normalizes Codex TodoWrite function calls as todo lists', () => { }); test('normalizes Codex rollout transcript records', () => { + // Codex mirrors each user prompt as both an event_msg/user_message and a + // durable response_item; only the response_item is mirrored to avoid duplicates. const user = normalizeCodexTranscriptLine({ type: 'event_msg', timestamp: 'u1', payload: { type: 'user_message', message: 'typed in codex tui', images: [], local_images: [], text_elements: [] }, }); - assert.equal(user[0].role, 'user'); - assert.equal(user[0].kind, 'text'); - assert.equal(user[0].content, 'typed in codex tui'); + assert.deepEqual(user, []); const responseUser = normalizeCodexTranscriptLine({ type: 'response_item', @@ -306,15 +306,15 @@ test('normalizes Codex rollout transcript records', () => { assert.equal(complete[0].finalizesTurn, true); }); -test('normalizes Codex event_msg assistant records from transcripts', () => { +test('ignores duplicate Codex event_msg messages but keeps reasoning and errors', () => { + // agent_message duplicates the durable response_item assistant message, so it + // is ignored to avoid double-mirroring every reply into the web chat. const text = normalizeCodexTranscriptLine({ type: 'event_msg', timestamp: 'a1', payload: { type: 'agent_message', message: 'assistant event text' }, }); - assert.equal(text[0].role, 'assistant'); - assert.equal(text[0].kind, 'text'); - assert.equal(text[0].content, 'assistant event text'); + assert.deepEqual(text, []); const reasoning = normalizeCodexTranscriptLine({ type: 'event_msg', @@ -330,8 +330,7 @@ test('normalizes Codex event_msg assistant records from transcripts', () => { timestamp: 'a2', payload: { type: 'assistant_message', message: 'assistant alias text' }, }); - assert.equal(assistantAlias[0].kind, 'text'); - assert.equal(assistantAlias[0].content, 'assistant alias text'); + assert.deepEqual(assistantAlias, []); const failed = normalizeCodexTranscriptLine({ type: 'event_msg', @@ -343,6 +342,30 @@ test('normalizes Codex event_msg assistant records from transcripts', () => { assert.equal(failed[0].finalizesTurn, true); }); +test('surfaces the Codex active model from turn_context as a session_config signal', () => { + const cfg = normalizeCodexTranscriptLine({ + type: 'turn_context', + timestamp: 'tc1', + payload: { turn_id: 'turn-1', cwd: '/tmp/project', model: 'gpt-5.5', approval_policy: 'never' }, + }); + assert.equal(cfg.length, 1); + assert.equal(cfg[0].kind, 'session_config'); + assert.deepEqual(cfg[0].payload, { model: 'gpt-5.5', permissionMode: 'ask-first' }); + + const none = normalizeCodexTranscriptLine({ type: 'turn_context', payload: { turn_id: 'turn-2' } }); + assert.deepEqual(none, []); +}); + +test('surfaces Codex approval and effort from turn_context as session config', () => { + const cfg = normalizeCodexTranscriptLine({ + type: 'turn_context', + timestamp: 't1', + payload: { turn_id: 'turn-1', approval_policy: 'never', reasoning_effort: 'medium' }, + }); + assert.equal(cfg[0].kind, 'session_config'); + assert.deepEqual(cfg[0].payload, { permissionMode: 'ask-first', effort: 'medium' }); +}); + test('ignores synthetic Codex user context records', () => { const messages = normalizeCodexTranscriptLine({ type: 'response_item', diff --git a/evcod_warp/test/opencode-tui.test.js b/evcod_warp/test/opencode-tui.test.js index 21b87fd..f5dcba3 100644 --- a/evcod_warp/test/opencode-tui.test.js +++ b/evcod_warp/test/opencode-tui.test.js @@ -291,6 +291,88 @@ test('opencode cancel aborts the server and releases the active turn as canceled assert.deepEqual(calls.find((call) => call[0] === 'releaseLock'), ['releaseLock', 'session-1', 'web', 'idle']); }); +test('opencode consumes session config changes and uses the new model', async () => { + const calls = []; + let relayHandler; + const core = { + subscribe(handler) { + relayHandler = handler; + return { readyState: 1, addEventListener() {}, close() {} }; + }, + async inputPane(paneId, data) { + calls.push(['inputPane', paneId, data]); + }, + async acquireLock(sessionId, owner, turnId, expectedVersion, providerSessionId) { + calls.push(['acquireLock', sessionId, owner, turnId, expectedVersion, providerSessionId]); + return { id: sessionId, lockOwner: owner, turnId }; + }, + async releaseLock(sessionId, owner, status) { + calls.push(['releaseLock', sessionId, owner, status]); + return { id: sessionId, status }; + }, + async completeTurn(conversationId, turnId, status) { + calls.push(['completeTurn', conversationId, turnId, status]); + }, + async createAgentMessage(conversationId, message) { + calls.push(['createAgentMessage', conversationId, message]); + return { id: `message-${calls.length}`, ...message }; + }, + close() {}, + }; + const controller = new OpencodeTuiController({ + core, + conversationId: 'conversation-1', + paneId: 'pane-1', + projectId: 'project-1', + cwd: process.cwd(), + model: 'openai/gpt-5.4', + }); + controller.session = { id: 'session-1' }; + controller.sessionId = 'opencode-session'; + controller.server = { + async sendMessage(sessionId, prompt, model) { + calls.push(['sendMessage', sessionId, prompt, model]); + }, + async listMessages() { + return [{ info: { id: 'assistant-1', role: 'assistant', time: { completed: 1 } }, parts: [{ id: 'text', type: 'text', text: 'ok' }] }]; + }, + }; + + controller.subscribe(); + relayHandler({ + type: 'event', + event: 'agent.config.changed', + payload: { + conversationId: 'conversation-1', + model: 'anthropic/claude-sonnet-4-6', + permissionMode: 'ask-first', + effort: 'high', + fastMode: true, + planMode: true, + source: 'web', + }, + }); + relayHandler({ + type: 'event', + event: 'agent.model.changed', + payload: { conversationId: 'conversation-1', model: 'anthropic/claude-sonnet-4-6', source: 'web' }, + }); + await controller.promptFromChat('hello', 'turn-1', 'web'); + + assert.equal(controller.permissionMode, 'ask-first'); + assert.equal(controller.effort, 'high'); + assert.equal(controller.fastMode, true); + assert.equal(controller.planMode, true); + assert.deepEqual(calls.find((call) => call[0] === 'inputPane'), ['inputPane', 'pane-1', '/model anthropic/claude-sonnet-4-6\r']); + assert.equal(calls.filter((call) => call[0] === 'inputPane').length, 1); + assert.deepEqual(calls.find((call) => call[0] === 'sendMessage'), [ + 'sendMessage', + 'opencode-session', + 'hello', + { providerID: 'anthropic', modelID: 'claude-sonnet-4-6' }, + ]); +}); + test('opencode catchUpPending replays every pending web prompt after the last assistant message', async () => { const prompts = []; const core = { From 58a2db0d1d6fb56569593d34d9d5ef1341d4d298 Mon Sep 17 00:00:00 2001 From: wt <123@qq.com> Date: Sat, 20 Jun 2026 12:07:02 +0800 Subject: [PATCH 2/2] chore: remove local guide documents --- AGENTS.md | 32 ---- CLAUDE.md | 81 ---------- PROJECT_OVERVIEW.md | 361 -------------------------------------------- 3 files changed, 474 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 PROJECT_OVERVIEW.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 785dcab..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -This monorepo contains three active code areas. `evcod/core/` is the Go backend service, with the entry point in `cmd/evcod-core/` and internal packages under `internal/` for API routing, services, storage, platform integration, events, and config. `evcod/webui/` is the React 19 + Vite + TypeScript client; source lives in `src/`, reusable UI in `src/components/`, chat-specific code in `src/chat/`, browser scripts in `tests/`, and static files in `public/`. `evcod_warp/` is a Node.js ESM CLI bridge; runtime code is in `src/`, command entry points are in `bin/`, and Node tests are in `test/`. Documentation is mainly under `evcod/docs/`, `chat_diff/`, and Chinese review/design documents at the repo root. - -## Build, Test, and Development Commands - -- `evcod/dev.command`: start the local development environment, including core and WebUI. -- `evcod/test_scripts/run_full_tests.sh`: run the full pipeline: Go formatting/tests/build, core smoke tests, warp tests, WebUI build, and browser flows. -- `evcod/test_scripts/run_full_tests.sh --no-browser`: run the pipeline without Playwright browser flows. -- `go -C evcod/core test ./...`: run all Go tests. -- `go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core`: build the core binary. -- `npm --prefix evcod/webui run build`: type-check and build the WebUI. -- `npm --prefix evcod/webui run dev`: start Vite on `127.0.0.1`. -- `npm --prefix evcod_warp test`: run Node tests for the warp CLI. - -## Coding Style & Naming Conventions - -Format Go with `gofmt`; use package-local tests named `*_test.go`. TypeScript and React files use ESM imports, functional components, and existing component naming such as `ConversationView.tsx`. Keep Node code in `evcod_warp` as ESM and require Node `>=22`. Match the surrounding documentation language; many docs and comments are Chinese. - -## Testing Guidelines - -Place Go tests beside the package they cover. WebUI browser flows are standalone Node/Playwright scripts in `evcod/webui/tests/` and may require `EVCOD_CORE_URL`, `EVCOD_WEB_URL`, and `EVCOD_API_KEY`. Warp tests use the built-in `node --test` runner and follow `*.test.js` naming. - -## Commit & Pull Request Guidelines - -Recent commits mostly use Conventional Commit style, for example `feat(core,webui,warp): ...`, `fix: ...`, `perf: ...`, and `test(webui): ...`. Prefer scoped, imperative subjects. Pull requests should summarize behavior changes, list validation commands run, link related issues or design docs, and include screenshots or recordings for visible WebUI changes. - -## Security & Configuration Tips - -Core requests require API keys. Use `go -C evcod/core run ./cmd/evcod-core key print` to create or print the default key and `key rotate` when credentials may be exposed. Avoid committing local state files, generated archives, or secrets. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 238bc2a..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Repository layout - -This is a multi-module monorepo for **evcod**, a local-first remote terminal/coding-assistant tool. Three code modules plus Chinese design docs: - -- `evcod/core/` — Go 1.26 backend service (`evcod-core` binary). Auth, projects, files, Git, workspace/worktrees, terminals (PTY), conversations, agent sessions, host metrics, and local state persistence. Serves both REST + WebSocket and (optionally) the built WebUI. -- `evcod/webui/` — React 19 + Vite 6 + TypeScript browser client (`evcod-webui`). Connects to one or more cores; provides terminals, file/Git panels, host dashboard, and chat. State via zustand; terminals via xterm; code editing via Monaco. -- `evcod_warp/` — Node.js (>=22, ESM) CLI `evcod-warp`. Bridges external AI agents (`claude`, `codex`, `gemini`, `qwen`, `opencode`, plus `fake` test fixture) to a running core, mirroring their conversation into the web chat. -- `evcod/docs/`, `chat_diff/`, `审查报告/`, `其他/`, `Chat 对齐文档.md` — Chinese design/architecture/review documents. API contract lives in `evcod/docs/api/` (see `rpc-protocol.md`, `overview.md`, `openapi.json`). - -## Common commands - -All commands below assume the repo root unless noted. The Go module path is `evcod/core`; use `go -C ` rather than `cd`. - -### Run the full dev environment -```bash -evcod/dev.command # builds core, starts core (:10065) + webui dev (:10066), prints API key -``` - -### Full test/build pipeline (the CI equivalent) -```bash -evcod/test_scripts/run_full_tests.sh # gofmt, go test, build, core API smoke, warp tests, webui build + browser E2E -evcod/test_scripts/run_full_tests.sh --no-browser # skip the Playwright browser flows -``` - -### Core (Go) -```bash -go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core -go -C evcod/core test ./... # all tests -go -C evcod/core test ./internal/services -run TestAgentCatalog # single test -gofmt -w evcod/core # format (run before committing Go) -go -C evcod/core run ./cmd/evcod-core key print # print/create default API key -go -C evcod/core run ./cmd/evcod-core key rotate # revoke all keys, create a new one -go -C evcod/core run ./cmd/evcod-core serve # run server (default bind 127.0.0.1:4865) -``` - -### WebUI (Node) -```bash -npm --prefix evcod/webui ci # install -npm --prefix evcod/webui run dev # vite dev server -npm --prefix evcod/webui run build # tsc -b && vite build -# Browser E2E (Playwright-driven plain node scripts; require a running core + webui): -EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke -# also: test:full-flow, test:feature-flow, test:agent-pane, test:agent-all -``` - -### Warp (Node) -```bash -npm --prefix evcod_warp test # node --test, all test/*.test.js -node --test evcod_warp/test/normalizer.test.js # single test file -node evcod_warp/bin/evcod-warp.js --list # list available agent backends -``` - -## Architecture - -### Core service (Go) -Layered, dependency flows inward: `cmd/evcod-core/main.go` → `internal/app` → `internal/api` (HTTP/WS router) → `internal/services` → `internal/store` + `internal/platform` + `internal/events`. - -- **`app.New` wiring**: opens the store, creates the events hub, constructs `services.Services` (a struct aggregating `Keys`, `Projects`, `Files`, `Git`, `Terminal`, `Chat`, `Agent`, `Host`, workspace). `Serve` ensures an API key, exports `EVCOD_CORE_URL`/`EVCOD_API_KEY` into the process env (so a colocated warp/agent can find the core), starts the host metrics loop and the scheduled chat queue, then serves HTTP. -- **Store (`internal/store/store.go`)**: persistence is a **single JSON file** loaded fully into memory behind a `sync.RWMutex` — there is no SQL database despite the `--db` flag naming. The `state` struct is the entire schema; every entity list (projects, worktrees, conversations, messages, agent sessions/turns, timeline events, terminal panes, host metrics, audit logs, etc.) lives there and is rewritten on change. Keep this in mind for performance and concurrency. -- **API router (`internal/api/router.go`)**: a single `http.ServeMux`. `ServeHTTP` applies CORS, optionally serves the built WebUI for non-`/api`/`/ws` GETs (`serveWebUI`, path-traversal guarded), then authenticates every `/api`/`/ws` request by bearer token (or `?token=` for browser WebSockets) and checks scope before dispatching. Two WebSocket endpoints: `/ws/rpc` (client RPC) and `/ws/relay` (agent/relay). -- **Platform abstraction (`internal/platform`)**: `NewRuntime()` factory selects an implementation; `common/` holds cross-platform defaults, `mac/` and `win/` hold OS specifics (PTY, default state path, host metrics). Add OS-specific behavior here, not in services. -- **Events hub (`internal/events/hub.go`)**: in-process pub/sub. Services publish events (terminal output, chat/timeline updates, host metrics) that the WS layer fans out to subscribers. - -### WS RPC protocol -JSON envelopes (`type: request|response|event`) inspired by muxy — see `evcod/docs/api/rpc-protocol.md` for the method table (`terminal.*`, `conversation.*`, etc.). REST endpoints and the RPC protocol are two parallel surfaces over the same services; keep both in sync when adding capabilities, and update `evcod/docs/api/` + `openapi.json`. - -### Agent bridge (warp) -`evcod_warp` connects to a core via `CoreBridge` (`src/core-bridge.js`: REST for conversations/messages, WS for events). `src/cli.js` routes by agent type: **native agents** (`claude`, `codex`, `gemini`, `qwen`, `opencode`) run their real TUI inside a core terminal pane and mirror output to web chat (`native-agent.js`, `opencode-tui.js`); only `fake` uses the legacy readline `SessionController` scheme. Backends live in `src/backends/`, transports (jsonl/jsonrpc) in `src/transports/`, output normalization in `normalizer.js`. The agent catalog the WebUI shows is computed server-side in `core/internal/services/agent_catalog.go` by probing for agent binaries and config (env-var overrides like `EVCOD_CLAUDE_BIN`/`EVCOD_CLAUDE_MODEL`). - -## Configuration & auth -- Core config (`internal/config`): `EVCOD_BIND` (default `127.0.0.1:4865`), `EVCOD_DB` (state JSON path), `EVCOD_WEBUI_DIR` (serve built WebUI). Flags `--bind`, `--db`, `--webui-dir` override. -- Auth: every request needs an API key. REST/non-browser WS use `Authorization: Bearer evcod_xxx`; browser WebSockets pass `?token=evcod_xxx`. The first server start auto-creates a default key; `key rotate` revokes all keys. - -## Conventions -- `gofmt` is enforced by the test pipeline — format Go before committing. -- Most documentation and many code comments are in Chinese; match the surrounding language when editing docs. -- Browser E2E "tests" in `webui/tests/` and `test_scripts/*.mjs` are standalone node scripts (Playwright-driven), not a unit-test framework — they need a live core + webui and the `EVCOD_CORE_URL`/`EVCOD_WEB_URL`/`EVCOD_API_KEY` env vars. diff --git a/PROJECT_OVERVIEW.md b/PROJECT_OVERVIEW.md deleted file mode 100644 index 40f5183..0000000 --- a/PROJECT_OVERVIEW.md +++ /dev/null @@ -1,361 +0,0 @@ -# evcod 项目完整介绍 - -> 本文档面向第一次接触本仓库的工程师 / 协作者,完整介绍 **evcod** 的定位、架构、模块职责、数据模型、接口协议、运行与测试方式。 -> -> 更新时间:2026-06-20 - ---- - -## 1. 项目定位 - -**evcod** 是一个**本地优先(local-first)的远程终端 / 编码助手工具**。核心思路:在你的本机(或局域网内某台机器)常驻一个内核服务,浏览器作为客户端连接它,从而获得一套"随处可访问"的远程开发工作台——远程终端、项目 / 文件 / Git 管理、主机监控仪表盘,以及把外部 AI 编码 Agent(Claude、Codex、Gemini 等)的对话镜像进网页聊天。 - -设计上把能力拆成三个独立产品面 / 模块: - -| 模块 | 目录 | 语言 / 运行时 | 角色 | -|------|------|---------------|------| -| **core** | `evcod/core/` | Go 1.26(`evcod-core` 二进制) | 常驻内核服务:鉴权、项目、文件、Git、工作区 / worktree、终端(PTY)、会话、Agent 会话、主机指标、本地状态持久化。同时提供 REST + WebSocket,并可托管已构建的 WebUI。 | -| **webui** | `evcod/webui/` | React 19 + Vite 6 + TypeScript(`evcod-webui`) | 浏览器客户端:连接一个或多个 core,提供终端、文件 / Git 面板、主机仪表盘、聊天。 | -| **warp** | `evcod_warp/` | Node.js ≥22(ESM,CLI `evcod-warp`) | Agent 桥:把外部 AI Agent(`claude` / `codex` / `gemini` / `qwen` / `opencode`,以及测试用 `fake`)接入运行中的 core,把它们的对话镜像到网页聊天。 | - -此外还有大量中文设计 / 评审文档(见第 9 节)。当前架构参考了 MUXY 的远程终端 / 工作区思路,并在 Web UI 上实现了类似桌面工具的多栏工作台布局。 - ---- - -## 2. 仓库总览 - -```text -evcod_all/ -├── CLAUDE.md # 给 Claude Code 的项目指引(也适合人快速上手) -├── Chat 对齐文档.md # 聊天 / 会话对齐设计文档 -├── chat_diff/ # 聊天相关 diff / 对齐记录 -├── 审查报告/ # 评审报告(中文) -├── 其他/ # 杂项文档 -├── start-evcod.bat # Windows 启动入口 -└── evcod/ - ├── README.md - ├── dev.command # macOS 一键开发环境(构建 core + 起 core/webui) - ├── start.bat / start-dev.bat - ├── core/ # Go 内核服务 - │ ├── cmd/evcod-core/ # main 入口(serve / key print / key rotate) - │ └── internal/ - │ ├── app/ # App.New 装配、Serve 生命周期 - │ ├── config/ # 配置(环境变量 + flag) - │ ├── api/ # HTTP/WS 路由(单一 ServeMux) - │ ├── services/ # 业务服务层 - │ ├── store/ # 单 JSON 文件持久化 - │ ├── domain/ # 领域模型(纯数据结构) - │ ├── events/ # 进程内事件 hub(pub/sub) - │ └── platform/ # 平台抽象(common / mac / win) - ├── webui/ # React 浏览器客户端 - │ ├── src/ # 应用源码 - │ └── tests/ # Playwright 驱动的浏览器 E2E(独立 node 脚本) - ├── docs/ # API 契约 + 中文项目文档 - │ └── api/ # rpc-protocol.md / overview.md / openapi.json 等 - ├── scripts/ - ├── test_scripts/ # run_full_tests.sh(CI 等价物)+ 各类 .mjs - └── gomoku-game/ # 示例 / 演示项目 -└── evcod_warp/ - ├── bin/evcod-warp.js # CLI 入口 - ├── src/ - │ ├── cli.js # 按 agent 类型路由 - │ ├── core-bridge.js # 连接 core(REST + WS) - │ ├── native-agent.js # 原生 Agent 在终端 pane 内运行并镜像 - │ ├── opencode-*.js # opencode 专用 server / tui - │ ├── session-controller.js # 旧式 readline 方案(仅 fake) - │ ├── normalizer.js # 输出归一化 - │ ├── registry.js / types.js / tool-detail.js - │ ├── backends/ # claude.js / codex.js / opencode.js / fake.js - │ └── transports/ # jsonl.js / jsonrpc.js / factory.js - └── test/ # node --test 单元测试 -``` - ---- - -## 3. Core 服务(Go) - -### 3.1 分层架构 - -依赖**自外向内单向流动**: - -``` -cmd/evcod-core/main.go - → internal/app (装配 + 生命周期) - → internal/api (HTTP/WS 路由) - → internal/services (业务逻辑) - → internal/store (持久化) - + internal/platform (平台抽象) - + internal/events (事件 hub) -``` - -### 3.2 启动与装配(`app.New` / `Serve`) - -- `app.New`:创建平台 `Runtime` → 打开 store(默认状态路径来自 runtime)→ 创建 events hub → 构造 `services.Services`(聚合 `Keys`、`Projects`、`Files`、`Git`、`Terminal`、`Chat`、`Agent`、`Host`、`Workspace`)。 -- `Serve`: - 1. 确保存在 API key(首次启动自动创建默认 key); - 2. 把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 写入进程环境变量——**这样同机运行的 warp / agent 能自动发现 core**; - 3. 启动主机指标采集循环(`Host.Start`); - 4. 启动定时聊天队列(`Chat.StartScheduledQueue`); - 5. 起 HTTP server(带 10s ReadHeaderTimeout),监听 ctx 取消优雅关闭。 - -入口命令(`main.go`): -- `serve`(默认)——启动服务; -- `key print`——打印 / 创建默认 API key; -- `key rotate`——吊销所有 key 并新建一个。 - -### 3.3 Store —— 单 JSON 文件持久化(重点) - -`internal/store/store.go`:**整个持久化就是一个 JSON 文件,全量加载进内存,由 `sync.RWMutex` 保护**。尽管 config 里有 `--db` flag,但**并没有 SQL 数据库**。 - -- `state` 结构体即完整 schema;每一类实体(projects、worktrees、conversations、messages、agent sessions/turns、timeline events、terminal panes、host metrics、audit logs、device tokens……)都是其中的列表。 -- 任何变更都会**重写整个文件**。 -- ⚠️ 由此带来的工程约束:注意性能(全量序列化)与并发(全局锁);大量小写入要谨慎。 - -### 3.4 API 路由(`internal/api/router.go`) - -单一 `http.ServeMux`,`ServeHTTP` 处理顺序: - -1. 施加 CORS; -2. 对非 `/api`、非 `/ws` 的 GET,可选地托管已构建的 WebUI(`serveWebUI`,带路径穿越防护); -3. 对每个 `/api`、`/ws` 请求做 **bearer token 鉴权**(浏览器 WebSocket 用 `?token=`),并检查 scope,再分发。 - -两个 WebSocket 端点: -- `/ws/rpc` —— 客户端 RPC(WebUI ↔ core); -- `/ws/relay` —— Agent / relay(warp ↔ core)。 - -**主要 REST 端点**(按域分组): - -| 域 | 端点 | -|----|------| -| 系统 / 设置 | `/api/system`、`/api/settings/runtime` | -| 主机监控 | `/api/host/overview`、`/host/performance`、`/host/history`、`/host/interfaces`、`/host/processes`、`/host/ports`、`/host/files/list` | -| 审计 | `/api/audit/logs` | -| 设备令牌 | `/api/tokens`、`/api/tokens/{id}` | -| 项目 | `/api/projects`、`/api/projects/{id}` | -| Worktree | `/api/worktrees`、`/worktrees/refresh`、`/worktrees/{id}` | -| 工作区 Tab | `/api/workspace/tabs`、`/workspace/tabs/{id}` | -| 文件 | `/api/files/list`、`read`、`write`、`search`、`browse-directories`、`create`、`mkdir`、`rename`、`delete` | -| Git | `/api/git/status`、`diff`、`diff-content`、`log`、`branches`、`checkout`、`branch`、`stage`、`unstage`、`discard`、`commit`、`pull`、`push`、`stash`、`stash/apply` | -| 终端 | `/api/terminal/panes`、`/terminal/panes/{id}` | -| Agent | `/api/agent/catalog`、`history`、`history/import`、`launch`、`sessions`、`sessions/{id}` | -| 会话 | `/api/conversations`、`/conversations/{id}` | - -> REST 与 WS RPC 是同一套服务的**两个并行表面**;新增能力时两边都要保持同步,并更新 `evcod/docs/api/` 与 `openapi.json`。 - -### 3.5 平台抽象(`internal/platform`) - -`NewRuntime()` 工厂选择实现: -- `common/` —— 跨平台默认; -- `mac/` —— macOS 特定(PTY、默认状态路径、主机指标); -- `win/` —— Windows 特定。 - -OS 相关行为应放进这里,**不要写进 services**。 - -### 3.6 事件 hub(`internal/events/hub.go`) - -进程内 pub/sub。services 发布事件(终端输出、聊天 / timeline 更新、主机指标),WS 层把事件扇出给订阅者。 - -### 3.7 服务层(`internal/services`) - -`Services` 聚合结构(`services.go`)持有所有子服务: - -- **KeyService** —— API key 生命周期、设备令牌(创建 / 吊销 / scope 归一化)、token 鉴权。 -- **ProjectService** —— 项目增删查。 -- **FileService** —— 文件列举 / 读写 / 搜索 / 重命名 / 删除、目录浏览。 -- **GitService** —— status / diff / log / branch / stage / commit / pull / push / stash 等。 -- **TerminalService** —— PTY 终端 pane(用 `github.com/aymanbagabas/go-pty`),快照 / 输入 / resize / 关闭。 -- **ChatService** —— 会话、消息、timeline 事件、定时消息队列。 -- **AgentService** —— Agent 会话、turn、锁(lockOwner/lockVersion)、provider session 关联。 -- **WorkspaceService** —— worktree、工作区 tab。 -- **HostService** —— 主机概览、性能采样、历史指标(分桶保留)、进程 / 端口 / 接口、主机文件浏览。 -- **agent_catalog.go** —— **服务端探测** agent 二进制与配置(支持 `EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL` 等 env 覆盖),算出 WebUI 展示的 Agent 目录。 - ---- - -## 4. WS RPC 协议 - -JSON 信封,`type: request | response | event`,灵感来自 muxy。方法表见 `evcod/docs/api/rpc-protocol.md`,代表性方法: - -- 终端:`terminal.create` / `terminal.list` / `terminal.input` / `terminal.resize` / `terminal.snapshot` / `terminal.close` -- 会话:`conversation.create` / `conversation.list` / `conversation.send` -- 项目:`project.list` -- 消息 / turn 流:`message.stream`、`turn.completed` -- 工具调用:`tool.call` / `tool.result`、`confirmation.requested` - ---- - -## 5. 领域模型(`internal/domain/models.go`) - -纯数据结构(JSON 标签),核心实体: - -- **Project / Worktree / WorkspaceTab** —— 项目、Git worktree、工作区标签页(kind / title / pinned / color)。 -- **TerminalPane** —— 终端 pane(cwd / rows / cols / status)。 -- **Conversation / ChatMessage** —— 会话与消息(role / kind / content / seq / turnId / source / status / payload)。 -- **AgentTurn** —— Agent 一次回合的完整状态机时间戳(requested / consumed / accepted / invoked / completed / failed / cancelled)+ attemptId / consumerId / providerSessionId。 -- **TimelineEvent** —— 带 seq + epoch 的时间线事件。 -- **PermissionRequest** —— 工具调用授权请求(toolName / action / resources / risk / choices / status)。 -- **QueuedMessage** —— 定时 / 排队消息。 -- **AuditLogEntry** —— 审计日志(action / category / actor / remoteAddr / outcome / metadata)。 -- **DeviceToken / TokenAuth** —— 设备令牌与鉴权结果(scopes / revoked / tokenHash)。 -- **AgentSession / AgentModel / AgentCatalogEntry / AgentHistoryItem** —— Agent 会话、模型、目录条目、历史导入项。 -- **Host\*** —— 主机概览、资源用量、磁盘分区 / IO、网络 IO、性能样本、历史分桶、网卡、进程、端口、文件。 -- **Git\*** —— 文件状态、status、branch、commit、stash。 -- **FileEntry / DirectoryListing / Event** 等。 - -> 这些模型直接定义了 REST/WS 的 JSON 形状,是前后端契约的事实来源。 - ---- - -## 6. WebUI(React 19 + Vite 6 + TypeScript) - -`evcod/webui/src/`: - -- **入口 / 框架**:`main.tsx`、`App.tsx`、`styles.css`。 -- **状态**:`store.ts`(zustand)、`types.ts`、`api.ts`(REST/WS 客户端)、`lib.ts`。 -- **终端**:`TerminalView.tsx` + `@xterm/xterm`(含 fit / serialize addon)。 -- **代码编辑**:`MonacoEditors.tsx`、`FileEditorWorkspace.tsx` + `monaco-editor`。 -- **面板组件**(`components/`): - - `Sidebar.tsx`、`RightPanel.tsx`、`WorkspaceStrip.tsx`、`PaneTabs.tsx` —— 多栏工作台布局; - - `FilesPanel.tsx`、`DirectoryPicker.tsx` —— 文件管理; - - `GitPanel.tsx` —— Git 面板; - - `HostWorkspace.tsx` —— 主机仪表盘; - - `ConversationView.tsx` + `components/chat/` —— 聊天 / 对话; - - `SettingsPage.tsx` —— 设置。 -- **聊天流处理**(`src/chat/`):`chatStreamAdapter.ts`、`streamTypes.ts`、`timelineRenderState.ts`、`toolDetail.ts` —— 把 core 的 timeline/message 事件渲染成聊天 UI(含工具调用详情)。 -- **辅助**:`markdown.tsx`、`messageParts.ts`、`fileLinks.ts`、`feedback.tsx`。 - -依赖关键:`react@19`、`zustand@5`、`@xterm/xterm@5.5`、`monaco-editor@0.55`、`lucide-react`、`vite@6`。 - ---- - -## 7. Warp —— Agent 桥(Node.js) - -把外部 AI Agent 接入 core,并把它们的对话镜像进网页聊天。 - -- **`CoreBridge`(`src/core-bridge.js`)**:REST 处理 conversations/messages,WS 接收事件。 -- **`src/cli.js`**:按 agent 类型路由: - - **原生 agents**(`claude` / `codex` / `gemini` / `qwen` / `opencode`):在 core 的终端 pane 内运行其**真实 TUI**,并把输出镜像到网页聊天(`native-agent.js`、`opencode-tui.js`); - - 只有 **`fake`** 走旧式 readline `SessionController` 方案(测试夹具)。 -- **`src/backends/`**:各 agent 后端(`claude.js`、`codex.js`、`opencode.js`、`fake.js`,由 `index.js` 注册)。 -- **`src/transports/`**:传输层 —— `jsonl.js`(行分隔 JSON)、`jsonrpc.js`,由 `factory.js` 选择。 -- **`normalizer.js`**:输出归一化;**`tool-detail.js`**:工具调用详情。 -- CLI:`node evcod_warp/bin/evcod-warp.js --list` 列出可用后端。 - -> WebUI 展示的 Agent 目录由 core 端 `agent_catalog.go` 探测计算(不是 warp),warp 负责实际拉起并桥接。 - ---- - -## 8. 配置与鉴权 - -### 配置(`internal/config`) - -| 项 | 环境变量 | flag | 默认 | -|----|----------|------|------| -| 绑定地址 | `EVCOD_BIND` | `--bind` | `127.0.0.1:4865` | -| 状态文件 | `EVCOD_DB` | `--db` | runtime 默认路径 | -| WebUI 目录 | `EVCOD_WEBUI_DIR` | `--webui-dir` | (不托管) | - -Agent 相关 env 覆盖:`EVCOD_CLAUDE_BIN` / `EVCOD_CLAUDE_MODEL`(其余 agent 同理)。 - -> 注意:`dev.command` 中 core 跑 `:10065`、webui dev 跑 `:10066`,与上面的默认 `4865` 不同(开发脚本显式覆盖)。 - -### 鉴权 - -- 每个请求都需要 API key。 -- REST / 非浏览器 WS:`Authorization: Bearer evcod_xxx`。 -- 浏览器 WebSocket:`?token=evcod_xxx`。 -- 首次启动自动创建默认 key;`key rotate` 吊销所有 key。 -- 设备令牌支持 scope;审计日志记录访问。 - ---- - -## 9. 文档 - -- **`evcod/docs/api/`** —— API 契约(事实来源):`overview.md`、`rpc-protocol.md`、`auth.md`、`conversation.md`、`files.md`、`terminal.md`、`workspace.md`、`errors.md`、`openapi.json`。 -- **`evcod/docs/project-overview.zh-CN.md`** —— 中文项目状态文档。 -- 仓库根:`Chat 对齐文档.md`、`chat_diff/`(聊天对齐)、`审查报告/`(评审报告)、`其他/`。 -- 多数文档与不少代码注释是**中文**——编辑文档时请匹配周围语言。 - ---- - -## 10. 构建、运行与测试 - -### 一键开发环境(macOS) - -```bash -evcod/dev.command # 构建 core,起 core(:10065) + webui dev(:10066),并打印 API key -``` - -### 完整测试 / 构建流水线(CI 等价物) - -```bash -evcod/test_scripts/run_full_tests.sh # gofmt + go test + 构建 + core API 烟测 + warp 测试 + webui 构建 + 浏览器 E2E -evcod/test_scripts/run_full_tests.sh --no-browser # 跳过 Playwright 浏览器流程 -``` - -### Core(Go,模块路径 `evcod/core`,用 `go -C ` 而非 cd) - -```bash -go -C evcod/core build -o ../../.tmp/evcod-core ./cmd/evcod-core -go -C evcod/core test ./... -go -C evcod/core test ./internal/services -run TestAgentCatalog # 单测 -gofmt -w evcod/core # 提交前格式化(流水线强制) -go -C evcod/core run ./cmd/evcod-core key print # 打印/创建默认 key -go -C evcod/core run ./cmd/evcod-core key rotate -go -C evcod/core run ./cmd/evcod-core serve # 默认 127.0.0.1:4865 -``` - -### WebUI(Node) - -```bash -npm --prefix evcod/webui ci -npm --prefix evcod/webui run dev # vite dev server -npm --prefix evcod/webui run build # tsc -b && vite build -# 浏览器 E2E(Playwright 驱动的独立 node 脚本,需要在跑的 core + webui): -EVCOD_CORE_URL=... EVCOD_WEB_URL=... npm --prefix evcod/webui run test:smoke -# 另有 test:full-flow / test:feature-flow / test:agent-pane / test:agent-all -``` - -### Warp(Node ≥22) - -```bash -npm --prefix evcod_warp test # node --test,全部 test/*.test.js -node --test evcod_warp/test/normalizer.test.js # 单测文件 -node evcod_warp/bin/evcod-warp.js --list # 列出可用 agent 后端 -``` - ---- - -## 11. 关键约定与注意事项 - -1. **gofmt 强制**:提交 Go 前必须格式化(流水线会检查)。 -2. **REST 与 WS RPC 双表面同步**:新增能力两边都要改,并更新 `docs/api/` + `openapi.json`。 -3. **持久化是单 JSON 文件 + 全局锁**:警惕性能与并发,避免高频小写入。 -4. **OS 特定逻辑放 `platform/`**,不要写进 services。 -5. **浏览器 "tests" 不是单测框架**:`webui/tests/`、`test_scripts/*.mjs` 是 Playwright 驱动的独立 node 脚本,需要 live core + webui,以及 `EVCOD_CORE_URL` / `EVCOD_WEB_URL` / `EVCOD_API_KEY` 环境变量。 -6. **Agent 目录由 core 探测**(`agent_catalog.go`),warp 负责拉起与桥接;二者职责不同。 -7. **文档语言**:中文为主,编辑时匹配周围语言。 - ---- - -## 12. 一次完整的数据流(示意) - -以"在网页里和 Claude 对话写代码"为例: - -``` -浏览器(WebUI) - │ REST: 创建项目 / 选目录 / 打开 worktree - │ WS /ws/rpc: conversation.create, conversation.send - ▼ -core (services.Chat / Agent) ──写入──► store(JSON) ──发布──► events hub - ▲ │ - │ WS /ws/relay │ 扇出事件 - │ ▼ -evcod-warp (CoreBridge) 浏览器实时收到 - │ 在 core 的终端 pane 内运行真实 claude TUI message.stream / timeline - │ normalizer 归一化输出 → 镜像回 core → 网页聊天 - ▼ -claude / codex / gemini ...(外部 AI Agent 二进制) -``` - -core 在 `Serve` 时把 `EVCOD_CORE_URL` / `EVCOD_API_KEY` 注入环境,所以同机的 warp 无需额外配置即可发现并连上 core。