chore(v0.6): checkpoint foundation — Amber theme, P1 types, P3 config, design docs

Prior-session prep for the v0.6 Project Manager, checkpointed on the
v0.6-projects branch as a clean base for the parallel builders:

- P1 (src/types.ts): ProjectInfo + ProjectSessionRef contracts; Config
  gains projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck.
- P3 (src/config.ts): parseBool + parseProjectRoots helpers; parse the
  4 PROJECT_* env vars with defaults ([homeDir]/4/10000/true).
- UI: public/style.css indigo -> Amber (#e3a64a) accent theme.
- Docs: FEATURE_PROJECT_MANAGER.md (1:N session design), THEME.md,
  docs/mockups/ (final-amber.png is the locked visual).

Typecheck (backend + web) green. No behavior change to runtime code yet;
P2/P4/P5/P6 implement the feature on top of this.
This commit is contained in:
Yaojia Wang
2026-06-30 10:05:40 +02:00
parent f9964a517d
commit dc5d073374
16 changed files with 1073 additions and 23 deletions

View File

@@ -0,0 +1,242 @@
# Feature: Project Manager 项目工作台 (v0.6)
> **状态**: 提案 / Draft探索完成待评审后进入 PLAN
> **目标**: 首页除了"会话选择器"外,新增一个 **项目面板**展示主机上的所有项目git 仓库),点一下某个项目 → 应用自动 spawn 一个 **Claude Code session**cwd = 该仓库目录、自动运行 `claude`),并把 **tab 标签标注为 repo 名**。
> **关键结论**: 终端机制几乎**零改动**——按目录 spawn (`attach.cwd`)、自动输入命令 (`initialInput`)、自定义标签 (`customTitle`) 全部已存在。新代码只有两块:**git 仓库发现 + `GET /projects` 端点** 和 **Projects 面板 UI**。
---
## 1. 用户故事 (User Stories)
- **US1**(核心):作为开发者,我打开 web-terminal 首页,看到我所有项目的卡片墙;点 `web-terminal` 卡片,立刻开出一个新 tab标签写着 `web-terminal`,里面已经 `cd` 到该目录并启动了 `claude`,我直接开始派活。
- **US2**:每个项目卡片显示它的**当前分支**、是否有未提交改动 (dirty)、以及**是否已有正在运行的 session**(避免重复开)。
- **US3**:如果某项目已经有一个跑着的 session点卡片**直接进入那个 session**(而不是再开一个)。
- **US4**:我能**搜索/过滤**项目(项目多时),把常用项目**置顶/收藏**。
- **US5**:我能在卡片上选择"**新开** Claude session"还是"**只开 shell**(不自动跑 claude",或"**resume** 该项目最近一次 Claude 会话"。
---
## 2. 竞品参考 (Prior Art)
调研了主流的"项目选择器 → 启动 agent 会话"产品,提炼可借鉴点:
| 产品 | 项目发现方式 | 卡片展示的元数据 | 启动交互 | 可借鉴 UX |
|---|---|---|---|---|
| **CloudCLI / claudecodeui** (siteboon) | 自动扫描 `~/.claude/projects/` 配置目录 | 项目名、路径、历史 session 列表 | 点项目 → 列出其 sessions → 选一个 resume / 新建 | 侧边栏树形项目→会话二级结构;与本地 Claude 配置同步 |
| **官方 Claude Code** | `~/.claude/projects/` 按 cwd 路径 keyed | cwd、历史会话 | `claude` 在 cwd 启动;`claude --resume <id>` | "项目 = 工作目录"这一极简心智模型 |
| **Conductor** (Melty Labs, Mac) | 每个 workspace = 一个 **git worktree** | 看板:每个 agent 在干嘛 / 卡在哪 | 把 ticket 拉进 workspace 当任务描述 | 看板式 dashboard + "拉一个 ticket 进来就开干" |
| **Crystal / Nimbalyst** (stravu) | 多个并行 session 跑在 git worktree | 并行会话、方案对比 | 一个项目开多个并行 session 比较产出 | 同一项目并行多 session、对比方案 |
| **AgentsRoom** | 手机端远程 | 终端输出、待回答提示 | 手机监控 + 回答 prompt | 移动端"监控 + 审批"闭环(本项目 v0.3 已有类似) |
| **tmuxinator / sesh** | 扫描配置/目录列出 project | 项目名 | 一键 attach "每项目一会话" | "每项目一会话"的命名与复用 |
**结论 / 我们要偷的点:**
1. **项目 = 工作目录**(官方心智模型)——发现逻辑围绕"目录是不是 git 仓库"。
2. **二级结构**CloudCLI项目 → 该项目下的历史/活动 session点项目可"新建"或"进入已有"。
3. **看板式状态**Conductor卡片直接显示该项目有没有 running session、在 working/waiting/idle 哪个状态(本项目 v0.3 hook 状态可复用)。
4. **git 元数据**(分支 + dirty让卡片"活"起来。
5. 本项目的**差异化护城河**自托管、不经云、多设备镜像同看、缩略图会话墙——Project 面板应与这些无缝融合(项目卡片可直接复用实时缩略图)。
---
## 3. 功能需求 (Functional Requirements)
| ID | 需求 | 优先级 |
|----|------|--------|
| FR1 | 提供 `GET /projects`,返回主机上发现的项目列表(名称、绝对路径、是否 git、分支、dirty、最近活跃时间 | P0 |
| FR2 | 项目发现:扫描 1~N 个**配置的根目录**(默认 `~`,可 env 覆盖)下的 git 仓库;并合并 `~/.claude/projects` 里出现过的 cwd最近用过的项目 | P0 |
| FR3 | 首页提供 **Projects 视图**(与现有 Sessions 选择器并列/可切换),渲染项目卡片网格 | P0 |
| FR4 | 点项目卡片**主操作** → 新开 tabcwd=仓库路径、自动运行 `claude`、tab 标签=repo 名 | P0 |
| FR5 | **一个项目可对应多个 session**1:N。卡片展示该项目下**所有运行中的 session**(每个含名称/Claude 状态/缩略图),可分别进入;同时永远提供" 新开"。详见 §4.6 | P0 |
| FR6 | 卡片显示 **当前分支** + **dirty 标记** | P1 |
| FR7 | 项目搜索/过滤框;收藏/置顶localStorage 持久化) | P1 |
| FR8 | 「+ 新开」菜单:新开 `claude` / 只开 shell / `claude --resume <最近会话>` | P2 |
| FR9 | 性能:发现逻辑有**深度上限**、跳过 `node_modules`/`.git` 内部、结果缓存,避免扫盘卡顿 | P0 |
---
## 4. 设计与架构 (Design)
### 4.1 数据流
```
首页 Projects 视图
│ GET /projects
src/http/projects.ts ──扫描配置根目录找 .git──► ProjectInfo[]
│ ──合并 ~/.claude/projects 的 cwd──►
│ ──对每个 repo 读 branch / dirty──►
卡片网格 (public/projects.ts)
│ 点卡片
TabApp.openProject(repoPath, repoName)
│ = addEntry(null, repoName, repoPath, 'claude') ← 全部已存在的能力
TerminalSession: attach{cwd: repoPath} → spawn PTY(cwd) → 700ms 后自动输入 "claude\r"
tab 标签 = customTitle(repoName)最高优先级shell 之后发的 OSC 标题不会覆盖)
```
**核心洞察**:从"点项目"到"开出带名字、跑着 claude 的 session"这条链路,**终端侧零改动**——直接调 `TabApp.addEntry(null, repoName, repoPath, 'claude')` 即可(见集成点表)。
### 4.2 既有集成点(无需改动,直接复用)
| 能力 | 现状 | 钩子位置 |
|------|------|----------|
| 按目录 spawn PTY | ✅ 已有 (M6) | `attach.cwd``manager.handleAttach(…, cwd)``createSession(…, cwd)``spawn({cwd})` (`src/session/session.ts:93`) |
| 自动运行 `claude` | ✅ 已有 (O2, 客户端驱动) | `TerminalSessionOpts.initialInput``attached` 后 700ms 自动输入 (`public/terminal-session.ts:253-261`) |
| 标签=repo 名 | ✅ 已有 | `addEntry(null, repoName, repoPath, 'claude')``customTitle` 优先级最高 (`public/tabs.ts:107,143`) |
| 启动器视图骨架 | ✅ 可复用 | `mountLauncher` / `.mg-grid` / `makePreviewCard` (`public/launcher.ts`, `public/preview-grid.ts`) |
| 历史 cwd 扫描模板 | ✅ 可复用 | `listSessions()``~/.claude/projects/*.jsonl`,提取 cwd (`src/http/history.ts:80`) |
### 4.3 新增后端:`src/http/projects.ts`(模仿 `history.ts`
```ts
// 在 src/types.ts 增加共享契约(协调点,不要本地重声明)
export interface ProjectInfo {
name: string; // repo 目录名
path: string; // 绝对路径
isGit: boolean;
branch?: string; // 当前分支git 仓库才有)
dirty?: boolean; // 有无未提交改动
lastActiveMs?: number; // 来自 ~/.claude/projects 的最近 mtime用于排序
sessions: ProjectSessionRef[]; // 该项目下当前运行中的 session1:N可能为空
}
// 一个项目下的某个运行中 session 的引用(由 /projects 把 live-sessions 按 cwd 归并而来)
export interface ProjectSessionRef {
id: string; // sessionId
title?: string; // tab 标题 / 自动名(如 'claude', 'shell', 'test'
claudeStatus?: 'working' | 'waiting' | 'idle'; // 复用 v0.3 hook 状态
startedMs: number;
clients: number; // 当前挂着几个 WS 客户端(多设备镜像)
}
```
```ts
// src/http/projects.ts 纯异步、best-effort、parse 逻辑可单测)
export async function listProjects(cfg): Promise<ProjectInfo[]>
// 1. roots = cfg.projectRoots默认 [os.homedir()]
// 2. 广度优先扫描,遇到 .git 即记为 repo 并停止下钻该子树
// - 深度上限 cfg.projectScanDepth默认 4
// - 跳过 node_modules / .git / 隐藏目录 / 符号链接环
// 3. 合并 history.ts 收集到的 cwd最近用过但不在扫描根下的项目
// 4. 对每个 repo读分支 = 解析 .git/HEAD轻量免 spawn git
// dirty = `git status --porcelain` 截断输出(可选、可惰性/并发限流)
// 5. 去重(by path)、按 lastActiveMs 倒序、限量返回
```
> **分支读取**:优先 `读取 .git/HEAD``ref: refs/heads/<branch>`)避免为每个 repo spawn git扫描快。dirty 状态较贵(要 `git status`),可**惰性**(卡片展开/hover 时再查)或**并发限流**(一次最多 N 个)以免大量 repo 卡顿。
### 4.4 新增端点:`GET /projects`
只读发现端点,**与 `/sessions``/live-sessions` 同类**——不需要 Origin/CSRF 守卫(那是给状态变更路由的)。放在 `src/server.ts:151` 附近:
```ts
app.get('/projects', async (_req, res) => res.json(await listProjects(cfg)));
```
### 4.5 前端:`public/projects.ts` + `TabApp.openProject`
- `mountProjects(host, { onOpenProject, onEnterSession })`,与 `mountLauncher` 并列挂载(`tabs.ts:59` 旁)。
- 首页加 **Sessions ↔ Projects 切换**(两个 tab/段控),复用 `.mg-grid``el()` 卡片工具。
- 项目卡片repo 名 + 分支 chip + dirty 圆点 + **session 计数徽标 + 该项目所有 running session 列表**(见 §4.6)。
- `openProject`(新开)与 `openSession`(进入既有,已存在于 `tabs.ts:129`
```ts
// public/tabs.ts 新增(与 newTabForResume 几乎同构)
openProject(repoPath: string, repoName: string, cmd = 'claude') {
// 同名多开时加序号后缀,避免标签全叫 'web-terminal'
const n = this.countOpenWithTitlePrefix(repoName);
const label = n === 0 ? repoName : `${repoName} #${n + 1}`;
this.addEntry(null, label, repoPath, cmd); // customTitle, cwd, initialInput
this.rebuild(); // + activate 新 tab
}
```
---
### 4.6 一个项目 ↔ 多个 session1:N核心修正
一个项目常会同时存在多个 session**一个 `claude` + 一个跑测试/`git` 的 shell**、或**并行开几个 claude 比方案**Conductor/Crystal 模式)、或同一会话被**多设备镜像**。因此卡片不能假设"0 或 1 个会话"。
**归并逻辑(后端 `listProjects`**:把 `manager.list()``GET /live-sessions`,每个含 `cwd``clients`,见 `session.ts:116`)按 **cwd 前缀归属到项目**,填进 `ProjectInfo.sessions[]`Claude 状态来自 v0.3 hook 侧信道(按 sessionId
> **匹配口径**:以 session 的**spawn cwd** 为锚(稳定,不随用户 `cd` 漂移)。`cwd === project.path` 或 `cwd` 在 `project.path` 之下都算该项目。**git worktree**(同 repo、不同路径v0.6 先按各自路径当**独立项目**呈现;"按 repo 把多个 worktree 收拢成一组"列入未来扩展§9
**卡片交互(按 session 数自适应)**
| 该项目 running session 数 | 卡片主体 | 主操作 |
|---|---|---|
| **0** | " start claude here" 占位 | **Open ▸ claude**`openProject` |
| **1** | 1 个 session 行(状态点 + 名称 + 缩略图) | 点行 = 进入;底部 ** New** |
| **N** | N 个 session 行(各带 ⚙/⏳/✓ 状态、镜像端数、可单独进入/Kill | 每行点击进入对应 session底部 ** New claude ▾** |
- 每个 session 行:`● 状态点` + `标题`claude / shell / `#2`…)+ `👥×2`(镜像设备数)+ 点击 → `onEnterSession(id)` = `TabApp.openSession(id)`(已有,会聚焦既有 tab 或挂载该 session不重复开
- 行尾 `✕` = Kill 该 session复用 `DELETE /live-sessions/:id`,带 Origin 守卫)。
- 「+ New」永远在因为"已有会话"不代表"不想再开一个"。`▾` 展开 = 新 claude / 只开 shell / `claude --resume`
- **同名多 tab**`openProject` 给第 2+ 个加 `#n` 后缀(如 `web-terminal #2`),避免标签无法区分。
---
## 5. 安全考量 (Security)
- `GET /projects` 只读,符合现有威胁模型(工具已对 LAN 交出整个 shell暴露目录名不额外升级风险——与 `/sessions` 一致,**不加** Origin 守卫,但**也不能写**。
- **路径越界**:发现的路径仅在服务端本地枚举;前端传回的 `cwd` 走既有 `attach.cwd` 校验(必须绝对路径,`protocol.ts:142`),不引入新攻击面。
- **扫描成本即 DoS 面**:深度上限 + 跳过大目录 + 结果缓存(如 10s TTL避免被频繁刷 `/projects` 拖垮磁盘。
- dirty 检查用 `git status` 会 spawn 子进程——**限流 + 超时**,防止 repo 多时 fork 爆炸。
---
## 6. 配置 (新增 env沿用"无硬编码"原则)
| Env | 默认 | 说明 |
|-----|------|------|
| `PROJECT_ROOTS` | `~`home 目录) | 逗号分隔的扫描根目录 |
| `PROJECT_SCAN_DEPTH` | `4` | 扫描最大深度 |
| `PROJECT_SCAN_TTL` | `10000` (ms) | `/projects` 结果缓存时长 |
| `PROJECT_DIRTY_CHECK` | `1` | 是否计算 dirty0=关,省 git 调用) |
---
## 7. 任务拆解 (v0.6 — 按 PLAN/Owns 模型)
> 沿用 v0.4/v0.5 的做法:作为 feature 条目并入,定义 disjoint `Owns:` 便于并行派给 `module-builder`。
| 任务 | Owns独占写 | 依赖 | 说明 |
|------|----------------|------|------|
| **P1 类型契约** | `src/types.ts`(加 `ProjectInfo`,改 `Config` 加 project* 字段) | — | 协调点,先落地,解锁并行 |
| **P2 后端发现** | `src/http/projects.ts` + `test/projects.test.ts` | P1 | git 扫描 + HEAD/dirty + 合并 history cwd + 缓存TDDparse 纯函数单测) |
| **P3 配置** | `src/config.ts`(加 `projectRoots/scanDepth/...` | P1 | env 解析 + 默认值 |
| **P4 端点接线** | `src/server.ts`(加 `GET /projects` 一行 + import | P2,P3 | 与 `/sessions` 同位 |
| **P5 前端面板** | `public/projects.ts` + 样式 | P1 | 卡片网格、搜索、收藏、切换视图 |
| **P6 Tab 接线** | `public/tabs.ts``openProject` + 挂载 `mountProjects` + running-session 匹配) | P5 | 复用 `addEntry`/`openSession` |
> P2/P3/P5 同波次、文件 disjoint可并行P4/P6 收尾接线。建议 builder 用 `isolation: worktree`。
---
## 8. 验收标准 (Acceptance)
- **A1**`GET /projects` 在含多个 git 仓库的目录树下返回正确列表(名称/路径/分支/dirty扫描有深度上限、跳过 `node_modules`10s 内缓存命中。
- **A2**:首页能切到 Projects 视图,渲染卡片网格;分支与 dirty 显示正确。
- **A3**:点一个**无运行会话**的项目 → 新 tab 标签=repo 名、cwd 正确、约 0.7s 后自动出现 `claude` 启动界面。
- **A4**:点一个**已有运行会话**的项目 → 进入既有 session不重复开。
- **A5**:搜索过滤、收藏置顶生效并持久化。
- **A6**:后端测试覆盖发现/解析逻辑≥80%,纯函数单测 + 临时目录集成)。
---
## 9. 未来扩展 / 暂不做 (Out of Scope for v0.6)
- **git worktree 看板**Conductor/Crystal 式:一个项目并行多 worktree 多 agent——强大但属另一个大特性v0.6 先做"一项目一会话"。
- 项目内**文件树/编辑器/Git Explorer**CloudCLI 有)——与本项目 byte-shuttle 哲学冲突,明确不做。
- 从 ticket/issue **拉任务进项目**Conductor 式)——后续可作为 v0.7 与 hook 系统结合。
- 多 agent 中立Cursor/Codex/Gemini——目前 hooks 与 `claude` 命令是 Claude 专用;如需中立化,`initialInput` 可改为可配置命令。
---
## 附:一句话总结
> 这是一个**"发现源 + 启动器视图"**特性。开 session 的终端机制(按目录 spawn、自动跑 claude、标签=repo 名)**今天就能做到**,只差**仓库发现(`GET /projects`**和**项目面板 UI**两块新代码。