diff --git a/AGENTS.md b/AGENTS.md index 2117e90..141a88b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,12 +2,12 @@ 修改 CodeDock 代码前,先阅读 [`docs/architecture.md`](docs/architecture.md)。该文档是当前目录归属和模块边界的依据。 -Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。Codex 对话复用 Agent 页,新建会话时选择模式;HTTP 走独立 `/codex`,前端用 `CodexClient`,不扩 `AgentClient`。 +Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字或 Tool、事件落库并由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`。Git 用户操作走 HTTP + `pkg/git`,不经过 Agent Tool。仓库根是当前会话冻结的工作目录(请求带 `session_id`);未带会话才回落 `GIT_REPO` / cwd。前端 Git 在 `packages/core/git`、`packages/views/git` 与 `apps/web` 的 `/git`,不扩 `AgentClient`。Codex 与 Claude Code 对话复用 Agent 页,新建会话时选择模式;HTTP 分别走独立 `/codex`、`/claude`,前端用 `CodexClient` / `ClaudeClient`,不扩 `AgentClient`。会话 / 实录 / 配置从本机 CLI 读,不落库。 ## 目录放置规则 - 服务启动、配置读取、Router 和依赖装配放在 `server/cmd/server`。 -- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。 +- 大部分 HTTP 逻辑放在 `server/internal/handler`:Session / Message / Usage / Approval 的 CRUD,SSE,Run 的 Start / Continue / Cancel,审批裁决,用户侧记忆查看/删除,以及 Git(直接调 `pkg/git`)。创建 Session 时在本包冻结 `workspace_id`。不 import `internal/agent/tools`。新对话选目录由 web 弹出系统目录选择框,不走 Agent Tool。Codex 的独立 `/codex` HTTP 放在 `server/internal/handler/codex`。Claude Code HTTP 仍在 `server/internal/handler`,直接调 `pkg/claude`。 - Agent 运行时编排和 sqlc 持久化放在 `server/internal/agent`。 - 本机 Codex app-server 生命周期、内存排队/问票/SSE 放在 `server/internal/codex`。不新增 Codex 业务表;凡官方 API 能读到的都不入库。 - Markdown 记忆(热层目录+专题)与 context message 索引(冷层按工作区 FTS)放在 `server/internal/agent/memory`;不放 `pkg/memory`。memory 不 import 父包 `internal/agent`,不定义 Tool。 @@ -16,14 +16,15 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - 插件 SDK 与 proto 放在 `server/pkg/plugin`;宿主放在 `server/internal/pluginhost`。两者都不进 `pkg/agent`,也不知道主循环内部状态机。可装载的插件放在仓根 `plugin/`(子目录名即插件名)。`plugin/example` 是作者拷贝模板:不订阅、不改正文、不换向、不登记方法。不进 `pkg/plugin`。插件共享参数用 `PluginContext`,不进模型、不复用 Hidden。 - Git CLI 操作放在 `server/pkg/git`:无状态,不写产品流程;Handler 直接调用。不进 `pkg/agent`。 - Codex 协议与领域类型放在 `server/pkg/codex`:看板的子模块,JSONL 客户端给 `internal/codex` 调用;不查库、不 spawn CLI。不进 `pkg/agent`。 +- Claude Code 对接放在 `server/pkg/claude`:无状态,不落库;会话 / 实录 / 模型 / 权限档从本机 Claude 读。Handler 直接调用。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。 - 进程内事件总线放在 `server/internal/events`。 - 数据库入口和 sqlc 生成代码放在 `server/pkg/db`。 - 数据库结构演进放在 `server/migrations`。 - 运行时产生的文件(SQLite 等)放在仓根 `data/`,不要写进 `server/`。该目录 gitignore。 -- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,不扩 `AgentClient`。 +- 无头业务放在 `packages/core`(`@codedock/core`):按业务域拆(现有 `chat/`、`git/`、`codex/`、`claude/`),文件直接在域目录下,不要 `src/`。不依赖 React、Next、DOM、`process.env`。`baseUrl` / `userId` 由调用方注入。Git 用独立 `GitClient`。Codex 用独立 `CodexClient`,Claude 用独立 `ClaudeClient`,不扩 `AgentClient`。 - 无业务 UI 放在 `packages/ui`(`@codedock/ui`):`components/`、`lib/`、`styles/`,不要 `src/`,不按业务域拆。不依赖 core,不知道 Session / Run / TimelineItem。 -- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,由 `ChatPage` 在 Codex 模式下组合,不单独做 Codex 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 -- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 不单独路由,走 `/` 与 `/s/c/:id`。开发态切页顶栏只放 web。不解析 SSE。 +- 组合层放在 `packages/views`(`@codedock/views`):按业务域拆,与 core 对齐(现有 `chat/`、`git/`、`codex/`、`claude/`)。包根 `provider.tsx` 注入 Agent client;Git 用 `views/git` 的 `GitProvider`;Codex 用 `views/codex` 的 `CodexProvider`,Claude 用 `views/claude` 的 `ClaudeProvider`,由 `ChatPage` 按模式组合,不单独做 Codex / Claude 页。不 import `next/*`;导航用回调。不要 `src/`,不预建空业务域。 +- Web 路由和平台装配放在 `apps/web`:读 `NEXT_PUBLIC_*`、创建 `AgentClient` / `GitClient` / `CodexClient` / `ClaudeClient`、包对应 Provider、`router.push`。`/git` 放在 `(chat)` 组外。Codex 走 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。开发态切页顶栏只放 web。不解析 SSE。 - 依赖方向:`apps/web` → `packages/views` → `packages/core`;`packages/views` → `packages/ui`。`ui` 不依赖 `core`。未来 CLI 只依赖 `core`。 - 不要创建 `server/pkg/ai`。大模型调用属于 `pkg/agent`。 @@ -39,9 +40,9 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字 - Agent 契约不得依赖 React、UI 包或路由框架。 - 流式事件是通知,不是真实数据源;重连时应按 `event_seq` 回放。 - 不要预先创建 Issue、Task、Review、Workspace 或其他具体业务域目录。 -- 不要把产品工作流放入 `server/pkg`。 +- 不要把产品工作流放入 `server/pkg`。Git、Codex 与 Claude Code 是本机 CLI 域:不落库,Handler 直接调 `pkg/git` / `pkg/codex` / `pkg/claude`。 - 前端三层不得反依赖:`core` 不依赖 React / Next / DOM / `process.env`;`ui` 不依赖 `core`;`views` 不 import `next/*`;`apps/web` 只做路由与平台装配。 -- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 +- 前端按业务域拆模块,不要 `src/`:`core` / `views` 用同名域目录(现有 `chat` / `git` / `codex` / `claude`);`ui` 只用 `components` / `lib` / `styles`。新业务再建目录,不预建空文件夹。 ## 注释规则 diff --git a/apps/web/app/(chat)/s/claude/[sessionId]/page.tsx b/apps/web/app/(chat)/s/claude/[sessionId]/page.tsx new file mode 100644 index 0000000..0b5c5c5 --- /dev/null +++ b/apps/web/app/(chat)/s/claude/[sessionId]/page.tsx @@ -0,0 +1,4 @@ +// ClaudeSessionPage 只占 /s/claude/:id,内容由 ChatHost 按路径装配。 +export default function ClaudeSessionPage() { + return null; +} diff --git a/apps/web/app/chat-host.tsx b/apps/web/app/chat-host.tsx index 66112b8..d882388 100644 --- a/apps/web/app/chat-host.tsx +++ b/apps/web/app/chat-host.tsx @@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation"; import { rememberSession } from "@/lib/session"; +// ChatHost 解析对话路径并把导航收成回调;views 不知道具体 URL。 export function ChatHost() { const pathname = usePathname(); const router = useRouter(); @@ -19,8 +20,9 @@ export function ChatHost() { engine={parsed.engine} brandSrc="/brand/codedock-berth-mark.svg" codexIconSrc="/brand/codex-app-icon.png" + claudeIconSrc="/brand/claude-app-icon.svg" onOpenSession={(id, engine = parsed.engine ?? "agent") => { - const path = engine === "codex" ? `/s/c/${id}` : `/s/${id}`; + const path = pathFor(id, engine); if (pathname !== path) { router.push(path); } @@ -34,14 +36,30 @@ export function ChatHost() { ); } +// parseChatPath 先认 /s/claude/:id,再认 /s/c/:id,避免吃掉 Claude 前缀。 function parseChatPath(pathname: string): { sessionId?: string; engine?: SessionEngine } { + const claude = pathname.match(/^\/s\/claude\/([^/]+)/); + if (claude?.[1]) { + return { sessionId: decodeURIComponent(claude[1]), engine: "claude" }; + } const codex = pathname.match(/^\/s\/c\/([^/]+)/); if (codex?.[1]) { return { sessionId: decodeURIComponent(codex[1]), engine: "codex" }; } const agent = pathname.match(/^\/s\/([^/]+)/); - if (agent?.[1] && agent[1] !== "c") { + if (agent?.[1] && agent[1] !== "c" && agent[1] !== "claude") { return { sessionId: decodeURIComponent(agent[1]), engine: "agent" }; } return {}; } + +// pathFor 由 web 决定三套会话路径。 +function pathFor(id: string, engine: SessionEngine): string { + if (engine === "claude") { + return `/s/claude/${id}`; + } + if (engine === "codex") { + return `/s/c/${id}`; + } + return `/s/${id}`; +} diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 3f0d9e2..46b05d3 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,7 +1,9 @@ "use client"; import { AgentClient } from "@codedock/core/chat"; +import { ClaudeClient } from "@codedock/core/claude"; import { CodexClient } from "@codedock/core/codex"; +import { ClaudeProvider } from "@codedock/views/claude"; import { CodexProvider } from "@codedock/views/codex"; import { AgentProvider } from "@codedock/views"; import { useMemo, type ReactNode } from "react"; @@ -10,9 +12,11 @@ import { apiBase, defaultUserId } from "@/lib/env"; import { pickDirectory } from "@/lib/pick-directory"; import { pickFiles } from "@/lib/pick-files"; +// Providers 装配本机 Web 的 Agent / Codex / Claude 客户端,views 不读环境变量。 export function Providers({ children }: { children: ReactNode }) { const client = useMemo(() => new AgentClient({ baseUrl: apiBase }), []); const codex = useMemo(() => new CodexClient({ baseUrl: apiBase }), []); + const claude = useMemo(() => new ClaudeClient({ baseUrl: apiBase, userId: defaultUserId }), []); return ( - {children} + + {children} + ); } diff --git a/apps/web/public/brand/claude-app-icon.svg b/apps/web/public/brand/claude-app-icon.svg new file mode 100644 index 0000000..8d527c7 --- /dev/null +++ b/apps/web/public/brand/claude-app-icon.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/docs/architecture.md b/docs/architecture.md index af8b373..29499d7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runt ## 总体架构 ```text -apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient) +apps/web (路由 + NEXT_PUBLIC_* + AgentClient / GitClient / CodexClient / ClaudeClient) | v packages/views (Chat 壳 / hooks,无 next/*) @@ -27,6 +27,7 @@ server/internal/handler |-- 领取 Run 后的 Loop --> internal/agent |-- 用户记忆查看 / 删除 --> pkg/db/sqlite |-- Git HTTP --> pkg/git(本机 CLI,无产品流程) + |-- Claude HTTP --> pkg/claude(本机 Claude Code,不落库) | v server/internal/agent @@ -40,6 +41,7 @@ server/internal/agent server/pkg/agent server/pkg/git server/pkg/codex +server/pkg/claude ``` `pkg/ai` 已删除。大模型调用放在 `pkg/agent`,由 `ModelConfig` 在方法内创建,不由 Runtime 注入。 @@ -51,9 +53,9 @@ CodeDock/ ├── apps/ │ └── web/ # Next.js 路由与平台装配;不解析 SSE ├── packages/ -│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/),不要 src/ +│ ├── core/ # 无头业务;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ │ ├── ui/ # 无业务语义;components / lib / styles,不要 src/ -│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/),不要 src/ +│ └── views/ # 组合层;按业务域拆(现有 chat/ git/ codex/ claude/),不要 src/ ├── docs/ ├── plugin/example/ # 插件拷贝模板;不改正文、不换向 ├── plugin/redact/ # 脱敏插件;PLUGIN_DIR 指到 plugin/ @@ -61,7 +63,7 @@ CodeDock/ ├── server/ │ ├── cmd/server/ # 服务启动、配置、Router 和依赖装配 │ ├── internal/ -│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git +│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git、Claude Code │ │ │ └── codex/ # 独立 /codex HTTP 薄桥接 │ │ ├── agent/ # 运行时编排 + sqlc 持久化 │ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引 @@ -78,6 +80,7 @@ CodeDock/ │ │ │ └── seam/ # Envelope / Dispatcher / 六个口的类型常量 │ │ ├── plugin/ # 插件 SDK 与 proto;作者只 import 这个包 │ │ ├── git/ # 无状态 Git CLI 操作,供 Handler 直接调用 +│ │ ├── claude/ # 无状态 Claude Code 对接;从本机 Claude 读,不落库 │ │ ├── codex/ # 看板的 Codex 子模块:协议客户端与领域类型 │ │ └── db/ # Client 与 sqlc 生成代码 │ ├── migrations/ @@ -102,6 +105,7 @@ internal/handler -> pkg/db/sqlite.Queries -> pkg/agent # 映射响应、token 统计、Profile 装配 -> pkg/git # 本机 Git CLI 操作 + -> pkg/claude # 本机 Claude Code 对接 -> internal/agent # Worker 领取后的 Loop -> internal/agent/memory # 用户侧记忆响应类型 @@ -158,6 +162,10 @@ pkg/git 不依赖 handler、internal、sqlc 无状态,只 exec 本机 git;不写产品流程 +pkg/claude + 不依赖 handler、internal、sqlc + 无状态,从本机 Claude 读会话 / 实录 / 配置,不落库;不写产品流程 + pkg/codex 看板的 Codex 子模块:领域类型与 app-server JSONL 协议客户端 不依赖 handler、internal、sqlc @@ -166,7 +174,7 @@ pkg/codex packages/core 不依赖 React、Next、DOM、process.env、AI SDK - 按业务域拆目录(chat / git / codex),不要 src/ + 按业务域拆目录(chat / git / codex / claude),不要 src/ 文件直接落在 packages/core// baseUrl / userId 由调用方注入 @@ -179,13 +187,13 @@ packages/views -> packages/core -> packages/ui 不 import next/* - 按业务域拆目录,与 core 对齐(现有 chat / git / codex) - AgentProvider 在包根注入 client + userId;CodexProvider 注入 CodexClient - ChatPage 在新建会话时选择 Agent / Codex 模式;导航用回调 + 按业务域拆目录,与 core 对齐(现有 chat / git / codex / claude) + AgentProvider 在包根注入 client + userId;CodexProvider / ClaudeProvider 注入各自 Client + ChatPage 在新建会话时选择 Local / Codex / Claude;导航用回调 apps/web -> packages/views - -> packages/core # 创建 AgentClient / GitClient / CodexClient + -> packages/core # 创建 AgentClient / GitClient / CodexClient / ClaudeClient -> packages/ui # 引入 tokens.css 不直接解析 SSE 或 event type ``` @@ -203,8 +211,9 @@ apps/web - Run 的 Start / Continue / Retry / Cancel 和审批裁决直接在 Handler 中处理,需要执行时再交给 Worker - 同一 Session 只有一个 active Run:已有 active 时 409。要打断当前轮,先 Cancel 再 Start - Git HTTP(`/git/*`):校验 checkout、组响应,直接调用 `pkg/git`。带 `session_id` 时仓库根是该会话冻结的 `workspace_id`;未带则 `GIT_REPO`,再否则 cwd。`GET /git/status` 回 `SiteState` 整局(含 `is_repo`、跟踪、ahead/behind、integrating) +- Claude Code HTTP(`/claude/*`):直接调用 `pkg/claude`。会话 / 实录 / 配置从本机 Claude 读,不查库、不落库 -Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `session_id` 时只查该 Session 的 `workspace_id`,不经过 Store。 +Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `session_id` 时只查该 Session 的 `workspace_id`,不经过 Store。Git 与 Claude Code 不写产品表。 ### `internal/agent` @@ -253,6 +262,10 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi 无状态 Git CLI:`Open` / `Status`(`SiteState` 整局)/ Diff / 图 / 暂存提交 / reset / revert / 推拉 / remote / 分支 / worktree / `stash create` 副本 / 冲突读写。不进 `pkg/agent`,不写 HTTP 或产品流程。Workspace / Branch / Undo / 说明 / Agent 快照的产品组合在 Handler。 +### `pkg/claude` + +无状态 Claude Code 对接:引擎探测、会话、配置、斜杠命令、附件、回合、实录、审批,以及对本机 Claude 说话。会话 / 实录 / 模型 / 权限档从本机 Claude 读,不落库。不进 `pkg/agent`,不走本地对话的工具 / 记忆 / 压缩。Handler 直接调用。 + ### `pkg/agent` 全部 Agent 通用逻辑,方法无状态: @@ -263,7 +276,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi - Tool 抽象、内存 `Registry`、无状态 `Dispatch`(不含具体工具定义) - Agent 配置抽象 `profile.Config` 与 `RunConfigSnapshot` - 模型调用 `Stream` / 压缩:在函数内按 `ModelConfig.Provider` 创建 - - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起),测试不打外网 + - `fake`:读 `Model.Options` 脚本(多段 text / tool_calls、失败次数、可取消挂起),测试用 - `openai`:OpenAI 兼容 HTTP(`BaseURL` + API Key) ### `pkg/codex` @@ -288,7 +301,7 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/core` -跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 +跨端无头业务,无 UI。按业务域拆目录,文件直接放在 `packages/core//`,不要 `src/`。现有 `chat/`:Session / Message / Run / 审批的 HTTP、SSE、Timeline reducer。Git 前端在 `git/`(`GitClient`,不扩 `AgentClient`)。Codex 前端在 `codex/`(`CodexClient`,不扩 `AgentClient`)。Claude 前端在 `claude/`(`ClaudeClient`,不扩 `AgentClient`)。`baseUrl` / `userId` 由调用方注入。不依赖 React。第一版 thinking 用 Run 状态(`queued` / `loading_context` / `running_llm`),不是模型 reasoning token。 ### `packages/ui` @@ -302,11 +315,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi ### `packages/views` -组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt;新建会话可选 Agent 或 Codex 模式。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`,由 `ChatPage` 组合,不单独做 Codex 页。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 +组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt;新建会话可选 Local / Codex / Claude。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。Codex 在 `codex/`:`CodexProvider` 只注入 `CodexClient`。Claude 在 `claude/`:`ClaudeProvider` 只注入 `ClaudeClient`。二者都由 `ChatPage` 组合,不单独做页。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。 ### `apps/web` -路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `CodexClient`、包 `AgentProvider` / `CodexProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。Codex 不单独路由,走对话页的 `/` 与 `/s/c/:id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 +路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient` / `CodexClient` / `ClaudeClient`、包 `AgentProvider` / `CodexProvider` / `ClaudeProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。Codex 走对话页的 `/` 与 `/s/c/:id`,Claude 走 `/` 与 `/s/claude/:id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。 ## 组装关系 diff --git a/packages/core/claude/client.test.ts b/packages/core/claude/client.test.ts new file mode 100644 index 0000000..ea1af80 --- /dev/null +++ b/packages/core/claude/client.test.ts @@ -0,0 +1,211 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { ClaudeClient, ClaudeClientError } from "./client.ts"; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("ClaudeClient covers every /claude HTTP route", async () => { + const calls: { url: string; method: string; body?: unknown }[] = []; + const client = new ClaudeClient({ + baseUrl: "http://api.test/", + userId: "local", + fetch: async (input, init) => { + const url = String(input); + const method = init?.method ?? "GET"; + const raw = typeof init?.body === "string" ? init.body : undefined; + calls.push({ url, method, body: raw ? JSON.parse(raw) : undefined }); + if (url.endsWith("/claude/status")) { + return json({ available: true, authorized: true, version: "2.1.0", hint: "" }); + } + if (url.endsWith("/claude/models")) { + return json({ + models: [ + { id: "sonnet", efforts: ["low"], default_effort: "medium", hidden: false, is_default: true }, + ], + }); + } + if (url.endsWith("/claude/modes")) { + return json({ modes: [{ id: "default", kind: "permission" }] }); + } + if (url.endsWith("/claude/commands")) { + return json({ commands: [{ name: "mcp", action: "hint", hint: "到终端改 Claude 配置" }] }); + } + if (url.endsWith("/claude/sessions") && method === "GET") { + return json({ + sessions: [ + { id: "s1", claude_session_id: "", title: "t", active_turn_id: "", archived: false }, + ], + }); + } + if (url.endsWith("/claude/sessions") && method === "POST") { + return json({ + session: { id: "s2", claude_session_id: "", title: "", active_turn_id: "", archived: false }, + }); + } + if (url.endsWith("/claude/sessions/s1") && method === "GET") { + return json({ + session: { id: "s1", claude_session_id: "c1", title: "t", active_turn_id: "t1", archived: false }, + }); + } + if (url.endsWith("/claude/sessions/s1") && method === "PATCH") { + return json({ ok: true }); + } + if (url.endsWith("/claude/sessions/s1/archive")) { + return json({ ok: true }); + } + if (url.endsWith("/claude/sessions/s1/fork")) { + return json({ + session: { id: "s3", claude_session_id: "c3", title: "t", active_turn_id: "", archived: false }, + }); + } + if (url.endsWith("/claude/sessions/s1/settings") && method === "GET") { + return json({ + model: "sonnet", + effort: "medium", + permission_mode: "default", + cwd: "/repo", + overridden: [], + }); + } + if (url.endsWith("/claude/sessions/s1/settings") && method === "POST") { + return json({ + model: "opus", + effort: "high", + permission_mode: "plan", + cwd: "/repo", + overridden: ["model"], + }); + } + if (url.endsWith("/claude/sessions/s1/commands")) { + return json({ hint: "到终端改 Claude 配置" }); + } + if (url.endsWith("/claude/sessions/s1/mentions") || url.endsWith("/claude/sessions/s1/images")) { + return json({ ok: true }); + } + if (url.endsWith("/claude/sessions/s1/turns")) { + return json({ turn_id: "turn-1" }); + } + if (url.endsWith("/claude/sessions/s1/transcript")) { + return json({ + items: [{ kind: "text", text: "hi", command: "", paths: [], diff: "" }], + usage: { used: 170, window: 200000 }, + }); + } + if (url.endsWith("/claude/turns/turn-1/cancel") || url.endsWith("/claude/turns/turn-1/continue")) { + return json({ ok: true }); + } + if (url.endsWith("/claude/approvals/a1/decision")) { + return json({ ok: true }); + } + if (url.endsWith("/claude/asks/r1/reject-unknown")) { + return json({ ok: true }); + } + return json({ error: "missing mock" }, 500); + }, + }); + + const status = await client.probe(); + assert.equal(status.available, true); + const models = await client.listModels(); + assert.equal(models[0]?.id, "sonnet"); + const modes = await client.listModes(); + assert.equal(modes[0]?.id, "default"); + const commands = await client.listCommands(); + assert.equal(commands[0]?.name, "mcp"); + const sessions = await client.listSessions(); + assert.equal(sessions[0]?.id, "s1"); + const created = await client.createSession(); + assert.equal(created.id, "s2"); + const got = await client.getSession("s1"); + assert.equal(got.claude_session_id, "c1"); + await client.renameSession("s1", "hello"); + await client.archiveSession("s1"); + const forked = await client.forkSession("s1"); + assert.equal(forked.id, "s3"); + const settings = await client.getSettings("s1"); + assert.equal(settings.model, "sonnet"); + const applied = await client.applySettings("s1", { model: "opus", effort: "high", permission_mode: "plan" }); + assert.equal(applied.model, "opus"); + const hint = await client.invoke("s1", "mcp", ""); + assert.equal(hint.includes("终端"), true); + await client.mention("s1", "a.go"); + await client.attachImage("s1", "a.png"); + const turnId = await client.startTurn("s1", { + content: "hi", + input: { text: "hi", mentions: ["a.go"], images: [] }, + mode: "start", + }); + assert.equal(turnId, "turn-1"); + const transcript = await client.hydrate("s1"); + assert.equal(transcript.items[0]?.kind, "text"); + assert.equal(transcript.usage?.used, 170); + assert.equal(transcript.usage?.window, 200000); + await client.cancelTurn("turn-1"); + await client.continueTurn("turn-1"); + await client.decide("a1", { approved: true, scope: "once", choice: "a", values: ["n"] }); + await client.rejectUnknown("r1", "s1", "t1"); + + const paths = calls.map((call) => `${call.method} ${call.url.replace("http://api.test", "")}`); + assert.deepEqual(paths, [ + "GET /claude/status", + "GET /claude/models", + "GET /claude/modes", + "GET /claude/commands", + "GET /claude/sessions", + "POST /claude/sessions", + "GET /claude/sessions/s1", + "PATCH /claude/sessions/s1", + "POST /claude/sessions/s1/archive", + "POST /claude/sessions/s1/fork", + "GET /claude/sessions/s1/settings", + "POST /claude/sessions/s1/settings", + "POST /claude/sessions/s1/commands", + "POST /claude/sessions/s1/mentions", + "POST /claude/sessions/s1/images", + "POST /claude/sessions/s1/turns", + "GET /claude/sessions/s1/transcript", + "POST /claude/turns/turn-1/cancel", + "POST /claude/turns/turn-1/continue", + "POST /claude/approvals/a1/decision", + "POST /claude/asks/r1/reject-unknown", + ]); + assert.deepEqual(calls[5]?.body, { user_id: "local" }); + assert.deepEqual(calls[7]?.body, { title: "hello" }); + assert.deepEqual(calls[11]?.body, { + model: "opus", + effort: "high", + permission_mode: "plan", + cwd: "", + overridden: [], + }); + assert.deepEqual(calls[12]?.body, { name: "mcp", args: "" }); + assert.deepEqual(calls[13]?.body, { path: "a.go" }); + assert.deepEqual(calls[14]?.body, { path: "a.png" }); + assert.deepEqual(calls[15]?.body, { + content: "hi", + input: { text: "hi", mentions: ["a.go"], images: [] }, + mode: "start", + }); + assert.deepEqual(calls[19]?.body, { approved: true, scope: "once", choice: "a", values: ["n"] }); + assert.deepEqual(calls[20]?.body, { session_id: "s1", turn_id: "t1" }); +}); + +test("ClaudeClient surfaces HTTP error", async () => { + const client = new ClaudeClient({ + baseUrl: "http://api.test", + userId: "local", + fetch: async () => json({ error: "unavailable" }, 503), + }); + await assert.rejects(() => client.probe(), (err: unknown) => { + assert.ok(err instanceof ClaudeClientError); + assert.equal(err.status, 503); + assert.equal(err.message, "unavailable"); + return true; + }); +}); diff --git a/packages/core/claude/client.ts b/packages/core/claude/client.ts new file mode 100644 index 0000000..4edf25b --- /dev/null +++ b/packages/core/claude/client.ts @@ -0,0 +1,241 @@ +import type { + ClaudeCommand, + ClaudeDecision, + ClaudeInput, + ClaudeMode, + ClaudeModel, + ClaudeSession, + ClaudeSettings, + ClaudeSettingsPatch, + ClaudeStartTurnRequest, + ClaudeStatus, + ClaudeTranscript, +} from "./types.ts"; + +export class ClaudeClientError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ClaudeClientError"; + this.status = status; + } +} + +export type ClaudeClientOptions = { + baseUrl: string; + userId: string; + fetch?: typeof fetch; +}; + +export class ClaudeClient { + readonly baseUrl: string; + readonly userId: string; + private readonly fetchImpl: typeof fetch; + + // 注入 API 根地址与默认用户,去掉末尾斜杠以免拼路径重复。 + constructor(options: ClaudeClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.userId = options.userId; + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + } + + // 探测本机 Claude Code 是否可用、是否已授权。 + async probe(): Promise { + return this.request("/claude/status"); + } + + // 列出本机 Claude 模型及各自推理强度。 + async listModels(): Promise { + const body = await this.request<{ models?: ClaudeModel[] }>("/claude/models"); + return body.models ?? []; + } + + // 列出 Claude 官方权限档。 + async listModes(): Promise { + const body = await this.request<{ modes?: ClaudeMode[] }>("/claude/modes"); + return body.modes ?? []; + } + + // 列出与 Claude 斜杠同名的命令。 + async listCommands(): Promise { + const body = await this.request<{ commands?: ClaudeCommand[] }>("/claude/commands"); + return body.commands ?? []; + } + + // 从本机 Claude 列对话。 + async listSessions(): Promise { + const body = await this.request<{ sessions?: ClaudeSession[] }>("/claude/sessions"); + return body.sessions ?? []; + } + + // 开一条只走 Claude Code 的空对话。 + async createSession(userId = this.userId): Promise { + const body = await this.request<{ session: ClaudeSession }>("/claude/sessions", { + method: "POST", + json: { user_id: userId }, + }); + return body.session; + } + + // 读一条对话的标题、绑定编号和进行中回合。 + async getSession(sessionId: string): Promise { + const body = await this.request<{ session: ClaudeSession }>(`/claude/sessions/${sessionId}`); + return body.session; + } + + // 改对话标题。 + async renameSession(sessionId: string, title: string): Promise { + await this.request<{ ok: boolean }>(`/claude/sessions/${sessionId}`, { + method: "PATCH", + json: { title }, + }); + } + + // 归档后不能再向 Claude 开回合。 + async archiveSession(sessionId: string): Promise { + await this.request<{ ok: boolean }>(`/claude/sessions/${sessionId}/archive`, { method: "POST" }); + } + + // 按官方 --fork-session 复制已落盘实录,换新对话。 + async forkSession(sessionId: string): Promise { + const body = await this.request<{ session: ClaudeSession }>(`/claude/sessions/${sessionId}/fork`, { + method: "POST", + }); + return body.session; + } + + // 读生效配置(默认与覆盖合并)。 + async getSettings(sessionId: string): Promise { + return this.request(`/claude/sessions/${sessionId}/settings`); + } + + // 记下用户改过的模型、强度、权限档或目录,下次开回合交给 Claude。 + async applySettings(sessionId: string, patch: ClaudeSettingsPatch): Promise { + return this.request(`/claude/sessions/${sessionId}/settings`, { + method: "POST", + json: { + model: patch.model ?? "", + effort: patch.effort ?? "", + permission_mode: patch.permission_mode ?? "", + cwd: patch.cwd ?? "", + overridden: patch.overridden ?? [], + }, + }); + } + + // 执行与斜杠同名的动作,或返回去终端改配置的提示。 + async invoke(sessionId: string, name: string, args = ""): Promise { + const body = await this.request<{ hint: string }>(`/claude/sessions/${sessionId}/commands`, { + method: "POST", + json: { name, args }, + }); + return body.hint ?? ""; + } + + // 把仓库内文件挂到下一条待发内容上。 + async mention(sessionId: string, path: string): Promise { + await this.request<{ ok: boolean }>(`/claude/sessions/${sessionId}/mentions`, { + method: "POST", + json: { path }, + }); + } + + // 把本地图片挂到下一条待发内容上。 + async attachImage(sessionId: string, path: string): Promise { + await this.request<{ ok: boolean }>(`/claude/sessions/${sessionId}/images`, { + method: "POST", + json: { path }, + }); + } + + // 空闲则开新一轮,进行中则排队。 + async startTurn(sessionId: string, req: ClaudeStartTurnRequest): Promise { + const input = emptyInput(req.input); + const body = await this.request<{ turn_id: string }>(`/claude/sessions/${sessionId}/turns`, { + method: "POST", + json: { + content: req.content, + input, + mode: req.mode ?? "start", + }, + }); + return body.turn_id; + } + + // 按本机 Claude 已落下的记录回放实录,并带上官方上下文用量。 + async hydrate(sessionId: string): Promise { + const body = await this.request(`/claude/sessions/${sessionId}/transcript`); + return { items: body.items ?? [], usage: body.usage }; + } + + // 打断当前一轮并传播取消。 + async cancelTurn(turnId: string): Promise { + await this.request<{ ok: boolean }>(`/claude/turns/${turnId}/cancel`, { method: "POST" }); + } + + // 审批后继续当前一轮。 + async continueTurn(turnId: string): Promise { + await this.request<{ ok: boolean }>(`/claude/turns/${turnId}/continue`, { method: "POST" }); + } + + // 对已知反问作答。 + async decide(approvalId: string, answer: ClaudeDecision): Promise { + await this.request<{ ok: boolean }>(`/claude/approvals/${approvalId}/decision`, { + method: "POST", + json: { + approved: answer.approved, + scope: answer.scope ?? "once", + choice: answer.choice ?? "", + values: answer.values ?? [], + }, + }); + } + + // 回包不兼容的未知问票。 + async rejectUnknown(requestId: string, sessionId: string, turnId: string): Promise { + await this.request<{ ok: boolean }>(`/claude/asks/${requestId}/reject-unknown`, { + method: "POST", + json: { session_id: sessionId, turn_id: turnId }, + }); + } + + // 发 JSON 请求,非 2xx 抽出 error 字段抛 ClaudeClientError。 + private async request(path: string, init: RequestInit & { json?: unknown } = {}): Promise { + const headers = new Headers(init.headers); + if (init.json !== undefined) { + headers.set("Content-Type", "application/json"); + } + const res = await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + headers, + body: init.json !== undefined ? JSON.stringify(init.json) : init.body, + }); + const text = await res.text(); + let parsed: unknown = undefined; + if (text) { + try { + parsed = JSON.parse(text); + } catch { + parsed = { error: text }; + } + } + if (!res.ok) { + const message = + parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : res.statusText; + throw new ClaudeClientError(res.status, message); + } + return parsed as T; + } +} + +// emptyInput 把缺省提及和图片收成空数组,避免后端收到 null。 +function emptyInput(input?: Partial): ClaudeInput { + return { + text: input?.text ?? "", + mentions: input?.mentions ?? [], + images: input?.images ?? [], + }; +} diff --git a/packages/core/claude/index.ts b/packages/core/claude/index.ts new file mode 100644 index 0000000..698db08 --- /dev/null +++ b/packages/core/claude/index.ts @@ -0,0 +1,17 @@ +export { ClaudeClient, ClaudeClientError, type ClaudeClientOptions } from "./client.ts"; +export type { + ClaudeCommand, + ClaudeDecision, + ClaudeInput, + ClaudeMode, + ClaudeModel, + ClaudeProgress, + ClaudeSession, + ClaudeSettings, + ClaudeSettingsPatch, + ClaudeStartTurnRequest, + ClaudeStatus, + ClaudeTimelineItem, + ClaudeTokenUsage, + ClaudeTranscript, +} from "./types.ts"; diff --git a/packages/core/claude/types.ts b/packages/core/claude/types.ts new file mode 100644 index 0000000..a42b0cb --- /dev/null +++ b/packages/core/claude/types.ts @@ -0,0 +1,95 @@ +export type ClaudeStatus = { + available: boolean; + authorized: boolean; + version: string; + hint: string; +}; + +export type ClaudeModel = { + id: string; + efforts: string[]; + default_effort: string; + hidden: boolean; + is_default: boolean; +}; + +export type ClaudeMode = { + id: string; + kind: string; +}; + +export type ClaudeCommand = { + name: string; + action: string; + hint: string; +}; + +export type ClaudeSession = { + id: string; + claude_session_id: string; + title: string; + active_turn_id: string; + archived: boolean; + /** 本机实录第一条时间,Unix 秒。 */ + created_at?: number; + /** 本机实录最近一条时间,Unix 秒。 */ + updated_at?: number; +}; + +export type ClaudeSettings = { + model: string; + effort: string; + permission_mode: string; + cwd: string; + overridden: string[]; +}; + +export type ClaudeSettingsPatch = { + model?: string; + effort?: string; + permission_mode?: string; + cwd?: string; + overridden?: string[]; +}; + +export type ClaudeInput = { + text: string; + mentions: string[]; + images: string[]; +}; + +export type ClaudeStartTurnRequest = { + content: string; + input?: Partial; + mode?: "start" | "queue"; +}; + +export type ClaudeProgress = { + kind: "user" | "text" | "reasoning" | "command" | "file_change" | "plan" | "notice" | string; + text: string; + command: string; + paths: string[]; + diff: string; +}; + +export type ClaudeTimelineItem = ClaudeProgress & { + id: string; + streaming?: boolean; +}; + +export type ClaudeTokenUsage = { + used: number; + window: number; +}; + +export type ClaudeTranscript = { + items: ClaudeProgress[]; + usage?: ClaudeTokenUsage; +}; + +export type ClaudeDecision = { + approved: boolean; + scope?: "once" | "session" | string; + choice?: string; + values?: string[]; +}; diff --git a/packages/core/index.ts b/packages/core/index.ts index b2cf33b..ce981fb 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -35,6 +35,7 @@ export { } from "./chat/index.ts"; export { GitClient, GitClientError, type GitClientOptions } from "./git/index.ts"; export { CodexClient, CodexClientError, type CodexClientOptions } from "./codex/index.ts"; +export { ClaudeClient, ClaudeClientError, type ClaudeClientOptions } from "./claude/index.ts"; export type { ApprovalAsk, AskAnswer, @@ -48,6 +49,20 @@ export type { Settings as CodexSettings, Turn as CodexTurn, } from "./codex/index.ts"; +export type { + ClaudeCommand, + ClaudeDecision, + ClaudeInput, + ClaudeMode, + ClaudeModel, + ClaudeProgress, + ClaudeSession, + ClaudeSettings, + ClaudeSettingsPatch, + ClaudeStartTurnRequest, + ClaudeStatus, + ClaudeTimelineItem, +} from "./claude/index.ts"; export type { Branch, BranchView, diff --git a/packages/core/package.json b/packages/core/package.json index 900af33..6fcd40b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -7,10 +7,11 @@ ".": "./index.ts", "./chat": "./chat/index.ts", "./git": "./git/index.ts", - "./codex": "./codex/index.ts" + "./codex": "./codex/index.ts", + "./claude": "./claude/index.ts" }, "scripts": { - "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts" + "test": "node --test --experimental-strip-types chat/reducer.test.ts chat/queue.test.ts chat/sse.test.ts chat/plan.test.ts chat/wait-run.test.ts git/client.test.ts codex/client.test.ts codex/reducer.test.ts claude/client.test.ts" }, "devDependencies": { "@types/node": "^20", diff --git a/packages/views/chat/chat-page.tsx b/packages/views/chat/chat-page.tsx index ecdc2ac..5e64658 100644 --- a/packages/views/chat/chat-page.tsx +++ b/packages/views/chat/chat-page.tsx @@ -1,10 +1,14 @@ "use client"; import type { ApprovalMode, Session, TimelineItem, WorkMode } from "@codedock/core/chat"; +import type { ClaudeSession } from "@codedock/core/claude"; import type { Session as CodexSession } from "@codedock/core/codex"; import { Button } from "@codedock/ui"; import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ClaudePane } from "../claude/claude-pane.tsx"; +import { useClaudeSessionList } from "../claude/hooks/use-session-list.ts"; +import { useClaude } from "../claude/provider.tsx"; import { CodexPane } from "../codex/codex-pane.tsx"; import { useCodexSessionList } from "../codex/hooks/use-session-list.ts"; import { useCodex } from "../codex/provider.tsx"; @@ -25,7 +29,7 @@ import { PendingDock } from "./pending-dock.tsx"; import { PromptBar } from "./prompt-bar.tsx"; import { SessionSidebar, type SidebarSession } from "./session-sidebar.tsx"; -export type SessionEngine = "agent" | "codex"; +export type SessionEngine = "agent" | "codex" | "claude"; export type ChatPageProps = { sessionId?: string; @@ -34,6 +38,7 @@ export type ChatPageProps = { onNewConversation: () => void; brandSrc?: string; codexIconSrc?: string; + claudeIconSrc?: string; headerActions?: ReactNode; }; @@ -45,12 +50,15 @@ export function ChatPage({ onNewConversation, brandSrc, codexIconSrc, + claudeIconSrc, headerActions, }: ChatPageProps) { const { client, pickDirectory, pickFiles } = useAgent(); const { client: codexClient } = useCodex(); + const { client: claudeClient } = useClaude(); const list = useSessionList(); const codexList = useCodexSessionList(); + const claudeList = useClaudeSessionList(); const [draftEngine, setDraftEngine] = useState(engine ?? "agent"); const activeEngine: SessionEngine = sessionId ? (engine ?? "agent") : draftEngine; useEffect(() => { @@ -70,8 +78,8 @@ export function ChatPage({ }, []); const sessions = useMemo( - () => mergeSessions(list.sessions, codexList.sessions), - [codexList.sessions, list.sessions], + () => mergeSessions(list.sessions, codexList.sessions, claudeList.sessions), + [claudeList.sessions, codexList.sessions, list.sessions], ); const currentKey = sessionId ? `${activeEngine}:${sessionId}` : undefined; const current = sessions.find((session) => `${session.engine}:${session.id}` === currentKey); @@ -137,12 +145,16 @@ export function ChatPage({ }); }; + // hideSession 按引擎归档或删除,当前打开的那条会回到新建页。 const hideSession = async (session: SidebarSession) => { const hiddenId = session.id; const hiddenEngine = session.engine ?? "agent"; if (session.engine === "codex") { await codexClient.archiveSession(session.id); await codexList.refresh(); + } else if (session.engine === "claude") { + await claudeClient.archiveSession(session.id); + await claudeList.refresh(); } else { await list.removeSession(session); } @@ -156,8 +168,14 @@ export function ChatPage({ void codexList.loadMore() : undefined} onCreate={onNewConversation} @@ -172,12 +190,17 @@ export function ChatPage({ }} brandSrc={brandSrc} codexIconSrc={codexIconSrc} + claudeIconSrc={claudeIconSrc} />
{sessionId ? (
- {activeEngine === "codex" ? "Codex 对话" : "Local 对话"} + {activeEngine === "codex" + ? "Codex 对话" + : activeEngine === "claude" + ? "Claude 对话" + : "Local 对话"} {workspaceLabel ? ( <> @@ -227,6 +250,22 @@ export function ChatPage({ onListChange={codexList.refresh} /> + ) : activeEngine === "claude" ? ( + <> + {composerError ? ( +
+ {composerError} +
+ ) : null} + onOpenSession(id, "claude")} + onNewConversation={onNewConversation} + onListChange={claudeList.refresh} + /> + ) : ( <> {timeline.error || composerError ? ( @@ -269,6 +308,7 @@ export function ChatPage({ { setDraftEngine(next); @@ -294,6 +334,15 @@ export function ChatPage({ onNewConversation={onNewConversation} onListChange={codexList.refresh} /> + ) : draftEngine === "claude" ? ( + onOpenSession(id, "claude")} + onNewConversation={onNewConversation} + onListChange={claudeList.refresh} + /> ) : ( ({ ...session, engine: "agent" as const })), - ...codex.map(asSidebarSession), + ...codex.map(asCodexSidebarSession), + ...claude.map(asClaudeSidebarSession), ]; const seen = new Map(); for (const session of mapped) { @@ -328,8 +382,8 @@ function mergeSessions(agent: Session[], codex: CodexSession[]): SidebarSession[ return [...seen.values()].sort((left, right) => (left.updated_at < right.updated_at ? 1 : -1)); } -// asSidebarSession 把 Codex 会话收成侧栏条目,目录用 cwd,标题优先 title。 -function asSidebarSession(session: CodexSession): SidebarSession { +// asCodexSidebarSession 把 Codex 会话收成侧栏条目,目录用 cwd,标题优先 title。 +function asCodexSidebarSession(session: CodexSession): SidebarSession { return { id: session.id, tenant_id: "", @@ -346,6 +400,24 @@ function asSidebarSession(session: CodexSession): SidebarSession { }; } +// asClaudeSidebarSession 把 Claude 会话收成侧栏条目;时间用本机实录的 Unix 秒。 +function asClaudeSidebarSession(session: ClaudeSession): SidebarSession { + return { + id: session.id, + tenant_id: "", + user_id: "", + agent_id: "claude", + workspace_id: "", + status: session.archived ? "archived" : "active", + last_event_seq: 1, + compaction_seq: 0, + summary: session.title || session.claude_session_id, + created_at: stampToIso(session.created_at), + updated_at: stampToIso(session.updated_at), + engine: "claude", + }; +} + // stampToIso 把秒或毫秒时间戳收成 ISO 字符串,无效值用纪元。 function stampToIso(value?: number): string { if (!value) { diff --git a/packages/views/chat/lib/format.ts b/packages/views/chat/lib/format.ts index adfc483..921b86e 100644 --- a/packages/views/chat/lib/format.ts +++ b/packages/views/chat/lib/format.ts @@ -1,5 +1,3 @@ -import { firstLine } from "@codedock/core/chat"; - export function shortId(id: string): string { return id.replace(/-/g, "").slice(0, 8); } @@ -28,11 +26,51 @@ export function relativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } +// sessionTitle 侧栏标题:正文中间可省略,末尾 (n) 必须留下。 export function sessionTitle(id: string, preview?: string): string { - if (preview?.trim()) { - return firstLine(preview, 36); + const { stem, suffix } = sessionTitleParts(id, preview); + return `${stem}${suffix}`; +} + +// sessionTitleParts 拆开可裁的正文和必须露出来的序号,给侧栏两段排。 +export function sessionTitleParts(id: string, preview?: string): { stem: string; suffix: string } { + const raw = preview?.trim() ? clipTitleKeepIndex(preview, 36) : `会话 ${shortId(id)}`; + return splitForkSuffix(raw); +} + +// clipTitleKeepIndex 取首行;超长时省略中间,保留末尾 (n)。 +function clipTitleKeepIndex(text: string, max: number): string { + const line = text.split("\n").find((part) => part.trim()) ?? text; + const trimmed = line.trim(); + const { stem, suffix } = splitForkSuffix(trimmed); + if ([...trimmed].length <= max) { + return trimmed; + } + const budget = Math.max(8, max - [...suffix].length); + return `${clipMiddle(stem, budget)}${suffix}`; +} + +// splitForkSuffix 认出官方 fork 序号,如「 (1)」。 +function splitForkSuffix(title: string): { stem: string; suffix: string } { + const match = title.match(/ \((\d+)\)$/); + if (!match || match.index == null) { + return { stem: title, suffix: "" }; + } + return { stem: title.slice(0, match.index), suffix: title.slice(match.index) }; +} + +// clipMiddle 超长正文留头尾,中间用省略号。 +function clipMiddle(text: string, max: number): string { + const runes = [...text]; + if (runes.length <= max) { + return text.trim(); } - return `会话 ${shortId(id)}`; + const keep = Math.max(1, max - 1); + const head = Math.max(1, Math.ceil(keep * 0.55)); + const tail = Math.max(0, keep - head); + const start = runes.slice(0, head).join("").trimEnd(); + const end = tail > 0 ? runes.slice(-tail).join("").trimStart() : ""; + return end ? `${start}…${end}` : `${start}…`; } export function shortWorkspace(path: string, max = 42): string { diff --git a/packages/views/chat/new-conversation.tsx b/packages/views/chat/new-conversation.tsx index 9085081..6669923 100644 --- a/packages/views/chat/new-conversation.tsx +++ b/packages/views/chat/new-conversation.tsx @@ -4,9 +4,13 @@ import { cn } from "@codedock/ui"; import { FolderOpen } from "lucide-react"; import type { ReactNode } from "react"; +import type { SessionEngine } from "./chat-page.tsx"; + +// NewConversation 居中偏上;模式切换放在标语下,目录条贴在输入框正上方且等宽。 export function NewConversation({ brandSrc, codexIconSrc, + claudeIconSrc, engine, onEngine, workspaceLabel, @@ -20,8 +24,9 @@ export function NewConversation({ }: { brandSrc?: string; codexIconSrc?: string; - engine: "agent" | "codex"; - onEngine: (engine: "agent" | "codex") => void; + claudeIconSrc?: string; + engine: SessionEngine; + onEngine: (engine: SessionEngine) => void; workspaceLabel: string; workspaceTitle: string; picking: boolean; @@ -40,7 +45,7 @@ export function NewConversation({

CodeDock

以issue驱动开发,把对话停在codedock

@@ -53,9 +58,17 @@ export function NewConversation({ onEngine("codex")} /> + onEngine("claude")} + />
@@ -94,14 +107,17 @@ export function NewConversation({ ); } +// EngineChoice 用图标加短名切换引擎,不另贴文字徽章。 function EngineChoice({ active, icon, + rounded, label, onClick, }: { active: boolean; icon?: string; + rounded?: boolean; label: string; onClick: () => void; }) { @@ -117,7 +133,7 @@ function EngineChoice({ )} onClick={onClick} > - {icon ? : null} + {icon ? : null} {label} ); diff --git a/packages/views/chat/session-sidebar.tsx b/packages/views/chat/session-sidebar.tsx index a1360c8..ff0f38e 100644 --- a/packages/views/chat/session-sidebar.tsx +++ b/packages/views/chat/session-sidebar.tsx @@ -4,10 +4,12 @@ import type { Session } from "@codedock/core/chat"; import { Button, cn } from "@codedock/ui"; import { Archive, PlusIcon } from "lucide-react"; -import { relativeTime, sessionTitle, shortId } from "./lib/format.ts"; +import type { SessionEngine } from "./chat-page.tsx"; +import { relativeTime, sessionTitle, sessionTitleParts, shortId } from "./lib/format.ts"; -export type SidebarSession = Session & { engine?: "agent" | "codex" }; +export type SidebarSession = Session & { engine?: SessionEngine }; +// SessionSidebar 混排 Local / Codex / Claude,引擎只靠图标区分。 export function SessionSidebar({ sessions, currentId, @@ -22,6 +24,7 @@ export function SessionSidebar({ canRecoverCurrent = false, brandSrc, codexIconSrc, + claudeIconSrc, }: { sessions: SidebarSession[]; currentId?: string; @@ -30,12 +33,13 @@ export function SessionSidebar({ hasMore?: boolean; onLoadMore?: () => void; onCreate: () => void; - onSelect: (id: string, engine?: "agent" | "codex") => void; + onSelect: (id: string, engine?: SessionEngine) => void; onRecover?: (runId: string) => Promise; onArchive?: (session: SidebarSession) => Promise; canRecoverCurrent?: boolean; brandSrc?: string; codexIconSrc?: string; + claudeIconSrc?: string; }) { return (
); case "text": @@ -107,7 +108,7 @@ function TimelineRow({ - + ); case "reasoning": @@ -163,12 +164,11 @@ function TimelineRow({ } } +// ForkAction 只挂在 AI 回复下,按官方 ThreadFork 开出独立副本。 function ForkAction({ - align, disabled, onFork, }: { - align: "start" | "end"; disabled: boolean; onFork?: () => Promise; }) { @@ -176,7 +176,7 @@ function ForkAction({ return null; } return ( -
+
); diff --git a/packages/views/index.ts b/packages/views/index.ts index 1fa23fb..fa86e93 100644 --- a/packages/views/index.ts +++ b/packages/views/index.ts @@ -7,6 +7,7 @@ export { } from "./provider.tsx"; export { GitPage, GitProvider, useGit, type GitPageProps } from "./git/index.ts"; export { CodexPane, CodexProvider, useCodex } from "./codex/index.ts"; +export { ClaudePane, ClaudeProvider, useClaude } from "./claude/index.ts"; export { ChatPage, ConversationTimeline, diff --git a/packages/views/package.json b/packages/views/package.json index 74a41ec..6e9aa7d 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -7,7 +7,8 @@ ".": "./index.ts", "./chat": "./chat/index.ts", "./git": "./git/index.ts", - "./codex": "./codex/index.ts" + "./codex": "./codex/index.ts", + "./claude": "./claude/index.ts" }, "dependencies": { "@codedock/core": "workspace:*", diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 3b7ab8f..56b0daf 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -11,7 +11,7 @@ import ( "github.com/go-chi/chi/v5/middleware" ) -// newRouter 注册健康检查、Agent CRUD 与独立 /codex 路由。 +// newRouter 注册健康检查、Agent CRUD、独立 /codex 与 /claude 路由。 func newRouter(log *slog.Logger, api *handler.API, codexAPI *codexhttp.API) http.Handler { router := chi.NewRouter() router.Use(cors) @@ -86,6 +86,29 @@ func newRouter(log *slog.Logger, api *handler.API, codexAPI *codexhttp.API) http r.Get("/undo", api.GitListUndo) r.Post("/undo", api.GitClickUndo) }) + router.Route("/claude", func(r chi.Router) { + r.Get("/status", api.ClaudeProbe) + r.Get("/models", api.ClaudeListModels) + r.Get("/modes", api.ClaudeListModes) + r.Get("/commands", api.ClaudeListCommands) + r.Get("/sessions", api.ClaudeListSessions) + r.Post("/sessions", api.ClaudeCreateSession) + r.Get("/sessions/{session_id}", api.ClaudeGetSession) + r.Patch("/sessions/{session_id}", api.ClaudeRenameSession) + r.Post("/sessions/{session_id}/archive", api.ClaudeArchiveSession) + r.Post("/sessions/{session_id}/fork", api.ClaudeForkSession) + r.Get("/sessions/{session_id}/settings", api.ClaudeEffective) + r.Post("/sessions/{session_id}/settings", api.ClaudeApplySettings) + r.Post("/sessions/{session_id}/commands", api.ClaudeInvoke) + r.Post("/sessions/{session_id}/mentions", api.ClaudeMention) + r.Post("/sessions/{session_id}/images", api.ClaudeAttachImage) + r.Post("/sessions/{session_id}/turns", api.ClaudeStartTurn) + r.Get("/sessions/{session_id}/transcript", api.ClaudeHydrate) + r.Post("/turns/{turn_id}/cancel", api.ClaudeCancelTurn) + r.Post("/turns/{turn_id}/continue", api.ClaudeContinueTurn) + r.Post("/approvals/{approval_id}/decision", api.ClaudeDecide) + r.Post("/asks/{request_id}/reject-unknown", api.ClaudeRejectUnknown) + }) } if codexAPI != nil { codexAPI.Mount(router) diff --git a/server/internal/codex/fake.go b/server/internal/codex/fake.go index bb54e78..ff1b512 100644 --- a/server/internal/codex/fake.go +++ b/server/internal/codex/fake.go @@ -123,7 +123,17 @@ func (f *FakeHandler) Handle(env pkg.Envelope) []pkg.Envelope { f.markLoaded(th.ID) body, _ := json.Marshal(map[string]any{"thread": th, "model": "gpt-5.6", "cwd": "/tmp", "approvalPolicy": "on-request", "sandbox": "workspace-write"}) return []pkg.Envelope{{ID: env.ID, Result: body}} - case pkg.MethodThreadArchive, pkg.MethodThreadUnarchive, pkg.MethodThreadNameSet, pkg.MethodThreadCompact, pkg.MethodReviewStart, pkg.MethodTurnInterrupt: + case pkg.MethodThreadNameSet: + id := jsonField(env.Params, "threadId") + name := jsonField(env.Params, "name") + f.mu.Lock() + if th, ok := f.Threads[id]; ok && name != "" { + th.Name = name + f.Threads[id] = th + } + f.mu.Unlock() + return []pkg.Envelope{{ID: env.ID, Result: json.RawMessage(`{}`)}} + case pkg.MethodThreadArchive, pkg.MethodThreadUnarchive, pkg.MethodThreadCompact, pkg.MethodReviewStart, pkg.MethodTurnInterrupt: if env.Method == pkg.MethodTurnInterrupt { tid := jsonField(env.Params, "threadId") turnID := jsonField(env.Params, "turnId") diff --git a/server/internal/codex/runtime_test.go b/server/internal/codex/runtime_test.go index 7ccee00..3b28816 100644 --- a/server/internal/codex/runtime_test.go +++ b/server/internal/codex/runtime_test.go @@ -245,6 +245,33 @@ func loopRT(t *testing.T, fake *FakeHandler) *Runtime { return rt } +func TestForkTitleSequence(t *testing.T) { + rt := loopRT(t, nil) + ctx := context.Background() + session, err := rt.CreateSession(ctx, pkg.Settings{Cwd: "/tmp", Model: "gpt-5.6"}) + if err != nil { + t.Fatal(err) + } + if err := rt.Rename(ctx, session.ID, "ping"); err != nil { + t.Fatal(err) + } + first, err := rt.Fork(ctx, session.ID) + if err != nil || first.Title != "ping (1)" { + t.Fatalf("first %+v %v", first, err) + } + second, err := rt.Fork(ctx, session.ID) + if err != nil || second.Title != "ping (2)" { + t.Fatalf("second %+v %v", second, err) + } + third, err := rt.Fork(ctx, first.ID) + if err != nil || third.Title != "ping (3)" { + t.Fatalf("third %+v %v", third, err) + } + if nextForkTitle("ping", []string{"ping"}) != "ping (1)" || nextForkTitle("", nil) != "fork (1)" { + t.Fatal("nextForkTitle") + } +} + func TestRuntimeInvokeExpireRingAndFailTurn(t *testing.T) { fake := NewFakeHandler() rt := loopRT(t, fake) diff --git a/server/internal/codex/session.go b/server/internal/codex/session.go index 4d0827b..918fd80 100644 --- a/server/internal/codex/session.go +++ b/server/internal/codex/session.go @@ -180,12 +180,13 @@ func (rt *Runtime) Archive(ctx context.Context, sessionID string) error { return nil } -// Fork 按官方历史分叉。 +// Fork 按官方历史分叉,标题加 (1)(2)(3),原对话不动。 func (rt *Runtime) Fork(ctx context.Context, sessionID string) (pkg.Session, error) { client, err := rt.requireReady(ctx) if err != nil { return pkg.Session{}, err } + base := rt.threadTitle(ctx, client, sessionID) res, err := client.ThreadFork(ctx, pkg.ThreadForkParams{ThreadID: sessionID}) if err != nil { return pkg.Session{}, mapRPC(err) @@ -198,9 +199,45 @@ func (rt *Runtime) Fork(ctx context.Context, sessionID string) (pkg.Session, err dst.mu.Lock() dst.settings = settings dst.mu.Unlock() + title := nextForkTitle(base, rt.sessionTitles(ctx)) + if err := mapRPC(client.ThreadSetName(ctx, res.Thread.ID, title)); err != nil { + return pkg.Session{}, err + } + res.Thread.Name = title return pkg.MapThread(res.Thread, false, ""), nil } +// threadTitle 读官方 thread 标题,没有名字则用预览。 +func (rt *Runtime) threadTitle(ctx context.Context, client *pkg.Client, sessionID string) string { + res, err := client.ThreadRead(ctx, pkg.ThreadReadParams{ThreadID: sessionID}) + if err != nil { + return "" + } + if name := strings.TrimSpace(res.Thread.Name); name != "" { + return name + } + return strings.TrimSpace(res.Thread.Preview) +} + +// sessionTitles 收集本机已有对话标题,用来给 fork 编号。 +func (rt *Runtime) sessionTitles(ctx context.Context) []string { + page, err := rt.ListSessions(ctx, false, "") + if err != nil { + return nil + } + out := make([]string, 0, len(page.Sessions)) + for _, sess := range page.Sessions { + if sess.Title != "" { + out = append(out, sess.Title) + continue + } + if sess.Preview != "" { + out = append(out, sess.Preview) + } + } + return out +} + // Effective 返回 Codex 默认与用户覆盖合并后的配置。 func (rt *Runtime) Effective(ctx context.Context, sessionID string) (pkg.Settings, error) { st := rt.state(sessionID) diff --git a/server/internal/codex/title.go b/server/internal/codex/title.go new file mode 100644 index 0000000..01f146a --- /dev/null +++ b/server/internal/codex/title.go @@ -0,0 +1,60 @@ +package codex + +import ( + "fmt" + "strconv" + "strings" +) + +// forkTitleStem 去掉末尾 (n),避免 fork「ping (1)」变成「ping (1) (1)」。 +func forkTitleStem(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "" + } + i := strings.LastIndex(title, " (") + if i < 0 || !strings.HasSuffix(title, ")") { + return title + } + raw := title[i+2 : len(title)-1] + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || strings.TrimSpace(title[:i]) == "" { + return title + } + return strings.TrimSpace(title[:i]) +} + +// nextForkTitle 在同名对话上取下一个空位,得到「标题 (1)」「标题 (2)」。 +func nextForkTitle(base string, titles []string) string { + stem := forkTitleStem(base) + if stem == "" { + stem = "fork" + } + used := map[int]bool{} + for _, title := range titles { + title = strings.TrimSpace(title) + n, ok := forkTitleIndex(title, stem) + if ok { + used[n] = true + } + } + n := 1 + for used[n] { + n++ + } + return fmt.Sprintf("%s (%d)", stem, n) +} + +// forkTitleIndex 判断标题是不是 stem (n)。 +func forkTitleIndex(title, stem string) (int, bool) { + prefix := stem + " (" + if !strings.HasPrefix(title, prefix) || !strings.HasSuffix(title, ")") { + return 0, false + } + raw := strings.TrimSuffix(strings.TrimPrefix(title, prefix), ")") + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || title != fmt.Sprintf("%s (%d)", stem, n) { + return 0, false + } + return n, true +} diff --git a/server/internal/handler/claude.go b/server/internal/handler/claude.go new file mode 100644 index 0000000..6c6b5ce --- /dev/null +++ b/server/internal/handler/claude.go @@ -0,0 +1,556 @@ +package handler + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "codedock/pkg/claude" +) + +// Claude HTTP 契约。路径参数与 JSON 字段定死后不要改。 +// 路径参数:session_id、turn_id、approval_id、request_id。 + +type ClaudeCreateSessionRequest struct { + UserID string `json:"user_id"` +} + +type ClaudeRenameSessionRequest struct { + SessionID string `json:"-"` + Title string `json:"title"` +} + +type ClaudeInvokeRequest struct { + SessionID string `json:"-"` + Name string `json:"name"` + Args string `json:"args"` +} + +type ClaudePathRequest struct { + SessionID string `json:"-"` + Path string `json:"path"` +} + +type ClaudeInput struct { + Text string `json:"text"` + Mentions []string `json:"mentions"` + Images []string `json:"images"` +} + +type ClaudeStartTurnRequest struct { + SessionID string `json:"-"` + Content string `json:"content"` + Input ClaudeInput `json:"input"` + Mode string `json:"mode"` // start | queue +} + +type ClaudeApplySettingsRequest struct { + SessionID string `json:"-"` + Model string `json:"model"` + Effort string `json:"effort"` + PermissionMode string `json:"permission_mode"` + Cwd string `json:"cwd"` + Overridden []string `json:"overridden"` +} + +type ClaudeDecideRequest struct { + ApprovalID string `json:"-"` + Approved bool `json:"approved"` + Scope string `json:"scope"` // once | session + Choice string `json:"choice"` + Values []string `json:"values"` +} + +type ClaudeRejectUnknownRequest struct { + RequestID string `json:"-"` + SessionID string `json:"session_id"` + TurnID string `json:"turn_id"` +} + +type ClaudeStatusResponse struct { + Available bool `json:"available"` + Authorized bool `json:"authorized"` + Version string `json:"version"` + Hint string `json:"hint"` +} + +type ClaudeModel struct { + ID string `json:"id"` + Efforts []string `json:"efforts"` + DefaultEffort string `json:"default_effort"` + Hidden bool `json:"hidden"` + IsDefault bool `json:"is_default"` +} + +type ClaudeListModelsResponse struct { + Models []ClaudeModel `json:"models"` +} + +type ClaudeMode struct { + ID string `json:"id"` + Kind string `json:"kind"` +} + +type ClaudeListModesResponse struct { + Modes []ClaudeMode `json:"modes"` +} + +type ClaudeCommand struct { + Name string `json:"name"` + Action string `json:"action"` + Hint string `json:"hint"` +} + +type ClaudeListCommandsResponse struct { + Commands []ClaudeCommand `json:"commands"` +} + +type ClaudeSession struct { + ID string `json:"id"` + ClaudeSessionID string `json:"claude_session_id"` + Title string `json:"title"` + ActiveTurnID string `json:"active_turn_id"` + Archived bool `json:"archived"` + CreatedAt int64 `json:"created_at"` // 本机实录第一条时间,Unix 秒。 + UpdatedAt int64 `json:"updated_at"` // 本机实录最近一条时间,Unix 秒。 +} + +type ClaudeSessionResponse struct { + Session ClaudeSession `json:"session"` +} + +type ClaudeListSessionsResponse struct { + Sessions []ClaudeSession `json:"sessions"` +} + +type ClaudeSettingsResponse struct { + Model string `json:"model"` + Effort string `json:"effort"` + PermissionMode string `json:"permission_mode"` + Cwd string `json:"cwd"` + Overridden []string `json:"overridden"` +} + +type ClaudeInvokeResponse struct { + Hint string `json:"hint"` +} + +type ClaudeStartTurnResponse struct { + TurnID string `json:"turn_id"` +} + +type ClaudeProgress struct { + Kind string `json:"kind"` + Text string `json:"text"` + Command string `json:"command"` + Paths []string `json:"paths"` + Diff string `json:"diff"` +} + +type ClaudeTranscriptResponse struct { + Items []ClaudeProgress `json:"items"` + Usage *ClaudeTokenUsage `json:"usage,omitempty"` +} + +type ClaudeTokenUsage struct { + Used int64 `json:"used"` + Window int64 `json:"window"` +} + +type ClaudeOKResponse struct { + OK bool `json:"ok"` +} + +func claudeOK() ClaudeOKResponse { + return ClaudeOKResponse{OK: true} +} + +func claudeSessionID(r *http.Request) string { + return chi.URLParam(r, "session_id") +} + +func claudeTurnID(r *http.Request) string { + return chi.URLParam(r, "turn_id") +} + +func claudeApprovalID(r *http.Request) string { + return chi.URLParam(r, "approval_id") +} + +func claudeRequestID(r *http.Request) string { + return chi.URLParam(r, "request_id") +} + +// ClaudeProbe 查看本机 Claude Code 是否可用、是否已取得授权。 +func (a *API) ClaudeProbe(w http.ResponseWriter, r *http.Request) { + status, err := claude.Probe() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeStatusResponse{ + Available: status.Available, + Authorized: status.Authorized, + Version: status.Version, + Hint: status.Hint, + }) +} + +// ClaudeListModels 列出 Claude Code 模型及各自支持的推理强度。 +func (a *API) ClaudeListModels(w http.ResponseWriter, r *http.Request) { + models, err := claude.ListModels() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeListModelsResponse{Models: mapClaudeModels(models)}) +} + +// ClaudeListModes 列出 Claude Code 的官方权限档。 +func (a *API) ClaudeListModes(w http.ResponseWriter, r *http.Request) { + modes, err := claude.ListModes() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeListModesResponse{Modes: mapClaudeModes(modes)}) +} + +// ClaudeListCommands 列出与 Claude 斜杠、官方扩展按钮共用的命令。 +func (a *API) ClaudeListCommands(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, ClaudeListCommandsResponse{Commands: mapClaudeCommands(claude.ListCommands())}) +} + +// ClaudeCreateSession 创建一条只走 Claude Code 的对话。 +func (a *API) ClaudeCreateSession(w http.ResponseWriter, r *http.Request) { + var req ClaudeCreateSessionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + sess, err := claude.Create(req.UserID) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeSessionResponse{Session: mapClaudeSession(sess)}) +} + +// ClaudeListSessions 从本机 Claude 列对话。 +func (a *API) ClaudeListSessions(w http.ResponseWriter, r *http.Request) { + sessions, err := claude.ListSessions() + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeListSessionsResponse{Sessions: mapClaudeSessions(sessions)}) +} + +// ClaudeGetSession 读取对话。 +func (a *API) ClaudeGetSession(w http.ResponseWriter, r *http.Request) { + sess, err := claude.Get(claudeSessionID(r)) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeSessionResponse{Session: mapClaudeSession(sess)}) +} + +// ClaudeRenameSession 改对话标题。 +func (a *API) ClaudeRenameSession(w http.ResponseWriter, r *http.Request) { + var req ClaudeRenameSessionRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + if err := claude.Rename(req.SessionID, req.Title); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeArchiveSession 归档,之后不能再向 Claude Code 开回合。 +func (a *API) ClaudeArchiveSession(w http.ResponseWriter, r *http.Request) { + if err := claude.Archive(claudeSessionID(r)); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeForkSession 按官方 --fork-session 复制已落盘实录,换新 Claude session。 +func (a *API) ClaudeForkSession(w http.ResponseWriter, r *http.Request) { + sess, err := claude.Fork(claudeSessionID(r)) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeSessionResponse{Session: mapClaudeSession(sess)}) +} + +// ClaudeEffective 返回 Claude 默认与用户覆盖合并后的生效配置。 +func (a *API) ClaudeEffective(w http.ResponseWriter, r *http.Request) { + settings, err := claude.Effective(claudeSessionID(r)) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, mapClaudeSettings(settings)) +} + +// ClaudeApplySettings 只记下用户改过的 Claude 项。 +func (a *API) ClaudeApplySettings(w http.ResponseWriter, r *http.Request) { + var req ClaudeApplySettingsRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + settings, err := claude.Apply(req.SessionID, claude.Settings{ + Model: req.Model, + Effort: req.Effort, + PermissionMode: req.PermissionMode, + Cwd: req.Cwd, + Overridden: claudeStrings(req.Overridden), + }) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, mapClaudeSettings(settings)) +} + +// ClaudeInvoke 执行同名动作,或返回去终端改 Claude 配置的提示。 +func (a *API) ClaudeInvoke(w http.ResponseWriter, r *http.Request) { + var req ClaudeInvokeRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + result, err := claude.Invoke(req.SessionID, req.Name, req.Args) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeInvokeResponse{Hint: result.Hint}) +} + +// ClaudeMention 把仓库内文件挂到待发给 Claude Code 的内容上。 +func (a *API) ClaudeMention(w http.ResponseWriter, r *http.Request) { + var req ClaudePathRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + if err := claude.Mention(req.SessionID, req.Path); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeAttachImage 把本地图片挂到待发给 Claude Code 的内容上。 +func (a *API) ClaudeAttachImage(w http.ResponseWriter, r *http.Request) { + var req ClaudePathRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + if err := claude.AttachImage(req.SessionID, req.Path); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeStartTurn 空闲则向 Claude Code 开新一轮;进行中则排队,不打断。 +func (a *API) ClaudeStartTurn(w http.ResponseWriter, r *http.Request) { + var req ClaudeStartTurnRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.SessionID = claudeSessionID(r) + mode := claude.InputModeStart + if req.Mode == string(claude.InputModeQueue) { + mode = claude.InputModeQueue + } + turnID, err := claude.Start(req.SessionID, req.Content, claude.Input{ + Text: req.Input.Text, + Mentions: claudeStrings(req.Input.Mentions), + Images: claudeStrings(req.Input.Images), + }, mode) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeStartTurnResponse{TurnID: turnID}) +} + +// ClaudeHydrate 按本机 Claude 已落下的记录回放。 +func (a *API) ClaudeHydrate(w http.ResponseWriter, r *http.Request) { + items, usage, err := claude.HydrateDetail(claudeSessionID(r)) + if err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, ClaudeTranscriptResponse{ + Items: mapClaudeProgress(items), + Usage: mapClaudeUsage(usage), + }) +} + +// ClaudeCancelTurn 用户手动打断当前一轮,并向 Claude Code 传播取消。 +func (a *API) ClaudeCancelTurn(w http.ResponseWriter, r *http.Request) { + if err := claude.Cancel(claudeTurnID(r)); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeContinueTurn 反问有了结果后让 Claude Code 继续。 +func (a *API) ClaudeContinueTurn(w http.ResponseWriter, r *http.Request) { + if err := claude.Continue(claudeTurnID(r)); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeDecide 按人对已知反问的作答记下结果。 +func (a *API) ClaudeDecide(w http.ResponseWriter, r *http.Request) { + var req ClaudeDecideRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.ApprovalID = claudeApprovalID(r) + if err := claude.Decide(req.ApprovalID, claude.AskAnswer{ + Approved: req.Approved, + Scope: claude.DecisionScope(req.Scope), + Choice: req.Choice, + Values: claudeStrings(req.Values), + }); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +// ClaudeRejectUnknown 官方新加、认不出的提问:提示不兼容并回包。 +func (a *API) ClaudeRejectUnknown(w http.ResponseWriter, r *http.Request) { + var req ClaudeRejectUnknownRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, err) + return + } + req.RequestID = claudeRequestID(r) + if err := claude.AppendProgress(req.SessionID, req.TurnID, claude.Progress{Kind: claude.ProgressKindNotice}); err != nil { + writeError(w, err) + return + } + if err := claude.RejectUnknown(req.RequestID); err != nil { + writeError(w, err) + return + } + if err := claude.Continue(req.TurnID); err != nil { + writeError(w, err) + return + } + writeJSON(w, http.StatusOK, claudeOK()) +} + +func claudeStrings(values []string) []string { + if values == nil { + return []string{} + } + return values +} + +func mapClaudeSession(sess claude.Session) ClaudeSession { + return ClaudeSession{ + ID: sess.ID, + ClaudeSessionID: sess.ClaudeSessionID, + Title: sess.Title, + ActiveTurnID: sess.ActiveTurnID, + Archived: sess.Archived, + CreatedAt: sess.CreatedAt, + UpdatedAt: sess.UpdatedAt, + } +} + +func mapClaudeSessions(items []claude.Session) []ClaudeSession { + out := make([]ClaudeSession, 0, len(items)) + for _, item := range items { + out = append(out, mapClaudeSession(item)) + } + return out +} + +func mapClaudeModels(items []claude.ModelInfo) []ClaudeModel { + out := make([]ClaudeModel, 0, len(items)) + for _, item := range items { + out = append(out, ClaudeModel{ + ID: item.ID, + Efforts: claudeStrings(item.Efforts), + DefaultEffort: item.DefaultEffort, + Hidden: item.Hidden, + IsDefault: item.IsDefault, + }) + } + return out +} + +func mapClaudeModes(items []claude.ModeInfo) []ClaudeMode { + out := make([]ClaudeMode, 0, len(items)) + for _, item := range items { + out = append(out, ClaudeMode{ID: item.ID, Kind: item.Kind}) + } + return out +} + +func mapClaudeCommands(items []claude.CommandSpec) []ClaudeCommand { + out := make([]ClaudeCommand, 0, len(items)) + for _, item := range items { + out = append(out, ClaudeCommand{Name: item.Name, Action: string(item.Action), Hint: item.Hint}) + } + return out +} + +func mapClaudeSettings(settings claude.Settings) ClaudeSettingsResponse { + return ClaudeSettingsResponse{ + Model: settings.Model, + Effort: settings.Effort, + PermissionMode: settings.PermissionMode, + Cwd: settings.Cwd, + Overridden: claudeStrings(settings.Overridden), + } +} + +func mapClaudeProgress(items []claude.Progress) []ClaudeProgress { + out := make([]ClaudeProgress, 0, len(items)) + for _, item := range items { + out = append(out, ClaudeProgress{ + Kind: string(item.Kind), + Text: item.Text, + Command: item.Command, + Paths: claudeStrings(item.Paths), + Diff: item.Diff, + }) + } + return out +} + +// mapClaudeUsage 只在官方实录里有用量时带上 used / window。 +func mapClaudeUsage(usage claude.TokenUsage) *ClaudeTokenUsage { + if usage.Used <= 0 || usage.Window <= 0 { + return nil + } + return &ClaudeTokenUsage{Used: usage.Used, Window: usage.Window} +} diff --git a/server/internal/handler/claude_test.go b/server/internal/handler/claude_test.go new file mode 100644 index 0000000..4e271b2 --- /dev/null +++ b/server/internal/handler/claude_test.go @@ -0,0 +1,280 @@ +package handler_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "unicode" + + "github.com/go-chi/chi/v5" + + "codedock/internal/config" + "codedock/internal/handler" + pkgagent "codedock/pkg/agent" +) + +var ( + fakeClaudeOnce sync.Once + fakeClaudePath string + fakeClaudeErr error +) + +func fakeClaudeBin(t *testing.T) string { + t.Helper() + fakeClaudeOnce.Do(func() { + dir, err := os.MkdirTemp("", "handler-fake-claude") + if err != nil { + fakeClaudeErr = err + return + } + out := filepath.Join(dir, "claude") + _, file, _, ok := runtime.Caller(0) + if !ok { + fakeClaudeErr = os.ErrNotExist + return + } + src := filepath.Join(filepath.Dir(file), "..", "..", "pkg", "claude", "testdata", "fakeclaude") + cmd := exec.Command("go", "build", "-o", out, src) + if body, err := cmd.CombinedOutput(); err != nil { + fakeClaudeErr = err + t.Logf("build fake claude: %s", body) + return + } + fakeClaudePath = out + }) + if fakeClaudeErr != nil { + t.Fatalf("build fake claude: %v", fakeClaudeErr) + } + return fakeClaudePath +} + +func newClaudeAPI(t *testing.T) *handler.API { + t.Helper() + return handler.New(nil, nil, nil, nil, pkgagent.RunConfigSnapshot{}, config.Load(), nil) +} + +func claudeRouter(api *handler.API) http.Handler { + r := chi.NewRouter() + r.Get("/claude/status", api.ClaudeProbe) + r.Get("/claude/models", api.ClaudeListModels) + r.Get("/claude/modes", api.ClaudeListModes) + r.Get("/claude/commands", api.ClaudeListCommands) + r.Get("/claude/sessions", api.ClaudeListSessions) + r.Post("/claude/sessions", api.ClaudeCreateSession) + r.Get("/claude/sessions/{session_id}", api.ClaudeGetSession) + r.Patch("/claude/sessions/{session_id}", api.ClaudeRenameSession) + r.Post("/claude/sessions/{session_id}/archive", api.ClaudeArchiveSession) + r.Post("/claude/sessions/{session_id}/fork", api.ClaudeForkSession) + r.Get("/claude/sessions/{session_id}/settings", api.ClaudeEffective) + r.Post("/claude/sessions/{session_id}/settings", api.ClaudeApplySettings) + r.Post("/claude/sessions/{session_id}/commands", api.ClaudeInvoke) + r.Post("/claude/sessions/{session_id}/mentions", api.ClaudeMention) + r.Post("/claude/sessions/{session_id}/images", api.ClaudeAttachImage) + r.Post("/claude/sessions/{session_id}/turns", api.ClaudeStartTurn) + r.Get("/claude/sessions/{session_id}/transcript", api.ClaudeHydrate) + r.Post("/claude/turns/{turn_id}/cancel", api.ClaudeCancelTurn) + r.Post("/claude/turns/{turn_id}/continue", api.ClaudeContinueTurn) + r.Post("/claude/approvals/{approval_id}/decision", api.ClaudeDecide) + r.Post("/claude/asks/{request_id}/reject-unknown", api.ClaudeRejectUnknown) + return r +} + +func doClaude(t *testing.T, router http.Handler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body == "" { + req = httptest.NewRequest(method, path, nil) + } else { + req = httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s %s status %d body %s", method, path, rec.Code, rec.Body.String()) + } + return rec +} + +func decodeClaude(t *testing.T, rec *httptest.ResponseRecorder, dest any) { + t.Helper() + dec := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())) + dec.DisallowUnknownFields() + if err := dec.Decode(dest); err != nil { + t.Fatalf("decode %T: %v body %s", dest, err, rec.Body.String()) + } +} + +func assertJSONKeys(t *testing.T, v any, want ...string) { + t.Helper() + raw, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if len(got) != len(want) { + t.Fatalf("%T keys %v want %v json %s", v, got, want, raw) + } + for _, key := range want { + if _, ok := got[key]; !ok { + t.Fatalf("%T missing %q in %s", v, key, raw) + } + } +} + +func TestClaudeHTTPContracts(t *testing.T) { + assertJSONKeys(t, handler.ClaudeCreateSessionRequest{}, "user_id") + assertJSONKeys(t, handler.ClaudeRenameSessionRequest{}, "title") + assertJSONKeys(t, handler.ClaudeInvokeRequest{}, "name", "args") + assertJSONKeys(t, handler.ClaudePathRequest{}, "path") + assertJSONKeys(t, handler.ClaudeInput{}, "text", "mentions", "images") + assertJSONKeys(t, handler.ClaudeStartTurnRequest{}, "content", "input", "mode") + assertJSONKeys(t, handler.ClaudeApplySettingsRequest{}, "model", "effort", "permission_mode", "cwd", "overridden") + assertJSONKeys(t, handler.ClaudeDecideRequest{}, "approved", "scope", "choice", "values") + assertJSONKeys(t, handler.ClaudeRejectUnknownRequest{}, "session_id", "turn_id") + + assertJSONKeys(t, handler.ClaudeStatusResponse{}, "available", "authorized", "version", "hint") + assertJSONKeys(t, handler.ClaudeModel{}, "id", "efforts", "default_effort", "hidden", "is_default") + assertJSONKeys(t, handler.ClaudeListModelsResponse{}, "models") + assertJSONKeys(t, handler.ClaudeMode{}, "id", "kind") + assertJSONKeys(t, handler.ClaudeListModesResponse{}, "modes") + assertJSONKeys(t, handler.ClaudeCommand{}, "name", "action", "hint") + assertJSONKeys(t, handler.ClaudeListCommandsResponse{}, "commands") + assertJSONKeys(t, handler.ClaudeSession{}, "id", "claude_session_id", "title", "active_turn_id", "archived", "created_at", "updated_at") + assertJSONKeys(t, handler.ClaudeSessionResponse{}, "session") + assertJSONKeys(t, handler.ClaudeListSessionsResponse{}, "sessions") + assertJSONKeys(t, handler.ClaudeSettingsResponse{}, "model", "effort", "permission_mode", "cwd", "overridden") + assertJSONKeys(t, handler.ClaudeInvokeResponse{}, "hint") + assertJSONKeys(t, handler.ClaudeStartTurnResponse{}, "turn_id") + assertJSONKeys(t, handler.ClaudeProgress{}, "kind", "text", "command", "paths", "diff") + assertJSONKeys(t, handler.ClaudeTranscriptResponse{}, "items") + assertJSONKeys(t, handler.ClaudeOKResponse{}, "ok") +} + +func TestClaudeSkeletonHTTP(t *testing.T) { + t.Setenv("CLAUDE_BIN", fakeClaudeBin(t)) + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + t.Setenv("GIT_REPO", t.TempDir()) + t.Setenv("FAKE_CLAUDE_AUTH", "1") + router := claudeRouter(newClaudeAPI(t)) + + var status handler.ClaudeStatusResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/status", ""), &status) + + var models handler.ClaudeListModelsResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/models", ""), &models) + if models.Models == nil { + t.Fatal("models must be []") + } + + var modes handler.ClaudeListModesResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/modes", ""), &modes) + if modes.Modes == nil { + t.Fatal("modes must be []") + } + + var commands handler.ClaudeListCommandsResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/commands", ""), &commands) + if commands.Commands == nil { + t.Fatal("commands must be []") + } + + var sessions handler.ClaudeListSessionsResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/sessions", ""), &sessions) + if sessions.Sessions == nil { + t.Fatal("sessions must be []") + } + + var created handler.ClaudeSessionResponse + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions", `{"user_id":"local"}`), &created) + + var got handler.ClaudeSessionResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/sessions/s1", ""), &got) + + var ok handler.ClaudeOKResponse + decodeClaude(t, doClaude(t, router, http.MethodPatch, "/claude/sessions/s1", `{"title":"t"}`), &ok) + if !ok.OK { + t.Fatal("ok") + } + + var settings handler.ClaudeSettingsResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/sessions/s1/settings", ""), &settings) + if settings.Overridden == nil { + t.Fatal("overridden must be []") + } + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/settings", `{"model":"opus","effort":"","permission_mode":"","cwd":"","overridden":[]}`), &settings) + + var invoked handler.ClaudeInvokeResponse + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/commands", `{"name":"mcp","args":""}`), &invoked) + + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/mentions", `{"path":"a.go"}`), &ok) + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/images", `{"path":"a.png"}`), &ok) + + var started handler.ClaudeStartTurnResponse + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/turns", `{"content":"hi","input":{"text":"hi","mentions":[],"images":[]},"mode":"start"}`), &started) + + var transcript handler.ClaudeTranscriptResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/sessions/s1/transcript", ""), &transcript) + if transcript.Items == nil { + t.Fatal("items must be []") + } + + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/turns/t1/cancel", ""), &ok) + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/turns/t1/continue", ""), &ok) + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/approvals/a1/decision", `{"approved":true,"scope":"once","choice":"","values":[]}`), &ok) + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/asks/r1/reject-unknown", `{"session_id":"s1","turn_id":"t1"}`), &ok) + + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/archive", ""), &ok) + + var live handler.ClaudeSessionResponse + decodeClaude(t, doClaude(t, router, http.MethodGet, "/claude/sessions/s1", ""), &live) + resumeID := live.Session.ClaudeSessionID + if resumeID == "" { + resumeID = live.Session.ID + } + if resumeID == "" { + resumeID = "s1" + } + writeClaudeJSONL(t, resumeID, `{"type":"user","sessionId":"`+resumeID+`","message":{"content":"hi"}}`+"\n") + decodeClaude(t, doClaude(t, router, http.MethodPost, "/claude/sessions/s1/fork", ""), &created) + if created.Session.ID == "" || created.Session.ID == "s1" || created.Session.ID == resumeID { + t.Fatalf("fork %+v", created) + } +} + +// writeClaudeJSONL 把一条 Claude 实录写进当前测试的 projects 目录。 +func writeClaudeJSONL(t *testing.T, id, body string) { + t.Helper() + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + cwd := os.Getenv("GIT_REPO") + abs, err := filepath.Abs(cwd) + if err != nil { + abs = cwd + } + var name strings.Builder + for _, r := range abs { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + name.WriteRune(r) + } else { + name.WriteByte('-') + } + } + dir := filepath.Join(cfg, "projects", name.String()) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/server/pkg/claude/approval.go b/server/pkg/claude/approval.go new file mode 100644 index 0000000..7368069 --- /dev/null +++ b/server/pkg/claude/approval.go @@ -0,0 +1,32 @@ +package claude + +import "github.com/google/uuid" + +// Require 登记一条 Claude Code 已知的反问。 +func Require(turnID string, ask ApprovalAsk) (string, error) { + id := ask.ExternalRequestID + if id == "" { + id = uuid.NewString() + } + ask.ExternalRequestID = id + rt.mu.Lock() + defer rt.mu.Unlock() + rt.asks[id] = &memAsk{ID: id, TurnID: turnID, Ask: ask} + if turn, ok := rt.turns[turnID]; ok { + turn.Status = TurnWaitingApproval + } + return id, nil +} + +// Decide 按人对已知反问的作答记下结果。 +func Decide(approvalID string, answer AskAnswer) error { + if err := ReplyAsk(approvalID, answer); err != nil { + return err + } + return Continue("") +} + +// Expire 过期按拒绝回给 Claude Code,避免死等。 +func Expire(approvalID string) error { + return ReplyAsk(approvalID, AskAnswer{Approved: false}) +} diff --git a/server/pkg/claude/attachment.go b/server/pkg/claude/attachment.go new file mode 100644 index 0000000..53e3f3e --- /dev/null +++ b/server/pkg/claude/attachment.go @@ -0,0 +1,75 @@ +package claude + +// Mention 把仓库内文件挂到待发给 Claude Code 的内容上。 +func Mention(sessionID, path string) error { + if sessionID == "" { + return wrapErr(errInvalid, "session_id is required") + } + if path == "" { + return wrapErr(errInvalid, "path is required") + } + if _, err := Get(sessionID); err != nil { + return err + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(sessionID) + sess.Draft.Mentions = append(sess.Draft.Mentions, path) + return nil +} + +// AttachImage 把本地图片挂到待发给 Claude Code 的内容上。 +func AttachImage(sessionID, path string) error { + if sessionID == "" { + return wrapErr(errInvalid, "session_id is required") + } + if path == "" { + return wrapErr(errInvalid, "path is required") + } + if _, err := Get(sessionID); err != nil { + return err + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(sessionID) + sess.Draft.Images = append(sess.Draft.Images, path) + return nil +} + +// TakeDraft 取出本条附件草稿并清空。 +func TakeDraft(sessionID string) (Input, error) { + if sessionID == "" { + return Input{Mentions: emptyStrings(), Images: emptyStrings()}, wrapErr(errInvalid, "session_id is required") + } + if _, err := Get(sessionID); err != nil { + return Input{Mentions: emptyStrings(), Images: emptyStrings()}, err + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(sessionID) + draft := sess.Draft + sess.Draft = Input{Mentions: emptyStrings(), Images: emptyStrings()} + if draft.Mentions == nil { + draft.Mentions = emptyStrings() + } + if draft.Images == nil { + draft.Images = emptyStrings() + } + return draft, nil +} + +func mergeInput(content string, input, draft Input) Input { + out := input + if out.Text == "" { + out.Text = content + } + out.Mentions = append(append([]string{}, draft.Mentions...), input.Mentions...) + out.Images = append(append([]string{}, draft.Images...), input.Images...) + if out.Mentions == nil { + out.Mentions = emptyStrings() + } + if out.Images == nil { + out.Images = emptyStrings() + } + return out +} diff --git a/server/pkg/claude/catalog.go b/server/pkg/claude/catalog.go new file mode 100644 index 0000000..1674d7b --- /dev/null +++ b/server/pkg/claude/catalog.go @@ -0,0 +1,18 @@ +package claude + +// 本文件管本机有没有 Claude Code、是否已取得 Claude 授权、允许选哪些模型与权限档。不管对话,不管开回合。 + +// Probe 查看本机 Claude Code 是否可用、是否已取得授权。 +func Probe() (EngineStatus, error) { + return ReadEngine() +} + +// ListModels 列出 Claude Code 模型及各自支持的推理强度。 +func ListModels() ([]ModelInfo, error) { + return ReadModels() +} + +// ListModes 列出 Claude Code 的官方权限档。 +func ListModes() ([]ModeInfo, error) { + return ReadModes() +} diff --git a/server/pkg/claude/claude_live_test.go b/server/pkg/claude/claude_live_test.go new file mode 100644 index 0000000..0e4faa6 --- /dev/null +++ b/server/pkg/claude/claude_live_test.go @@ -0,0 +1,95 @@ +package claude + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + "time" +) + +func TestLiveProbeAndTurn(t *testing.T) { + unsetForTest(t, "CLAUDE_CONFIG_DIR") + unsetForTest(t, "CLAUDE_BIN") + bin, err := exec.LookPath("claude") + if err != nil { + t.Skip("claude not installed") + } + auth := exec.Command(bin, "auth", "status") + if out, err := auth.CombinedOutput(); err != nil { + t.Skipf("claude not authorized: %v %s", err, out) + } + resetRuntime() + t.Cleanup(resetRuntime) + t.Setenv("CLAUDE_BIN", bin) + repo := t.TempDir() + t.Setenv("GIT_REPO", repo) + t.Cleanup(func() { + home, err := os.UserHomeDir() + if err != nil { + return + } + _ = os.RemoveAll(filepath.Join(home, ".claude", "projects", sanitizeProject(repo))) + }) + status, err := Probe() + if err != nil { + t.Fatal(err) + } + if !status.Available || !status.Authorized { + t.Fatalf("%+v", status) + } + sess, err := Create("live") + if err != nil { + t.Fatal(err) + } + if _, err := Apply(sess.ID, Settings{PermissionMode: "dontAsk"}); err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "Reply with the single word pong.", Input{Text: "Reply with the single word pong.", Mentions: emptyStrings(), Images: emptyStrings()}, InputModeStart) + if err != nil { + t.Fatal(err) + } + if turnID == "" { + t.Fatal("turn id") + } + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + got, err := Get(sess.ID) + if err != nil { + t.Fatal(err) + } + if got.ActiveTurnID == "" { + break + } + time.Sleep(200 * time.Millisecond) + } + got, err := Get(sess.ID) + if err != nil { + t.Fatal(err) + } + if got.ClaudeSessionID == "" { + t.Fatal("missing claude session") + } + items, err := Hydrate(sess.ID) + if err != nil { + t.Fatal(err) + } + if items == nil { + t.Fatal("items") + } +} + +func unsetForTest(t *testing.T, key string) { + t.Helper() + orig, had := os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if had { + _ = os.Setenv(key, orig) + return + } + _ = os.Unsetenv(key) + }) +} diff --git a/server/pkg/claude/claude_test.go b/server/pkg/claude/claude_test.go new file mode 100644 index 0000000..ab8422d --- /dev/null +++ b/server/pkg/claude/claude_test.go @@ -0,0 +1,606 @@ +package claude + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + goruntime "runtime" + "strings" + "sync" + "testing" + "time" +) + +var ( + fakeBinOnce sync.Once + fakeBinPath string + fakeBinErr error +) + +func fakeClaude(t *testing.T) string { + t.Helper() + fakeBinOnce.Do(func() { + dir, err := os.MkdirTemp("", "fake-claude-bin") + if err != nil { + fakeBinErr = err + return + } + out := filepath.Join(dir, "claude") + _, file, _, ok := goruntime.Caller(0) + if !ok { + fakeBinErr = errUnavailable + return + } + src := filepath.Join(filepath.Dir(file), "testdata", "fakeclaude") + cmd := exec.Command("go", "build", "-o", out, src) + cmd.Dir = filepath.Dir(file) + if body, err := cmd.CombinedOutput(); err != nil { + fakeBinErr = err + t.Logf("build fake: %s", body) + return + } + fakeBinPath = out + }) + if fakeBinErr != nil { + t.Fatalf("build fake claude: %v", fakeBinErr) + } + return fakeBinPath +} + +func testEnv(t *testing.T) { + t.Helper() + resetRuntime() + t.Cleanup(resetRuntime) + t.Setenv("CLAUDE_BIN", fakeClaude(t)) + t.Setenv("CLAUDE_CONFIG_DIR", t.TempDir()) + t.Setenv("GIT_REPO", t.TempDir()) + t.Setenv("FAKE_CLAUDE_MODE", "") + t.Setenv("FAKE_CLAUDE_AUTH", "1") + t.Setenv("FAKE_CLAUDE_SLEEP", "") + t.Setenv("FAKE_CLAUDE_LOG", "") +} + +func waitIdle(t *testing.T, sessionID string) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + sess, err := Get(sessionID) + if err != nil { + t.Fatal(err) + } + if sess.ActiveTurnID == "" { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("turn still active") +} + +func waitAsk(t *testing.T, turnID string) { + t.Helper() + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + rt.mu.Lock() + turn := rt.turns[turnID] + status := TurnStatus("") + if turn != nil { + status = turn.Status + } + rt.mu.Unlock() + if status == TurnWaitingApproval { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("not waiting approval") +} + +func TestProbeMissingBinary(t *testing.T) { + resetRuntime() + t.Setenv("CLAUDE_BIN", filepath.Join(t.TempDir(), "nope")) + status, err := Probe() + if err != nil { + t.Fatal(err) + } + if status.Available { + t.Fatal("available") + } + if status.Hint == "" { + t.Fatal("hint") + } +} + +func TestProbeUnauthorized(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_AUTH", "0") + status, err := Probe() + if err != nil { + t.Fatal(err) + } + if !status.Available || status.Authorized { + t.Fatalf("%+v", status) + } +} + +func TestProbeOK(t *testing.T) { + testEnv(t) + status, err := Probe() + if err != nil { + t.Fatal(err) + } + if !status.Available || !status.Authorized || status.Version == "" { + t.Fatalf("%+v", status) + } +} + +func TestCatalog(t *testing.T) { + testEnv(t) + models, err := ListModels() + if err != nil || len(models) == 0 { + t.Fatalf("models %v %v", models, err) + } + modes, err := ListModes() + if err != nil || len(modes) != 6 { + t.Fatalf("modes %v %v", modes, err) + } + cmds := ListCommands() + if len(cmds) < 8 { + t.Fatalf("commands %d", len(cmds)) + } +} + +func TestSessionLifecycle(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil || sess.ID == "" { + t.Fatalf("%+v %v", sess, err) + } + got, err := Get(sess.ID) + if err != nil || got.ID != sess.ID { + t.Fatal(err) + } + if err := Rename(sess.ID, "hello"); err != nil { + t.Fatal(err) + } + got, err = Get(sess.ID) + if err != nil || got.Title != "hello" { + t.Fatalf("%+v", got) + } + if err := Archive(sess.ID); err != nil { + t.Fatal(err) + } + if _, err := Start(sess.ID, "x", Input{Text: "x"}, InputModeStart); err == nil { + t.Fatal("archived start") + } +} + +// writeSessionJSONL 把一条 Claude 实录写进当前测试的 projects 目录。 +func writeSessionJSONL(t *testing.T, id, body string) string { + t.Helper() + dir := filepath.Join(os.Getenv("CLAUDE_CONFIG_DIR"), "projects", sanitizeProject(os.Getenv("GIT_REPO"))) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, id+".jsonl") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestForkAndBind(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + parentID := "11111111-1111-1111-1111-111111111111" + writeSessionJSONL(t, parentID, `{"type":"user","sessionId":"`+parentID+`","message":{"content":"hello from parent"}}`+"\n") + if err := BindClaudeSession(sess.ID, parentID); err != nil { + t.Fatal(err) + } + if err := BindClaudeSession(sess.ID, "22222222-2222-2222-2222-222222222222"); err == nil { + t.Fatal("second bind") + } + child, err := Fork(sess.ID) + if err != nil || child.ID == "" || child.ID == sess.ID || child.ID == parentID { + t.Fatalf("%+v %v", child, err) + } + if !sessionFileExists(child.ID) { + t.Fatal("forked session missing on disk") + } + items, err := ReadTranscript(child.ID) + if err != nil || len(items) == 0 || items[0].Text != "hello from parent" { + t.Fatalf("child transcript %+v %v", items, err) + } + body, err := os.ReadFile(findSessionFile(child.ID)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), child.ID) || strings.Contains(string(body), `"sessionId":"`+parentID+`"`) { + t.Fatalf("session id not rewritten: %s", body) + } + if child.Title != "hello from parent (1)" { + t.Fatalf("fork title %q", child.Title) + } + list, err := ListSessions() + if err != nil || len(list) == 0 { + t.Fatalf("%v %v", list, err) + } +} + +func TestForkTitleSequence(t *testing.T) { + testEnv(t) + parentID := "11111111-1111-1111-1111-111111111111" + writeSessionJSONL(t, parentID, `{"type":"system","subtype":"title","title":"ping"}`+"\n"+`{"type":"user","message":{"content":"ping"}}`+"\n") + first, err := Fork(parentID) + if err != nil || first.Title != "ping (1)" { + t.Fatalf("first %+v %v", first, err) + } + second, err := Fork(parentID) + if err != nil || second.Title != "ping (2)" { + t.Fatalf("second %+v %v", second, err) + } + third, err := Fork(first.ID) + if err != nil || third.Title != "ping (3)" { + t.Fatalf("third %+v %v", third, err) + } + resetRuntime() + got, err := Get(second.ID) + if err != nil || got.Title != "ping (2)" { + t.Fatalf("persist %+v %v", got, err) + } +} + +func TestSettingsApply(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Apply(sess.ID, Settings{Model: "nope"}); err == nil { + t.Fatal("bad model") + } + out, err := Apply(sess.ID, Settings{Model: "opus", Effort: "high", PermissionMode: "plan"}) + if err != nil { + t.Fatal(err) + } + if out.Model != "opus" || out.Effort != "high" || out.PermissionMode != "plan" { + t.Fatalf("%+v", out) + } + if len(out.Overridden) != 3 { + t.Fatalf("overridden %v", out.Overridden) + } + logPath := filepath.Join(t.TempDir(), "args.log") + t.Setenv("FAKE_CLAUDE_LOG", logPath) + if _, err := Start(sess.ID, "hi", Input{Text: "hi"}, InputModeStart); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) + body, _ := os.ReadFile(logPath) + if !strings.Contains(string(body), "--model opus") || !strings.Contains(string(body), "--effort high") { + t.Fatalf("args %s", body) + } +} + +func TestDraftAndStart(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if err := Mention(sess.ID, "a.go"); err != nil { + t.Fatal(err) + } + png := filepath.Join(t.TempDir(), "a.png") + if err := os.WriteFile(png, png1x1, 0o644); err != nil { + t.Fatal(err) + } + if err := AttachImage(sess.ID, png); err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "hi", Input{Text: "hi"}, InputModeStart) + if err != nil || turnID == "" { + t.Fatalf("%s %v", turnID, err) + } + waitIdle(t, sess.ID) + items, err := Hydrate(sess.ID) + if err != nil || items == nil { + t.Fatal(err) + } + draft, err := TakeDraft(sess.ID) + if err != nil || len(draft.Mentions) != 0 { + t.Fatalf("%+v %v", draft, err) + } +} + +func TestQueueCancel(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_MODE", "hang") + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "one", Input{Text: "one"}, InputModeStart) + if err != nil { + t.Fatal(err) + } + queued, err := Start(sess.ID, "two", Input{Text: "two"}, InputModeStart) + if err != nil || queued == "" { + t.Fatal(err) + } + t.Setenv("FAKE_CLAUDE_MODE", "") + if err := Cancel(turnID); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestAskDecide(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_MODE", "ask") + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "run", Input{Text: "run"}, InputModeStart) + if err != nil { + t.Fatal(err) + } + waitAsk(t, turnID) + if err := Decide("req-1", AskAnswer{Approved: true, Scope: DecisionOnce}); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestUnknownAsk(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_MODE", "unknown") + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Start(sess.ID, "x", Input{Text: "x"}, InputModeStart); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestQuestionFormEdit(t *testing.T) { + testEnv(t) + for _, mode := range []string{"question", "form", "form_values", "edit", "multiedit"} { + t.Setenv("FAKE_CLAUDE_MODE", mode) + resetRuntime() + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, mode, Input{Text: mode}, InputModeStart) + if err != nil { + t.Fatal(err) + } + waitAsk(t, turnID) + if err := Decide("req-1", AskAnswer{Approved: true, Choice: "a", Values: []string{"n"}}); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) + } +} + +func TestInvoke(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "mcp", ""); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "model", "sonnet"); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "effort", "low"); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "plan", ""); err != nil { + t.Fatal(err) + } + if err := BindClaudeSession(sess.ID, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "compact", ""); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "review", ""); err != nil { + t.Fatal(err) + } + writeSessionJSONL(t, "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", `{"type":"user","message":{"content":"slash fork"}}`+"\n") + if _, err := Invoke(sess.ID, "fork", ""); err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "branch", ""); err != nil { + t.Fatal(err) + } +} + +func TestHydrateFixture(t *testing.T) { + testEnv(t) + cwd := os.Getenv("GIT_REPO") + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + id := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + dir := filepath.Join(cfg, "projects", sanitizeProject(cwd)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + src := filepath.Join("testdata", "transcript.jsonl") + body, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), body, 0o644); err != nil { + t.Fatal(err) + } + sess, err := ReadSession(id) + if err != nil || sess.Title == "" { + t.Fatalf("%+v %v", sess, err) + } + items, usage, err := HydrateDetail(id) + if err != nil { + t.Fatal(err) + } + kinds := map[ProgressKind]bool{} + for _, item := range items { + kinds[item.Kind] = true + } + for _, kind := range []ProgressKind{ProgressKindUser, ProgressKindText, ProgressKindReasoning, ProgressKindCommand, ProgressKindFileChange, ProgressKindPlan} { + if !kinds[kind] { + t.Fatalf("missing %s in %+v", kind, items) + } + } + if usage.Used != 170 || usage.Window != 200000 { + t.Fatalf("usage %+v", usage) + } +} + +// TestUsageFromLine 按官方公式取 used,[1m] 模型窗口为 1M,零用量行跳过。 +func TestUsageFromLine(t *testing.T) { + got, ok := usageFromLine([]byte(`{"type":"assistant","message":{"model":"claude-sonnet-4-6[1m]","usage":{"input_tokens":10,"cache_creation_input_tokens":2,"cache_read_input_tokens":3,"output_tokens":9}}}`)) + if !ok || got.Used != 15 || got.Window != 1_000_000 { + t.Fatalf("1m %+v %v", got, ok) + } + if _, ok := usageFromLine([]byte(`{"type":"assistant","message":{"usage":{"input_tokens":0,"cache_read_input_tokens":0}}}`)); ok { + t.Fatal("zero usage") + } + if _, ok := usageFromLine([]byte(`{"type":"user","message":{"usage":{"input_tokens":9}}}`)); ok { + t.Fatal("user usage") + } +} + +// TestReadSessionUsesJSONLTimestamps 确认列表时间来自实录行,而不是打开当下。 +func TestReadSessionUsesJSONLTimestamps(t *testing.T) { + testEnv(t) + cwd := os.Getenv("GIT_REPO") + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + id := "cccccccc-cccc-cccc-cccc-cccccccccccc" + dir := filepath.Join(cfg, "projects", sanitizeProject(cwd)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := strings.Join([]string{ + `{"type":"user","timestamp":"2026-01-02T03:04:05.000Z","message":{"content":"old ping"}}`, + `{"type":"assistant","timestamp":"2026-03-04T05:06:07.000Z","message":{"content":[{"type":"text","text":"pong"}]}}`, + }, "\n") + if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(body+"\n"), 0o644); err != nil { + t.Fatal(err) + } + sess, err := ReadSession(id) + if err != nil { + t.Fatal(err) + } + if sess.CreatedAt != time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC).Unix() { + t.Fatalf("created_at %d", sess.CreatedAt) + } + if sess.UpdatedAt != time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC).Unix() { + t.Fatalf("updated_at %d", sess.UpdatedAt) + } + listed, err := ListSessions() + if err != nil { + t.Fatal(err) + } + found := false + for _, item := range listed { + if item.ID == id && item.UpdatedAt == sess.UpdatedAt && item.CreatedAt == sess.CreatedAt { + found = true + break + } + } + if !found { + t.Fatalf("list missing timestamps: %+v", listed) + } +} + +func TestParseAndControl(t *testing.T) { + item, ok := progressFromLine([]byte(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}`)) + if !ok || item.Kind != ProgressKindText { + t.Fatalf("%+v", item) + } + reqID, req, ok := parseControlRequest([]byte(`{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"pwd"}}}`)) + if !ok || reqID != "r1" { + t.Fatal(reqID) + } + ask, known := askFromTool(req) + if !known || ask.Kind != AskKindCommand { + t.Fatalf("%+v", ask) + } + if id := parseResultSessionID("{\"session_id\":\"abc\"}\n"); id != "abc" { + t.Fatal(id) + } + if authLoggedIn(`{"loggedIn":true}`) != true { + t.Fatal("auth") + } + buf := &closeBuffer{} + if err := writeUserMessage(buf, Input{Text: "hi", Mentions: []string{"a.go"}, Images: []string{"/nope.png"}}); err == nil { + t.Fatal("missing image") + } +} + +func TestExpireContinueReject(t *testing.T) { + testEnv(t) + id, err := Require("turn", ApprovalAsk{Kind: AskKindCommand, ExternalRequestID: "exp-1"}) + if err != nil || id != "exp-1" { + t.Fatal(err) + } + if err := Expire("exp-1"); err != nil { + t.Fatal(err) + } + if err := Continue("turn"); err != nil { + t.Fatal(err) + } + if err := RejectUnknown("missing"); err != nil { + t.Fatal(err) + } + if err := Cancel(""); err != nil { + t.Fatal(err) + } + if err := Interrupt("", "missing"); err != nil { + t.Fatal(err) + } +} + +func TestStartTurnExported(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + id, err := StartSession(os.Getenv("GIT_REPO"), Settings{}) + if err != nil || id == "" { + t.Fatal(err) + } + if err := ResumeSession(id); err != nil { + t.Fatal(err) + } + if err := BindClaudeSession(sess.ID, id); err != nil { + t.Fatal(err) + } + turnID, err := StartTurn(id, Input{Text: "hi", Mentions: emptyStrings(), Images: emptyStrings()}, Settings{Cwd: os.Getenv("GIT_REPO")}) + if err != nil || turnID == "" { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +type closeBuffer struct{ bytes.Buffer } + +func (c *closeBuffer) Close() error { return nil } + +var png1x1 = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +} diff --git a/server/pkg/claude/command.go b/server/pkg/claude/command.go new file mode 100644 index 0000000..b463bc3 --- /dev/null +++ b/server/pkg/claude/command.go @@ -0,0 +1,59 @@ +package claude + +// ListCommands 列出与 Claude 斜杠、官方扩展按钮共用的命令。 +func ListCommands() []CommandSpec { + hint := "到终端改 Claude 配置" + return []CommandSpec{ + {Name: "model", Action: CommandApplySettings}, + {Name: "effort", Action: CommandApplySettings}, + {Name: "plan", Action: CommandApplySettings}, + {Name: "compact", Action: CommandTurn}, + {Name: "review", Action: CommandTurn}, + {Name: "fork", Action: CommandSession}, + {Name: "branch", Action: CommandSession}, + {Name: "mcp", Action: CommandHint, Hint: hint}, + {Name: "skills", Action: CommandHint, Hint: hint}, + {Name: "plugins", Action: CommandHint, Hint: hint}, + {Name: "hooks", Action: CommandHint, Hint: hint}, + } +} + +// Invoke 执行同名动作,或返回去终端改 Claude 配置的提示。 +func Invoke(sessionID, name, args string) (CommandResult, error) { + _ = ListCommands() + switch name { + case "model": + _, err := Apply(sessionID, Settings{Model: args}) + return CommandResult{}, err + case "effort": + _, err := Apply(sessionID, Settings{Effort: args}) + return CommandResult{}, err + case "plan": + mode := args + if mode == "" { + mode = "plan" + } + _, err := Apply(sessionID, Settings{PermissionMode: mode}) + return CommandResult{}, err + case "compact": + sess, err := Get(sessionID) + if err != nil { + return CommandResult{}, err + } + return CommandResult{}, Compact(sess.ClaudeSessionID) + case "review": + sess, err := Get(sessionID) + if err != nil { + return CommandResult{}, err + } + return CommandResult{}, Review(sess.ClaudeSessionID) + case "fork", "branch": + _, err := Fork(sessionID) + return CommandResult{}, err + case "mcp", "skills", "plugins", "hooks": + return CommandResult{Hint: "到终端改 Claude 配置"}, nil + default: + _, err := Start(sessionID, args, Input{Text: args, Mentions: emptyStrings(), Images: emptyStrings()}, InputModeStart) + return CommandResult{}, err + } +} diff --git a/server/pkg/claude/coverage_test.go b/server/pkg/claude/coverage_test.go new file mode 100644 index 0000000..971e875 --- /dev/null +++ b/server/pkg/claude/coverage_test.go @@ -0,0 +1,453 @@ +package claude + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestControlOtherAndFail(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_MODE", "other") + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Start(sess.ID, "x", Input{Text: "x"}, InputModeStart); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) + + t.Setenv("FAKE_CLAUDE_MODE", "fail") + resetRuntime() + sess, err = Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Start(sess.ID, "x", Input{Text: "x"}, InputModeStart); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestQueueModeAndResume(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + queued, err := Start(sess.ID, "queued", Input{Text: "queued"}, InputModeQueue) + if err != nil || queued == "" { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "first", Input{Text: "first"}, InputModeStart) + if err != nil || turnID == "" { + t.Fatal(err) + } + waitIdle(t, sess.ID) + time.Sleep(50 * time.Millisecond) + waitIdle(t, sess.ID) + turn2, err := Start(sess.ID, "second", Input{Text: "second"}, InputModeStart) + if err != nil || turn2 == "" { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestStartUnavailable(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_BIN", filepath.Join(t.TempDir(), "missing")) + if _, err := Start(sess.ID, "x", Input{Text: "x"}, InputModeStart); err == nil { + t.Fatal("expected unavailable") + } +} + +func TestClaimConflictAndInterrupt(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_MODE", "hang") + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + turnID, err := Start(sess.ID, "one", Input{Text: "one"}, InputModeStart) + if err != nil { + t.Fatal(err) + } + if err := ClaimActiveTurn(sess.ID, "other"); err == nil { + t.Fatal("expected conflict") + } + got, err := Get(sess.ID) + if err != nil { + t.Fatal(err) + } + if err := Interrupt(got.ClaudeSessionID, ""); err != nil { + t.Fatal(err) + } + t.Setenv("FAKE_CLAUDE_MODE", "") + _ = Cancel(turnID) + waitIdle(t, sess.ID) +} + +func TestInvokeUnknownStartsTurn(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Invoke(sess.ID, "not-a-slash", "hello"); err != nil { + t.Fatal(err) + } + waitIdle(t, sess.ID) +} + +func TestApplyValidationAndCwd(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Apply(sess.ID, Settings{PermissionMode: "nope"}); err == nil { + t.Fatal("bad mode") + } + if _, err := Apply(sess.ID, Settings{Model: "haiku", Effort: "max"}); err == nil { + t.Fatal("bad effort") + } + cwd := t.TempDir() + out, err := Apply(sess.ID, Settings{Cwd: cwd, Model: "haiku", Effort: "low"}) + if err != nil { + t.Fatal(err) + } + if out.Cwd != cwd || out.Model != "haiku" { + t.Fatalf("%+v", out) + } + if _, err := Apply("", Settings{Model: "sonnet"}); err == nil { + t.Fatal("empty session") + } + base, err := Effective("") + if err != nil || base.Model == "" { + t.Fatalf("%+v %v", base, err) + } +} + +func TestSettingsFileAndPaths(t *testing.T) { + testEnv(t) + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + body := `{"model":"opus","permissionMode":"plan","effortLevel":"high"}` + if err := os.WriteFile(filepath.Join(cfg, "settings.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + settings, err := ReadSettings("x") + if err != nil || settings.Model != "opus" || settings.PermissionMode != "plan" || settings.Effort != "high" { + t.Fatalf("%+v %v", settings, err) + } + _ = os.WriteFile(filepath.Join(cfg, "settings.json"), []byte(`{"defaultMode":"auto","model":"haiku"}`), 0o644) + settings, err = ReadSettings("") + if err != nil || settings.PermissionMode != "auto" || settings.Model != "haiku" { + t.Fatalf("%+v %v", settings, err) + } + _ = os.WriteFile(filepath.Join(cfg, "settings.json"), []byte(`{`), 0o644) + settings, err = ReadSettings("") + if err != nil || settings.Model != "sonnet" { + t.Fatalf("%+v %v", settings, err) + } + long := strings.Repeat("a", 220) + if name := sanitizeProject("/tmp/" + long); len(name) <= 200 { + t.Fatalf("long name %d", len(name)) + } + t.Setenv("CLAUDE_CONFIG_DIR", "") + t.Setenv("GIT_REPO", "") + if dir := configDir(); dir == "" { + t.Fatal("home config") + } + if cwd := defaultCwd(); cwd == "" { + t.Fatal("cwd") + } +} + +func TestAuthAndParseMore(t *testing.T) { + if !authLoggedIn(`{"status":"logged_in"}`) { + t.Fatal("status") + } + if !authLoggedIn(`{"authenticated":"true"}`) { + t.Fatal("authenticated") + } + if !authLoggedIn("logged in as user") { + t.Fatal("text") + } + if authLoggedIn("{") || authLoggedIn("") || authLoggedIn(`{"loggedIn":false}`) { + t.Fatal("false") + } + if wrapErr(errInvalid, "") != errInvalid { + t.Fatal("wrap empty") + } + if forkTitleStem("ping (2)") != "ping" || forkTitleStem("ping") != "ping" { + t.Fatal("stem") + } + if nextForkTitle("ping", []string{"ping"}) != "ping (1)" { + t.Fatal("first") + } + if nextForkTitle("ping (1)", []string{"ping", "ping (1)", "ping (2)"}) != "ping (3)" { + t.Fatal("next") + } + if nextForkTitle("", nil) != "fork (1)" { + t.Fatal("empty") + } + if parseResultSessionID("") != "" { + t.Fatal("empty result") + } + id := parseResultSessionID("not json\n{\"type\":\"result\",\"session_id\":\"abc\"}\n") + if id != "abc" { + t.Fatal(id) + } + item, ok := progressFromLine([]byte("not-json")) + if ok { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"user","message":"hi there"}`)) + if !ok || item.Kind != ProgressKindUser { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"assistant","message":{"content":{"type":"text","text":"one"}}}`)) + if !ok || item.Text != "one" { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"assistant","message":{"content":[{"type":"thinking","text":"why"}]}}`)) + if !ok || item.Kind != ProgressKindReasoning { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"MultiEdit","input":{"file_path":"b.go","new_string":"z"}}]}}`)) + if !ok || item.Kind != ProgressKindFileChange || len(item.Paths) != 1 { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TodoWrite","input":{}}]}}`)) + if !ok || item.Kind != ProgressKindPlan { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"OtherTool","input":{}}]}}`)) + if !ok || item.Command != "OtherTool" { + t.Fatalf("%+v", item) + } + item, ok = progressFromLine([]byte(`{"type":"system","subtype":"permission_denied","result":"no"}`)) + if !ok || item.Kind != ProgressKindNotice { + t.Fatalf("%+v", item) + } + if titleFromLine([]byte(`{"title":"T"}`)) != "T" { + t.Fatal("title") + } + _, req, ok := parseControlRequest([]byte(`{"type":"user"}`)) + if ok { + t.Fatalf("%+v", req) + } + _, _, ok = parseControlRequest([]byte(`{"type":"control_request","request_id":"x","request":"nope"}`)) + if ok { + t.Fatal("bad request") + } + ask, known := askFromTool(controlRequest{ToolName: "Write", Input: []byte(`{"file_path":"a.go"}`)}) + if !known || ask.Kind != AskKindFileChange { + t.Fatalf("%+v", ask) + } + ask, known = askFromTool(controlRequest{ToolName: "AskUserQuestion", Input: []byte(`{"question":"q","options":["a"]}`)}) + if !known || len(ask.Options) != 1 { + t.Fatalf("%+v", ask) + } + if stringField(nil, "x") != "" || stringList(nil, "x") != nil && len(stringList(map[string]any{}, "missing")) != 0 { + t.Fatal("empty fields") + } + if _, err := imagePart(filepath.Join(t.TempDir(), "no.jpg")); err == nil { + t.Fatal("missing image") + } + png := filepath.Join(t.TempDir(), "a.webp") + if err := os.WriteFile(png, png1x1, 0o644); err != nil { + t.Fatal(err) + } + part, err := imagePart(png) + if err != nil || part["type"] != "image" { + t.Fatal(err) + } + gif := filepath.Join(t.TempDir(), "a.gif") + if err := os.WriteFile(gif, png1x1, 0o644); err != nil { + t.Fatal(err) + } + if _, err := imagePart(gif); err != nil { + t.Fatal(err) + } + jpg := filepath.Join(t.TempDir(), "a.jpeg") + if err := os.WriteFile(jpg, png1x1, 0o644); err != nil { + t.Fatal(err) + } + if _, err := imagePart(jpg); err != nil { + t.Fatal(err) + } +} + +func TestAttachmentErrors(t *testing.T) { + testEnv(t) + if err := Mention("", "a.go"); err == nil { + t.Fatal("mention session") + } + if err := Mention("s", ""); err == nil { + t.Fatal("mention path") + } + if err := AttachImage("", "a.png"); err == nil { + t.Fatal("image session") + } + if err := AttachImage("s", ""); err == nil { + t.Fatal("image path") + } + if _, err := TakeDraft(""); err == nil { + t.Fatal("draft") + } + if _, err := Hydrate(""); err == nil { + t.Fatal("hydrate") + } + if _, err := Get(""); err == nil { + t.Fatal("get") + } + if err := BindClaudeSession("", "x"); err == nil { + t.Fatal("bind") + } + if err := ResumeSession(""); err == nil { + t.Fatal("resume") + } + if err := Compact(""); err == nil { + t.Fatal("compact") + } + if err := Review(""); err == nil { + t.Fatal("review") + } + merged := mergeInput("t", Input{}, Input{Mentions: nil, Images: nil}) + if merged.Text != "t" || merged.Mentions == nil || merged.Images == nil { + t.Fatalf("%+v", merged) + } +} + +func TestTimeoutAndLookPath(t *testing.T) { + testEnv(t) + t.Setenv("FAKE_CLAUDE_SLEEP", "300ms") + t.Setenv("CLAUDE_TIMEOUT", "50ms") + out := runClaude("", "-v") + if out.err == nil { + t.Fatal("expected timeout") + } + t.Setenv("FAKE_CLAUDE_SLEEP", "") + t.Setenv("CLAUDE_TIMEOUT", "") + t.Setenv("CLAUDE_BIN", "claude") + t.Setenv("PATH", t.TempDir()) + if err := lookClaude(); err == nil { + t.Fatal("look path") + } + if err := signalInterrupt(nil); err != nil { + t.Fatal(err) + } + _ = claudeErr(claudeOutput{err: errUnavailable, stderr: " ", stdout: "boom"}) + _ = claudeErr(claudeOutput{err: nil}) +} + +func TestStartTurnNoSessionAndWriteNil(t *testing.T) { + testEnv(t) + if err := writeUserMessage(nil, Input{Text: "x"}); err != nil { + t.Fatal(err) + } + turnID, err := StartTurn("", Input{Text: "hi", Mentions: emptyStrings(), Images: emptyStrings()}, Settings{Cwd: os.Getenv("GIT_REPO")}) + if err != nil || turnID == "" { + t.Fatal(err) + } + deadline := time.Now().Add(8 * time.Second) + for time.Now().Before(deadline) { + rt.mu.Lock() + turn := rt.turns[turnID] + status := TurnStatus("") + if turn != nil { + status = turn.Status + } + rt.mu.Unlock() + if status == TurnCompleted || status == TurnFailed || status == TurnCancelled { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("turn") +} + +func TestReplyAskWithoutWaiter(t *testing.T) { + testEnv(t) + if err := ReplyAsk("", AskAnswer{}); err != nil { + t.Fatal(err) + } + if err := ReplyAsk("missing", AskAnswer{Approved: true}); err != nil { + t.Fatal(err) + } + id, err := Require("", ApprovalAsk{Kind: AskKindCommand}) + if err != nil || id == "" { + t.Fatal(err) + } + if err := Decide(id, AskAnswer{Approved: false}); err != nil { + t.Fatal(err) + } +} + +func TestForkWithoutBind(t *testing.T) { + testEnv(t) + sess, err := Create("local") + if err != nil { + t.Fatal(err) + } + if _, err := Fork(sess.ID); err == nil { + t.Fatal("fork without transcript") + } + if _, err := ForkSession(""); err == nil { + t.Fatal("empty fork") + } + id, err := StartSession("", Settings{}) + if err != nil || id == "" { + t.Fatal(err) + } +} + +func TestListSessionsFromDisk(t *testing.T) { + testEnv(t) + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + cwd := os.Getenv("GIT_REPO") + dir := filepath.Join(cfg, "projects", sanitizeProject(cwd)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + id := "cccccccc-cccc-cccc-cccc-cccccccccccc" + if err := os.WriteFile(filepath.Join(dir, id+".jsonl"), []byte(`{"type":"user","message":{"content":"hello world from disk"}}`), 0o644); err != nil { + t.Fatal(err) + } + list, err := ListSessions() + if err != nil { + t.Fatal(err) + } + found := false + for _, sess := range list { + if sess.ID == id || sess.ClaudeSessionID == id { + found = true + } + } + if !found { + t.Fatalf("%+v", list) + } + items, err := Hydrate(id) + if err != nil || len(items) == 0 { + t.Fatalf("%v %v", items, err) + } +} + +func TestAddOverrideIdempotent(t *testing.T) { + names := addOverride(nil, "model") + names = addOverride(names, "model") + if len(names) != 1 { + t.Fatal(names) + } + if knownModel(nil, "sonnet") || knownMode(nil, "plan") || knownEffort(nil, "sonnet", "low") { + t.Fatal("empty catalogs") + } +} diff --git a/server/pkg/claude/engine.go b/server/pkg/claude/engine.go new file mode 100644 index 0000000..7856910 --- /dev/null +++ b/server/pkg/claude/engine.go @@ -0,0 +1,474 @@ +package claude + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +func turnSettingsArgs(settings Settings) []string { + var args []string + over := map[string]bool{} + for _, name := range settings.Overridden { + over[name] = true + } + if over["model"] && settings.Model != "" { + args = append(args, "--model", settings.Model) + } + if over["effort"] && settings.Effort != "" { + args = append(args, "--effort", settings.Effort) + } + if over["permission_mode"] && settings.PermissionMode != "" { + args = append(args, "--permission-mode", settings.PermissionMode) + } + return args +} + +func printArgs(prompt string, extra ...string) []string { + args := []string{"-p", "--bare", "--output-format", "json"} + args = append(args, extra...) + if prompt != "" { + args = append(args, prompt) + } + return args +} + +func runPrint(cwd, prompt string, extra ...string) (string, error) { + out := runClaude(cwd, printArgs(prompt, extra...)...) + if out.err != nil { + return "", claudeErr(out) + } + return out.stdout, nil +} + +// StartSession 让 Claude Code 新建一条 session。 +func StartSession(cwd string, settings Settings) (string, error) { + _ = settings + if strings.TrimSpace(cwd) == "" { + cwd = defaultCwd() + } + id := uuid.NewString() + _ = cwd + return id, nil +} + +// ResumeSession 接上已有的 Claude session。 +func ResumeSession(claudeSessionID string) error { + if claudeSessionID == "" { + return wrapErr(errInvalid, "claude session id is required") + } + return nil +} + +// ForkSession 按官方 --resume --fork-session:复制已落盘实录,换新 session ID,不发模型。 +func ForkSession(claudeSessionID string) (string, error) { + if claudeSessionID == "" { + return "", wrapErr(errInvalid, "claude session id is required") + } + src := findSessionFile(claudeSessionID) + if src == "" { + return "", wrapErr(errNotFound, "no conversation found with session ID: %s", claudeSessionID) + } + return copyForkedSession(src) +} + +// StartTurn 让 Claude Code 开始一轮。 +func StartTurn(claudeSessionID string, input Input, settings Settings) (string, error) { + turnID := uuid.NewString() + sessionID := "" + if found := sessionByClaudeID(claudeSessionID); found != nil { + sessionID = found.ID + } + if err := startTurn(turnID, sessionID, claudeSessionID, input, settings); err != nil { + return "", err + } + return turnID, nil +} + +func startTurn(turnID, sessionID, claudeSessionID string, input Input, settings Settings) error { + cwd := settings.Cwd + if cwd == "" { + cwd = defaultCwd() + } + args := []string{ + "-p", "--bare", + "--output-format", "stream-json", + "--input-format", "stream-json", + "--verbose", + "--permission-prompt-tool", "stdio", + } + args = append(args, turnSettingsArgs(settings)...) + if claudeSessionID != "" { + if sessionFileExists(claudeSessionID) { + args = append(args, "--resume", claudeSessionID) + } else { + args = append(args, "--session-id", claudeSessionID) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cmd, stdin, stdout, err := startClaude(cwd, args) + if err != nil { + cancel() + return wrapErr(errUnavailable, "%s", err.Error()) + } + rt.mu.Lock() + rt.turns[turnID] = &memTurn{ + ID: turnID, + SessionID: sessionID, + ClaudeSessionID: claudeSessionID, + Status: TurnRunning, + cmd: cmd, + stdin: stdin, + cancel: cancel, + waiters: map[string]chan AskAnswer{}, + } + if sessionID != "" { + internLocked(sessionID).ActiveTurnID = turnID + } + rt.mu.Unlock() + go func() { + defer cancel() + pumpTurn(ctx, turnID, stdout) + _ = cmd.Wait() + _ = stdin.Close() + finishTurn(turnID) + }() + _ = writeUserMessage(stdin, input) + return nil +} + +func writeUserMessage(stdin io.WriteCloser, input Input) error { + if stdin == nil { + return nil + } + text := input.Text + for _, mention := range input.Mentions { + if mention == "" { + continue + } + text = strings.TrimSpace(text + "\n@" + mention) + } + type part map[string]any + parts := []part{{"type": "text", "text": text}} + for _, image := range input.Images { + block, err := imagePart(image) + if err != nil { + return err + } + parts = append(parts, block) + } + payload := map[string]any{ + "type": "user", + "message": map[string]any{ + "role": "user", + "content": parts, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + _, err = stdin.Write(append(body, '\n')) + return err +} + +func imagePart(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, wrapErr(errInvalid, "%s", err.Error()) + } + media := "image/png" + switch strings.ToLower(filepath.Ext(path)) { + case ".jpg", ".jpeg": + media = "image/jpeg" + case ".gif": + media = "image/gif" + case ".webp": + media = "image/webp" + } + return map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": media, + "data": base64.StdEncoding.EncodeToString(data), + }, + }, nil +} + +func pumpTurn(ctx context.Context, turnID string, stdout io.Reader) { + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + if ctx.Err() != nil { + return + } + line := scanner.Bytes() + if len(strings.TrimSpace(string(line))) == 0 { + continue + } + handleStreamLine(turnID, append([]byte{}, line...)) + } +} + +func handleStreamLine(turnID string, line []byte) { + if reqID, req, ok := parseControlRequest(line); ok { + handleControlAsk(turnID, reqID, req) + return + } + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return + } + if parsed.Type == "result" { + if parsed.SessionID != "" { + bindFromTurn(turnID, parsed.SessionID) + } + closeTurnStdin(turnID) + } + if parsed.Type == "system" && parsed.Subtype == "init" && parsed.SessionID != "" { + bindFromTurn(turnID, parsed.SessionID) + } + sessID := turnSessionID(turnID) + if item, ok := progressFromLine(line); ok { + _ = AppendProgress(sessID, turnID, item) + } +} + +func bindFromTurn(turnID, claudeSessionID string) { + rt.mu.Lock() + defer rt.mu.Unlock() + turn := rt.turns[turnID] + if turn == nil { + return + } + turn.ClaudeSessionID = claudeSessionID + if turn.SessionID == "" { + return + } + sess := internLocked(turn.SessionID) + if sess.ClaudeSessionID == "" { + sess.ClaudeSessionID = claudeSessionID + } +} + +func closeTurnStdin(turnID string) { + rt.mu.Lock() + defer rt.mu.Unlock() + turn := rt.turns[turnID] + if turn == nil || turn.stdin == nil { + return + } + _ = turn.stdin.Close() + turn.stdin = nil +} + +func turnSessionID(turnID string) string { + rt.mu.Lock() + defer rt.mu.Unlock() + if turn := rt.turns[turnID]; turn != nil { + return turn.SessionID + } + return "" +} + +func handleControlAsk(turnID, requestID string, req controlRequest) { + if req.Subtype != "" && req.Subtype != "can_use_tool" { + sessID := turnSessionID(turnID) + _ = AppendProgress(sessID, turnID, Progress{Kind: ProgressKindNotice, Text: "不兼容的提问", Paths: emptyStrings()}) + _ = writeControlResponse(turnID, requestID, AskAnswer{Approved: false}) + return + } + ask, known := askFromTool(req) + ask.ExternalRequestID = requestID + if !known { + sessID := turnSessionID(turnID) + _ = AppendProgress(sessID, turnID, Progress{Kind: ProgressKindNotice, Text: "不兼容的提问", Paths: emptyStrings()}) + _ = writeControlResponse(turnID, requestID, AskAnswer{Approved: false}) + return + } + ch := make(chan AskAnswer, 1) + rt.mu.Lock() + if turn := rt.turns[turnID]; turn != nil { + turn.Status = TurnWaitingApproval + turn.waiters[requestID] = ch + } + rt.mu.Unlock() + _, _ = Require(turnID, ask) + select { + case answer := <-ch: + _ = writeControlResponse(turnID, requestID, answer) + case <-time.After(10 * time.Minute): + _ = writeControlResponse(turnID, requestID, AskAnswer{Approved: false}) + } +} + +func writeControlResponse(turnID, requestID string, answer AskAnswer) error { + behavior := "deny" + message := "rejected" + updated := map[string]any{} + if answer.Approved { + behavior = "allow" + message = "" + if answer.Choice != "" { + updated["answers"] = map[string]string{"choice": answer.Choice} + } + if len(answer.Values) > 0 { + updated["values"] = answer.Values + } + } + payload := map[string]any{ + "type": "control_response", + "request_id": requestID, + "response": map[string]any{ + "subtype": "success", + "response": map[string]any{ + "behavior": behavior, + "message": message, + "updatedInput": updated, + }, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + rt.mu.Lock() + turn := rt.turns[turnID] + stdin := io.WriteCloser(nil) + if turn != nil { + stdin = turn.stdin + if turn.Status == TurnWaitingApproval { + turn.Status = TurnRunning + } + } + rt.mu.Unlock() + if stdin == nil { + return nil + } + _, err = stdin.Write(append(body, '\n')) + return err +} + +func finishTurn(turnID string) { + rt.mu.Lock() + turn := rt.turns[turnID] + sessionID := "" + if turn != nil { + sessionID = turn.SessionID + if turn.Status == TurnRunning || turn.Status == TurnWaitingApproval { + turn.Status = TurnCompleted + } + if turn.stdin != nil { + _ = turn.stdin.Close() + turn.stdin = nil + } + } + rt.mu.Unlock() + if sessionID != "" { + _ = ClearActiveTurn(sessionID, turnID) + drainQueue(sessionID) + } +} + +// Interrupt 按用户请求打断 Claude Code 当前一轮。 +func Interrupt(claudeSessionID, turnID string) error { + _ = claudeSessionID + rt.mu.Lock() + turn := rt.turns[turnID] + if turn == nil && claudeSessionID != "" { + for _, item := range rt.turns { + if item.ClaudeSessionID == claudeSessionID { + turn = item + break + } + } + } + var cmd *exec.Cmd + if turn != nil { + turn.Status = TurnCancelled + if turn.cancel != nil { + turn.cancel() + } + cmd = turn.cmd + } + rt.mu.Unlock() + return signalInterrupt(cmd) +} + +// Compact 让 Claude Code 自己压缩上下文。 +func Compact(claudeSessionID string) error { + if claudeSessionID == "" { + return wrapErr(errInvalid, "claude session id is required") + } + _, err := runPrint(defaultCwd(), "/compact", "--resume", claudeSessionID) + return err +} + +// Review 让 Claude Code 评审当前工作区改动。 +func Review(claudeSessionID string) error { + if claudeSessionID == "" { + return wrapErr(errInvalid, "claude session id is required") + } + _, err := runPrint(defaultCwd(), "/review", "--resume", claudeSessionID) + return err +} + +func renameClaude(claudeSessionID, title string) error { + if claudeSessionID == "" { + return nil + } + _, err := runPrint(defaultCwd(), "/rename "+title, "--resume", claudeSessionID) + return err +} + +// ReplyAsk 把人对已知反问的回答回给 Claude Code。 +func ReplyAsk(requestID string, answer AskAnswer) error { + if requestID == "" { + return nil + } + rt.mu.Lock() + ask := rt.asks[requestID] + turnID := "" + var ch chan AskAnswer + if ask != nil { + turnID = ask.TurnID + } + if turnID == "" { + for id, turn := range rt.turns { + if waiter, ok := turn.waiters[requestID]; ok { + turnID = id + ch = waiter + break + } + } + } else if turn := rt.turns[turnID]; turn != nil { + ch = turn.waiters[requestID] + } + rt.mu.Unlock() + if ch != nil { + select { + case ch <- answer: + default: + } + return nil + } + if turnID != "" { + return writeControlResponse(turnID, requestID, answer) + } + return nil +} + +// RejectUnknown 官方新加、认不出的提问:提示不兼容并回包,避免卡死。 +func RejectUnknown(requestID string) error { + return ReplyAsk(requestID, AskAnswer{Approved: false}) +} diff --git a/server/pkg/claude/errors.go b/server/pkg/claude/errors.go new file mode 100644 index 0000000..7fb6c5c --- /dev/null +++ b/server/pkg/claude/errors.go @@ -0,0 +1,22 @@ +package claude + +import ( + "errors" + "fmt" +) + +var ( + errNotFound = errors.New("not found") + errConflict = errors.New("conflict") + errInvalid = errors.New("invalid") + errUnavailable = errors.New("unavailable") + errUnauthorized = errors.New("unauthorized") + errArchived = errors.New("archived") +) + +func wrapErr(kind error, format string, args ...any) error { + if format == "" { + return kind + } + return fmt.Errorf("%w: %s", kind, fmt.Sprintf(format, args...)) +} diff --git a/server/pkg/claude/local.go b/server/pkg/claude/local.go new file mode 100644 index 0000000..9fcb216 --- /dev/null +++ b/server/pkg/claude/local.go @@ -0,0 +1,527 @@ +package claude + +import ( + "bufio" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/google/uuid" +) + +func configDir() string { + if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude") +} + +func defaultCwd() string { + if repo := strings.TrimSpace(os.Getenv("GIT_REPO")); repo != "" { + if abs, err := filepath.Abs(repo); err == nil { + return abs + } + } + wd, err := os.Getwd() + if err != nil { + return "" + } + return wd +} + +func sanitizeProject(cwd string) string { + abs, err := filepath.Abs(cwd) + if err != nil { + abs = cwd + } + var b strings.Builder + for _, r := range abs { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + name := b.String() + if len(name) > 200 { + sum := sha1.Sum([]byte(abs)) + name = name[:200] + hex.EncodeToString(sum[:4]) + } + return name +} + +func projectDir(cwd string) string { + return filepath.Join(configDir(), "projects", sanitizeProject(cwd)) +} + +func emptyStrings() []string { + return []string{} +} + +func catalogModels() []ModelInfo { + efforts := []string{"low", "medium", "high", "xhigh", "max"} + return []ModelInfo{ + {ID: "sonnet", Efforts: append([]string{}, efforts...), DefaultEffort: "medium", IsDefault: true}, + {ID: "opus", Efforts: append([]string{}, efforts...), DefaultEffort: "high"}, + {ID: "haiku", Efforts: []string{"low", "medium", "high"}, DefaultEffort: "medium"}, + {ID: "fable", Efforts: []string{"low", "medium", "high"}, DefaultEffort: "medium", Hidden: true}, + } +} + +func catalogModes() []ModeInfo { + ids := []string{"default", "acceptEdits", "plan", "bypassPermissions", "auto", "dontAsk"} + out := make([]ModeInfo, 0, len(ids)) + for _, id := range ids { + out = append(out, ModeInfo{ID: id, Kind: "permission"}) + } + return out +} + +func findSessionFile(sessionID string) string { + if sessionID == "" { + return "" + } + root := filepath.Join(configDir(), "projects") + matches, err := filepath.Glob(filepath.Join(root, "*", sessionID+".jsonl")) + if err != nil || len(matches) == 0 { + return "" + } + return matches[0] +} + +func sessionFileExists(sessionID string) bool { + return findSessionFile(sessionID) != "" +} + +// copyForkedSession 按官方 --fork-session 落盘:同目录复制实录,换新 session ID。 +func copyForkedSession(src string) (string, error) { + from := strings.TrimSuffix(filepath.Base(src), ".jsonl") + id := uuid.NewString() + dst := filepath.Join(filepath.Dir(src), id+".jsonl") + body, err := os.ReadFile(src) + if err != nil { + return "", wrapErr(errUnavailable, "%s", err.Error()) + } + if err := os.WriteFile(dst, rewriteSessionIDs(body, from, id), 0o644); err != nil { + return "", wrapErr(errUnavailable, "%s", err.Error()) + } + return id, nil +} + +// rewriteSessionIDs 只改实录行顶层 sessionId / session_id,不改正文里的编号。 +func rewriteSessionIDs(body []byte, from, to string) []byte { + if from == "" || from == to { + return body + } + lines := strings.Split(string(body), "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + out = append(out, line) + continue + } + var obj map[string]any + if err := json.Unmarshal([]byte(trimmed), &obj); err != nil { + out = append(out, line) + continue + } + changed := false + for _, key := range []string{"sessionId", "session_id"} { + if value, ok := obj[key].(string); ok && value == from { + obj[key] = to + changed = true + } + } + if !changed { + out = append(out, line) + continue + } + rewritten, err := json.Marshal(obj) + if err != nil { + out = append(out, line) + continue + } + out = append(out, string(rewritten)) + } + return []byte(strings.Join(out, "\n")) +} + +// applySessionTitle 把官方 system/title 行写进实录,fork 后的序号才能留下。 +func applySessionTitle(path, title string) error { + title = strings.TrimSpace(title) + if path == "" || title == "" { + return nil + } + body, err := os.ReadFile(path) + if err != nil { + return wrapErr(errUnavailable, "%s", err.Error()) + } + encoded, err := json.Marshal(map[string]any{ + "type": "system", + "subtype": "title", + "title": title, + }) + if err != nil { + return wrapErr(errUnavailable, "%s", err.Error()) + } + lines := strings.Split(string(body), "\n") + out := make([]string, 0, len(lines)+1) + replaced := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + out = append(out, line) + continue + } + if isTitleLine([]byte(trimmed)) { + if !replaced { + out = append(out, string(encoded)) + replaced = true + } + continue + } + out = append(out, line) + } + if !replaced { + out = append([]string{string(encoded)}, out...) + } + return os.WriteFile(path, []byte(strings.Join(out, "\n")), 0o644) +} + +// isTitleLine 认官方实录里的会话标题行。 +func isTitleLine(line []byte) bool { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return false + } + return parsed.Type == "system" && parsed.Subtype == "title" +} + +type parsedSessionFile struct { + Title string + Items []Progress + Usage TokenUsage + CreatedAt int64 + UpdatedAt int64 +} + +// parseSessionFile 读本机 JSONL:标题、给人看的实录,以及首末 timestamp。 +func parseSessionFile(path string) (parsedSessionFile, error) { + file, err := os.Open(path) + if err != nil { + return parsedSessionFile{}, err + } + defer file.Close() + created, updated := fileUnixTimes(path) + firstTS, lastTS := int64(0), int64(0) + out := parsedSessionFile{Items: make([]Progress, 0), CreatedAt: created, UpdatedAt: updated} + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := bytesTrim(scanner.Bytes()) + if len(line) == 0 { + continue + } + if ts := timestampFromLine(line); ts > 0 { + if firstTS == 0 { + firstTS = ts + } + lastTS = ts + } + if t := titleFromLine(line); t != "" && out.Title == "" { + out.Title = t + } + if item, ok := progressFromLine(line); ok { + out.Items = append(out.Items, item) + if out.Title == "" && item.Kind == ProgressKindUser && item.Text != "" { + out.Title = clipTitle(item.Text) + } + } + if usage, ok := usageFromLine(line); ok { + out.Usage = usage + } + } + if err := scanner.Err(); err != nil { + return out, err + } + if firstTS > 0 { + out.CreatedAt = firstTS + } + if lastTS > 0 { + out.UpdatedAt = lastTS + } + return out, nil +} + +// parseTitleAndProgress 兼容旧调用,只取标题和实录。 +func parseTitleAndProgress(path string) (string, []Progress, error) { + parsed, err := parseSessionFile(path) + return parsed.Title, parsed.Items, err +} + +// fileUnixTimes 用文件 mtime 当创建/更新回落,单位 Unix 秒。 +func fileUnixTimes(path string) (created, updated int64) { + info, err := os.Stat(path) + if err != nil { + return 0, 0 + } + updated = info.ModTime().Unix() + return updated, updated +} + +// timestampFromLine 读 Claude 实录行上的 ISO timestamp。 +func timestampFromLine(line []byte) int64 { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return 0 + } + raw := strings.TrimSpace(parsed.Timestamp) + if raw == "" { + return 0 + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t.Unix() + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t.Unix() + } + return 0 +} + +func bytesTrim(b []byte) []byte { + return []byte(strings.TrimSpace(string(b))) +} + +func clipTitle(text string) string { + text = strings.TrimSpace(strings.ReplaceAll(text, "\n", " ")) + if len([]rune(text)) > 80 { + return string([]rune(text)[:80]) + } + return text +} + +type fileSettings struct { + Model string `json:"model"` + PermissionMode string `json:"permissionMode"` + DefaultMode string `json:"defaultMode"` + EffortLevel string `json:"effortLevel"` +} + +func readSettingsFile() Settings { + settings := Settings{ + Model: "sonnet", + Effort: "medium", + PermissionMode: "default", + Cwd: defaultCwd(), + Overridden: emptyStrings(), + } + body, err := os.ReadFile(filepath.Join(configDir(), "settings.json")) + if err != nil { + return settings + } + var parsed fileSettings + if err := json.Unmarshal(body, &parsed); err != nil { + return settings + } + if parsed.Model != "" { + settings.Model = parsed.Model + } + if parsed.EffortLevel != "" { + settings.Effort = parsed.EffortLevel + } + if parsed.PermissionMode != "" { + settings.PermissionMode = canonicalPermissionMode(parsed.PermissionMode) + } else if parsed.DefaultMode != "" { + settings.PermissionMode = canonicalPermissionMode(parsed.DefaultMode) + } + return settings +} + +func authLoggedIn(body string) bool { + body = strings.TrimSpace(body) + if body == "" { + return false + } + var payload map[string]any + if err := json.Unmarshal([]byte(body), &payload); err != nil { + lower := strings.ToLower(body) + return strings.Contains(lower, "logged in") || strings.Contains(lower, "logged-in") + } + for _, key := range []string{"loggedIn", "logged_in", "authenticated"} { + switch v := payload[key].(type) { + case bool: + if v { + return true + } + case string: + if strings.EqualFold(v, "true") || strings.EqualFold(v, "logged in") { + return true + } + } + } + if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "logged_in") { + return true + } + return false +} + +// ReadEngine 从本机 Claude 读是否可用、是否已取得授权。 +func ReadEngine() (EngineStatus, error) { + status := EngineStatus{} + if err := lookClaude(); err != nil { + status.Hint = "未安装 Claude Code" + return status, nil + } + status.Available = true + ver := runClaude("", "-v") + status.Version = strings.TrimSpace(ver.stdout) + if status.Version == "" { + status.Version = strings.TrimSpace(ver.stderr) + } + auth := runClaude("", "auth", "status") + if auth.err == nil || authLoggedIn(auth.stdout) || authLoggedIn(auth.stderr) { + status.Authorized = true + return status, nil + } + status.Hint = "未取得 Claude 授权" + return status, nil +} + +// ReadModels 从本机 Claude 读模型及各自支持的推理强度。 +func ReadModels() ([]ModelInfo, error) { + if _, err := ReadEngine(); err != nil { + return []ModelInfo{}, err + } + return catalogModels(), nil +} + +// ReadModes 从本机 Claude 读官方权限档。 +func ReadModes() ([]ModeInfo, error) { + return catalogModes(), nil +} + +// ReadSession 从本机 Claude 读一条 session。 +func ReadSession(claudeSessionID string) (Session, error) { + if claudeSessionID == "" { + return Session{}, wrapErr(errInvalid, "session_id is required") + } + rt.mu.Lock() + sess := internLocked(claudeSessionID) + claudeID := sess.ClaudeSessionID + rt.mu.Unlock() + path := findSessionFile(claudeSessionID) + if path == "" && claudeID != "" { + path = findSessionFile(claudeID) + } + title := "" + fileID := "" + createdAt, updatedAt := int64(0), int64(0) + if path != "" { + parsed, err := parseSessionFile(path) + if err != nil { + return Session{}, err + } + title = parsed.Title + createdAt = parsed.CreatedAt + updatedAt = parsed.UpdatedAt + fileID = strings.TrimSuffix(filepath.Base(path), ".jsonl") + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess = internLocked(claudeSessionID) + if sess.Title == "" && title != "" { + sess.Title = title + } + if sess.ClaudeSessionID == "" && fileID != "" { + sess.ClaudeSessionID = fileID + } + if createdAt > 0 { + sess.CreatedAt = createdAt + } + if updatedAt > 0 { + sess.UpdatedAt = updatedAt + } + out := sess.snapshot() + if out.Title == "" { + out.Title = title + } + if out.ClaudeSessionID == "" { + out.ClaudeSessionID = fileID + } + return out, nil +} + +// ReadSessions 从本机 Claude 读对话列表。 +func ReadSessions() ([]Session, error) { + seen := map[string]Session{} + dir := projectDir(defaultCwd()) + entries, _ := filepath.Glob(filepath.Join(dir, "*.jsonl")) + all, _ := filepath.Glob(filepath.Join(configDir(), "projects", "*", "*.jsonl")) + if len(entries) == 0 { + entries = all + } + for _, path := range entries { + id := strings.TrimSuffix(filepath.Base(path), ".jsonl") + sess, err := ReadSession(id) + if err != nil { + continue + } + seen[id] = sess + } + rt.mu.Lock() + for id, sess := range rt.sessions { + snap := sess.snapshot() + if snap.ID == "" { + snap.ID = id + } + seen[snap.ID] = snap + } + rt.mu.Unlock() + out := make([]Session, 0, len(seen)) + for _, sess := range seen { + out = append(out, sess) + } + return out, nil +} + +// ReadTranscript 从本机 Claude 读给人看的实录。 +func ReadTranscript(claudeSessionID string) ([]Progress, error) { + items, _, err := ReadTranscriptAndUsage(claudeSessionID) + return items, err +} + +// ReadTranscriptAndUsage 回放实录,并带上官方最后一次用量。 +func ReadTranscriptAndUsage(claudeSessionID string) ([]Progress, TokenUsage, error) { + id := claudeSessionID + rt.mu.Lock() + if sess, ok := rt.sessions[claudeSessionID]; ok && sess.ClaudeSessionID != "" { + id = sess.ClaudeSessionID + } + rt.mu.Unlock() + path := findSessionFile(id) + if path == "" { + return []Progress{}, TokenUsage{}, nil + } + parsed, err := parseSessionFile(path) + items := parsed.Items + if items == nil { + items = []Progress{} + } + return items, parsed.Usage, err +} + +// ReadSettings 从本机 Claude 读生效配置。 +func ReadSettings(claudeSessionID string) (Settings, error) { + _ = claudeSessionID + return readSettingsFile(), nil +} diff --git a/server/pkg/claude/parse.go b/server/pkg/claude/parse.go new file mode 100644 index 0000000..d783908 --- /dev/null +++ b/server/pkg/claude/parse.go @@ -0,0 +1,354 @@ +package claude + +import ( + "encoding/json" + "strings" +) + +type ndjsonLine struct { + Type string `json:"type"` + Subtype string `json:"subtype"` + SessionID string `json:"session_id"` + RequestID string `json:"request_id"` + Result string `json:"result"` + Request json.RawMessage `json:"request"` + Message json.RawMessage `json:"message"` + Event json.RawMessage `json:"event"` + Title string `json:"title"` + Timestamp string `json:"timestamp"` // Claude 实录行上的 ISO 时间。 +} + +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + Name string `json:"name"` + ID string `json:"id"` + Input json.RawMessage `json:"input"` +} + +type messageBody struct { + Role string `json:"role"` + Model string `json:"model"` + Content json.RawMessage `json:"content"` + Usage tokenUsageBody `json:"usage"` +} + +type tokenUsageBody struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"` + CacheReadInputTokens int64 `json:"cache_read_input_tokens"` +} + +type controlRequest struct { + Subtype string `json:"subtype"` + ToolName string `json:"tool_name"` + ToolUseID string `json:"tool_use_id"` + Input json.RawMessage `json:"input"` +} + +func titleFromLine(line []byte) string { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return "" + } + if parsed.Title != "" { + return parsed.Title + } + if parsed.Type == "system" && parsed.Subtype == "title" { + return parsed.Title + } + return "" +} + +// usageFromLine 读官方 assistant.message.usage,used 不含 output。 +func usageFromLine(line []byte) (TokenUsage, bool) { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil || parsed.Type != "assistant" { + return TokenUsage{}, false + } + var body messageBody + if err := json.Unmarshal(parsed.Message, &body); err != nil { + return TokenUsage{}, false + } + used := body.Usage.InputTokens + body.Usage.CacheCreationInputTokens + body.Usage.CacheReadInputTokens + if used <= 0 { + return TokenUsage{}, false + } + return TokenUsage{Used: used, Window: contextWindowSize(body.Model)}, true +} + +// contextWindowSize 对齐官方 vp/JL:默认 200k,模型名带 [1m] 则 1M。 +func contextWindowSize(model string) int64 { + if strings.Contains(strings.ToLower(model), "[1m]") { + return 1_000_000 + } + return 200_000 +} + +func progressFromLine(line []byte) (Progress, bool) { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return Progress{}, false + } + switch parsed.Type { + case "user": + text := messageText(parsed.Message) + if text == "" { + return Progress{}, false + } + return Progress{Kind: ProgressKindUser, Text: text, Paths: emptyStrings()}, true + case "assistant": + return assistantProgress(parsed.Message) + case "system": + if parsed.Subtype == "notice" || parsed.Subtype == "permission_denied" { + return Progress{Kind: ProgressKindNotice, Text: parsed.Result, Paths: emptyStrings()}, true + } + } + return Progress{}, false +} + +func messageText(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var body messageBody + if err := json.Unmarshal(raw, &body); err != nil { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + return "" + } + blocks := decodeBlocks(body.Content) + var parts []string + for _, block := range blocks { + if block.Type == "text" && block.Text != "" { + parts = append(parts, block.Text) + } + } + if len(parts) == 0 && len(body.Content) > 0 && body.Content[0] == '"' { + var s string + _ = json.Unmarshal(body.Content, &s) + return s + } + return strings.Join(parts, "\n") +} + +func decodeBlocks(raw json.RawMessage) []contentBlock { + if len(raw) == 0 { + return nil + } + if raw[0] == '"' { + var s string + if err := json.Unmarshal(raw, &s); err == nil && s != "" { + return []contentBlock{{Type: "text", Text: s}} + } + return nil + } + var blocks []contentBlock + if err := json.Unmarshal(raw, &blocks); err == nil { + return blocks + } + var one contentBlock + if err := json.Unmarshal(raw, &one); err == nil && one.Type != "" { + return []contentBlock{one} + } + return nil +} + +func assistantProgress(raw json.RawMessage) (Progress, bool) { + var body messageBody + if err := json.Unmarshal(raw, &body); err != nil { + return Progress{}, false + } + for _, block := range decodeBlocks(body.Content) { + switch block.Type { + case "thinking": + text := block.Thinking + if text == "" { + text = block.Text + } + return Progress{Kind: ProgressKindReasoning, Text: text, Paths: emptyStrings()}, true + case "text": + if block.Text == "" { + continue + } + return Progress{Kind: ProgressKindText, Text: block.Text, Paths: emptyStrings()}, true + case "tool_use": + return toolProgress(block), true + } + } + return Progress{}, false +} + +func toolProgress(block contentBlock) Progress { + item := Progress{Kind: ProgressKindCommand, Command: block.Name, Paths: emptyStrings()} + var input map[string]any + _ = json.Unmarshal(block.Input, &input) + switch block.Name { + case "Bash": + item.Kind = ProgressKindCommand + item.Command = stringField(input, "command") + item.Text = stringField(input, "description") + case "Edit", "Write", "MultiEdit": + item.Kind = ProgressKindFileChange + path := stringField(input, "path") + if path == "" { + path = stringField(input, "file_path") + } + if path != "" { + item.Paths = []string{path} + } + item.Diff = stringField(input, "new_string") + case "TodoWrite", "Plan", "ExitPlanMode": + item.Kind = ProgressKindPlan + item.Text = stringField(input, "plan") + if item.Text == "" { + item.Text = block.Name + } + default: + item.Kind = ProgressKindCommand + item.Command = block.Name + } + return item +} + +func stringField(input map[string]any, key string) string { + if input == nil { + return "" + } + v, ok := input[key] + if !ok { + return "" + } + s, _ := v.(string) + return s +} + +func parseControlRequest(line []byte) (string, controlRequest, bool) { + var parsed ndjsonLine + if err := json.Unmarshal(line, &parsed); err != nil { + return "", controlRequest{}, false + } + if parsed.Type != "control_request" { + return "", controlRequest{}, false + } + var req controlRequest + if err := json.Unmarshal(parsed.Request, &req); err != nil { + return parsed.RequestID, req, false + } + return parsed.RequestID, req, true +} + +func parseResultSessionID(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + var parsed ndjsonLine + if err := json.Unmarshal([]byte(body), &parsed); err == nil && parsed.SessionID != "" { + return parsed.SessionID + } + scannerLines := strings.Split(body, "\n") + for i := len(scannerLines) - 1; i >= 0; i-- { + line := strings.TrimSpace(scannerLines[i]) + if line == "" { + continue + } + parsed = ndjsonLine{} + if err := json.Unmarshal([]byte(line), &parsed); err == nil && parsed.SessionID != "" { + return parsed.SessionID + } + } + return "" +} + +func askFromTool(req controlRequest) (ApprovalAsk, bool) { + ask := ApprovalAsk{ + ExternalRequestID: "", + Paths: emptyStrings(), + Options: emptyStrings(), + Fields: emptyStrings(), + } + var input map[string]any + _ = json.Unmarshal(req.Input, &input) + switch req.ToolName { + case "Bash": + ask.Kind = AskKindCommand + ask.Command = stringField(input, "command") + return ask, true + case "Edit", "Write", "MultiEdit": + ask.Kind = AskKindFileChange + path := stringField(input, "path") + if path == "" { + path = stringField(input, "file_path") + } + if path != "" { + ask.Paths = []string{path} + } + ask.Diff = stringField(input, "new_string") + return ask, true + case "AskUserQuestion": + ask.Kind = AskKindQuestion + ask.Prompt = stringField(input, "question") + if ask.Prompt == "" { + ask.Prompt = stringField(input, "prompt") + } + ask.Options = stringList(input, "options") + return ask, true + default: + if strings.HasPrefix(req.ToolName, "mcp__") || looksLikeForm(input) { + ask.Kind = AskKindForm + ask.Prompt = stringField(input, "prompt") + ask.Fields = stringList(input, "fields") + if len(ask.Fields) == 0 { + for key := range input { + ask.Fields = append(ask.Fields, key) + } + } + return ask, true + } + } + return ask, false +} + +func looksLikeForm(input map[string]any) bool { + if input == nil { + return false + } + if _, ok := input["fields"]; ok { + return true + } + _, hasPrompt := input["prompt"] + _, hasValues := input["values"] + return hasPrompt && hasValues +} + +func stringList(input map[string]any, key string) []string { + out := []string{} + if input == nil { + return out + } + raw, ok := input[key] + if !ok { + return out + } + switch v := raw.(type) { + case []any: + for _, item := range v { + switch n := item.(type) { + case string: + out = append(out, n) + case map[string]any: + if label, ok := n["label"].(string); ok { + out = append(out, label) + } + } + } + case []string: + out = append(out, v...) + } + return out +} diff --git a/server/pkg/claude/run.go b/server/pkg/claude/run.go new file mode 100644 index 0000000..6f392b9 --- /dev/null +++ b/server/pkg/claude/run.go @@ -0,0 +1,132 @@ +package claude + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func claudeBin() string { + if bin := strings.TrimSpace(os.Getenv("CLAUDE_BIN")); bin != "" { + return bin + } + return "claude" +} + +func claudeTimeout() time.Duration { + if raw := strings.TrimSpace(os.Getenv("CLAUDE_TIMEOUT")); raw != "" { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + return d + } + } + return 60 * time.Second +} + +type claudeOutput struct { + stdout string + stderr string + code int + err error +} + +func runClaude(dir string, args ...string) claudeOutput { + return runClaudeCtx(context.Background(), dir, args...) +} + +func runClaudeCtx(ctx context.Context, dir string, args ...string) claudeOutput { + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, claudeTimeout()) + defer cancel() + cmd := exec.CommandContext(ctx, claudeBin(), args...) + if strings.TrimSpace(dir) != "" { + cmd.Dir = dir + } + cmd.Env = os.Environ() + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + out := claudeOutput{stdout: stdout.String(), stderr: stderr.String(), err: err} + if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + out.err = fmt.Errorf("claude %s timed out", strings.Join(args, " ")) + out.code = -1 + return out + } + var ee *exec.ExitError + if errors.As(err, &ee) { + out.code = ee.ExitCode() + } else { + out.code = -1 + } + } + return out +} + +func claudeErr(out claudeOutput) error { + if out.err == nil { + return nil + } + msg := strings.TrimSpace(out.stderr) + if msg == "" { + msg = strings.TrimSpace(out.stdout) + } + if msg == "" { + msg = out.err.Error() + } + return errors.New(msg) +} + +func lookClaude() error { + bin := claudeBin() + if filepath.IsAbs(bin) || strings.Contains(bin, string(os.PathSeparator)) { + _, err := os.Stat(bin) + return err + } + _, err := exec.LookPath(bin) + return err +} + +func startClaude(dir string, args []string) (*exec.Cmd, io.WriteCloser, io.ReadCloser, error) { + cmd := exec.Command(claudeBin(), args...) + if strings.TrimSpace(dir) != "" { + cmd.Dir = dir + } + cmd.Env = os.Environ() + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + _ = stdin.Close() + return nil, nil, nil, err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + _ = stdin.Close() + _ = stdout.Close() + return nil, nil, nil, err + } + return cmd, stdin, stdout, nil +} + +func signalInterrupt(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := cmd.Process.Signal(os.Interrupt); err != nil { + return cmd.Process.Kill() + } + return nil +} diff --git a/server/pkg/claude/runtime.go b/server/pkg/claude/runtime.go new file mode 100644 index 0000000..111215f --- /dev/null +++ b/server/pkg/claude/runtime.go @@ -0,0 +1,132 @@ +package claude + +import ( + "context" + "io" + "os/exec" + "sync" + "time" + + "github.com/google/uuid" +) + +type memSession struct { + ID string + ClaudeSessionID string + Title string + Archived bool + ActiveTurnID string + CreatedAt int64 // 本机实录或创建时刻,Unix 秒。 + UpdatedAt int64 // 本机实录最近一条或上次写入,Unix 秒。 + Overrides Settings + Overridden []string + Draft Input + Queue []queuedTurn +} + +type queuedTurn struct { + Content string + Input Input +} + +type memTurn struct { + ID string + SessionID string + ClaudeSessionID string + Status TurnStatus + cmd *exec.Cmd + stdin io.WriteCloser + cancel context.CancelFunc + waiters map[string]chan AskAnswer +} + +type memAsk struct { + ID string + TurnID string + Ask ApprovalAsk +} + +type runtime struct { + mu sync.Mutex + sessions map[string]*memSession + turns map[string]*memTurn + asks map[string]*memAsk +} + +var rt = newRuntime() + +func newRuntime() *runtime { + return &runtime{ + sessions: map[string]*memSession{}, + turns: map[string]*memTurn{}, + asks: map[string]*memAsk{}, + } +} + +func resetRuntime() { + rt.mu.Lock() + defer rt.mu.Unlock() + for _, turn := range rt.turns { + if turn.cancel != nil { + turn.cancel() + } + if turn.cmd != nil && turn.cmd.Process != nil { + _ = turn.cmd.Process.Kill() + } + if turn.stdin != nil { + _ = turn.stdin.Close() + } + } + rt.sessions = map[string]*memSession{} + rt.turns = map[string]*memTurn{} + rt.asks = map[string]*memAsk{} +} + +func internLocked(id string) *memSession { + if id == "" { + id = uuid.NewString() + } + if sess, ok := rt.sessions[id]; ok { + return sess + } + for _, sess := range rt.sessions { + if sess.ClaudeSessionID == id { + return sess + } + } + now := time.Now().Unix() + sess := &memSession{ + ID: id, + CreatedAt: now, + UpdatedAt: now, + Draft: Input{Mentions: []string{}, Images: []string{}}, + } + rt.sessions[id] = sess + return sess +} + +func (s *memSession) snapshot() Session { + return Session{ + ID: s.ID, + ClaudeSessionID: s.ClaudeSessionID, + Title: s.Title, + ActiveTurnID: s.ActiveTurnID, + Archived: s.Archived, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } +} + +func sessionByClaudeID(claudeSessionID string) *memSession { + rt.mu.Lock() + defer rt.mu.Unlock() + if claudeSessionID == "" { + return nil + } + for _, sess := range rt.sessions { + if sess.ClaudeSessionID == claudeSessionID { + return sess + } + } + return internLocked(claudeSessionID) +} diff --git a/server/pkg/claude/session.go b/server/pkg/claude/session.go new file mode 100644 index 0000000..cea84ca --- /dev/null +++ b/server/pkg/claude/session.go @@ -0,0 +1,208 @@ +package claude + +import ( + "fmt" + "strconv" + "strings" + + "github.com/google/uuid" +) + +// Create 创建一条只走 Claude Code 的对话。Claude session 编号在首次开回合后才有。 +func Create(userID string) (Session, error) { + _ = userID + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(uuid.NewString()) + return sess.snapshot(), nil +} + +// Get 读取对话。 +func Get(sessionID string) (Session, error) { + if sessionID == "" { + return Session{}, wrapErr(errInvalid, "session_id is required") + } + return ReadSession(sessionID) +} + +// ListSessions 从本机 Claude 列对话。 +func ListSessions() ([]Session, error) { + return ReadSessions() +} + +// BindClaudeSession 记下 Claude session 编号,只能写一次。本模块不落库,编号以本机 Claude 为准。 +func BindClaudeSession(sessionID, claudeSessionID string) error { + if sessionID == "" || claudeSessionID == "" { + return wrapErr(errInvalid, "session id is required") + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(sessionID) + if sess.ClaudeSessionID != "" && sess.ClaudeSessionID != claudeSessionID { + return wrapErr(errConflict, "claude session already bound") + } + sess.ClaudeSessionID = claudeSessionID + return nil +} + +// Archive 归档,之后不能再向 Claude Code 开回合。不删本机 Claude 记录。 +func Archive(sessionID string) error { + sess, err := Get(sessionID) + if err != nil { + return err + } + rt.mu.Lock() + defer rt.mu.Unlock() + internLocked(sess.ID).Archived = true + return nil +} + +// Rename 改对话标题。 +func Rename(sessionID, title string) error { + sess, err := Get(sessionID) + if err != nil { + return err + } + rt.mu.Lock() + internLocked(sess.ID).Title = title + claudeID := internLocked(sess.ID).ClaudeSessionID + rt.mu.Unlock() + return renameClaude(claudeID, title) +} + +// Fork 按官方 --fork-session 复制已落盘实录,换新 Claude session,原对话不动。 +func Fork(sessionID string) (Session, error) { + sess, err := Get(sessionID) + if err != nil { + return Session{}, err + } + resumeID := sess.ClaudeSessionID + if resumeID == "" { + resumeID = sess.ID + } + newID, err := ForkSession(resumeID) + if err != nil { + return Session{}, err + } + title := nextForkTitle(sess.Title, sessionTitles()) + if path := findSessionFile(newID); path != "" && title != "" { + if err := applySessionTitle(path, title); err != nil { + return Session{}, err + } + } + rt.mu.Lock() + child := internLocked(newID) + child.ClaudeSessionID = newID + child.Title = title + out := child.snapshot() + rt.mu.Unlock() + return out, nil +} + +// sessionTitles 收集本机已有对话标题,用来给 fork 编号。 +func sessionTitles() []string { + list, err := ListSessions() + if err != nil { + return nil + } + out := make([]string, 0, len(list)) + for _, item := range list { + if item.Title != "" { + out = append(out, item.Title) + } + } + return out +} + +// forkTitleStem 去掉末尾 (n),避免 fork「ping (1)」变成「ping (1) (1)」。 +func forkTitleStem(title string) string { + title = strings.TrimSpace(title) + if title == "" { + return "" + } + i := strings.LastIndex(title, " (") + if i < 0 || !strings.HasSuffix(title, ")") { + return title + } + raw := title[i+2 : len(title)-1] + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || strings.TrimSpace(title[:i]) == "" { + return title + } + return strings.TrimSpace(title[:i]) +} + +// nextForkTitle 在同名对话上取下一个空位,得到「标题 (1)」「标题 (2)」。 +func nextForkTitle(base string, titles []string) string { + stem := forkTitleStem(base) + if stem == "" { + stem = "fork" + } + used := map[int]bool{} + for _, title := range titles { + title = strings.TrimSpace(title) + n, ok := forkTitleIndex(title, stem) + if ok { + used[n] = true + } + } + n := 1 + for used[n] { + n++ + } + return fmt.Sprintf("%s (%d)", stem, n) +} + +// forkTitleIndex 判断标题是不是 stem (n)。 +func forkTitleIndex(title, stem string) (int, bool) { + prefix := stem + " (" + if !strings.HasPrefix(title, prefix) || !strings.HasSuffix(title, ")") { + return 0, false + } + raw := strings.TrimSuffix(strings.TrimPrefix(title, prefix), ")") + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || title != fmt.Sprintf("%s (%d)", stem, n) { + return 0, false + } + return n, true +} + +// ClaimActiveTurn 标成当前执行;同时只能有一个。 +func ClaimActiveTurn(sessionID, turnID string) error { + sess, err := Get(sessionID) + if err != nil { + return err + } + rt.mu.Lock() + defer rt.mu.Unlock() + mem := internLocked(sess.ID) + if mem.Archived { + return wrapErr(errArchived, "session is archived") + } + if mem.ActiveTurnID != "" && mem.ActiveTurnID != turnID { + return wrapErr(errConflict, "turn already running") + } + mem.ActiveTurnID = turnID + return nil +} + +// ClearActiveTurn 清掉当前执行标记。 +func ClearActiveTurn(sessionID, turnID string) error { + if sessionID == "" { + rt.mu.Lock() + if turn, ok := rt.turns[turnID]; ok { + sessionID = turn.SessionID + } + rt.mu.Unlock() + } + if sessionID == "" { + return nil + } + rt.mu.Lock() + defer rt.mu.Unlock() + mem := internLocked(sessionID) + if mem.ActiveTurnID == turnID || turnID == "" { + mem.ActiveTurnID = "" + } + return nil +} diff --git a/server/pkg/claude/settings.go b/server/pkg/claude/settings.go new file mode 100644 index 0000000..1036300 --- /dev/null +++ b/server/pkg/claude/settings.go @@ -0,0 +1,132 @@ +package claude + +// Effective 返回 Claude 默认与用户覆盖合并后的生效配置。 +func Effective(sessionID string) (Settings, error) { + base, err := ReadSettings(sessionID) + if err != nil { + return Settings{}, err + } + if sessionID == "" { + return base, nil + } + rt.mu.Lock() + defer rt.mu.Unlock() + sess := internLocked(sessionID) + if sess.Overrides.Model != "" { + base.Model = sess.Overrides.Model + } + if sess.Overrides.Effort != "" { + base.Effort = sess.Overrides.Effort + } + if sess.Overrides.PermissionMode != "" { + base.PermissionMode = sess.Overrides.PermissionMode + } + if sess.Overrides.Cwd != "" { + base.Cwd = sess.Overrides.Cwd + } + base.Overridden = append([]string{}, sess.Overridden...) + if base.Overridden == nil { + base.Overridden = emptyStrings() + } + if base.Cwd == "" { + base.Cwd = defaultCwd() + } + return base, nil +} + +// Apply 只记下用户改过的 Claude 项。下次开回合再把改过的项交给 Claude Code。 +func Apply(sessionID string, patch Settings) (Settings, error) { + models, err := ListModels() + if err != nil { + return Settings{}, err + } + modes, err := ListModes() + if err != nil { + return Settings{}, err + } + if patch.Model != "" && !knownModel(models, patch.Model) { + return Settings{}, wrapErr(errInvalid, "unknown model %s", patch.Model) + } + if patch.PermissionMode != "" { + patch.PermissionMode = canonicalPermissionMode(patch.PermissionMode) + if !knownMode(modes, patch.PermissionMode) { + return Settings{}, wrapErr(errInvalid, "unknown permission mode %s", patch.PermissionMode) + } + } + if patch.Model != "" && patch.Effort != "" && !knownEffort(models, patch.Model, patch.Effort) { + return Settings{}, wrapErr(errInvalid, "unknown effort %s", patch.Effort) + } + if sessionID == "" { + return Settings{}, wrapErr(errInvalid, "session_id is required") + } + rt.mu.Lock() + sess := internLocked(sessionID) + if patch.Model != "" { + sess.Overrides.Model = patch.Model + sess.Overridden = addOverride(sess.Overridden, "model") + } + if patch.Effort != "" { + sess.Overrides.Effort = patch.Effort + sess.Overridden = addOverride(sess.Overridden, "effort") + } + if patch.PermissionMode != "" { + sess.Overrides.PermissionMode = patch.PermissionMode + sess.Overridden = addOverride(sess.Overridden, "permission_mode") + } + if patch.Cwd != "" { + sess.Overrides.Cwd = patch.Cwd + sess.Overridden = addOverride(sess.Overridden, "cwd") + } + rt.mu.Unlock() + return Effective(sessionID) +} + +func knownModel(models []ModelInfo, id string) bool { + for _, model := range models { + if model.ID == id { + return true + } + } + return false +} + +// canonicalPermissionMode 把官方别名收成 --permission-mode 用的 id。 +func canonicalPermissionMode(id string) string { + if id == "manual" { + return "default" + } + return id +} + +func knownMode(modes []ModeInfo, id string) bool { + id = canonicalPermissionMode(id) + for _, mode := range modes { + if mode.ID == id { + return true + } + } + return false +} + +func knownEffort(models []ModelInfo, modelID, effort string) bool { + for _, model := range models { + if model.ID != modelID { + continue + } + for _, item := range model.Efforts { + if item == effort { + return true + } + } + } + return false +} + +func addOverride(names []string, name string) []string { + for _, item := range names { + if item == name { + return names + } + } + return append(names, name) +} diff --git a/server/pkg/claude/testdata/fakeclaude/main.go b/server/pkg/claude/testdata/fakeclaude/main.go new file mode 100644 index 0000000..064696b --- /dev/null +++ b/server/pkg/claude/testdata/fakeclaude/main.go @@ -0,0 +1,253 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/google/uuid" +) + +func main() { + if raw := os.Getenv("FAKE_CLAUDE_SLEEP"); raw != "" { + if d, err := time.ParseDuration(raw); err == nil && d > 0 { + time.Sleep(d) + } + } + args := os.Args[1:] + if has(args, "-v") || has(args, "--version") { + fmt.Println(getenv("FAKE_CLAUDE_VERSION", "2.1.0")) + return + } + if len(args) >= 2 && args[0] == "auth" && args[1] == "status" { + if os.Getenv("FAKE_CLAUDE_AUTH") == "0" { + fmt.Println(`{"loggedIn":false}`) + os.Exit(1) + } + fmt.Println(`{"loggedIn":true}`) + return + } + + sessionID := flagValue(args, "--session-id") + resume := flagValue(args, "--resume") + if resume == "" { + resume = flagValue(args, "-r") + } + if sessionID == "" { + sessionID = resume + } + if sessionID == "" || has(args, "--fork-session") { + sessionID = uuid.NewString() + } + prompt := lastPositional(args) + if has(args, "--fork-session") && resume != "" { + copyJSONL(resume, sessionID) + } else { + writeJSONL(sessionID, prompt) + } + + if os.Getenv("FAKE_CLAUDE_LOG") != "" { + _ = os.WriteFile(os.Getenv("FAKE_CLAUDE_LOG"), []byte(strings.Join(args, " ")), 0o644) + } + + if has(args, "stream-json") || flagValue(args, "--output-format") == "stream-json" { + runStream(sessionID) + return + } + fmt.Printf("{\"type\":\"result\",\"subtype\":\"success\",\"session_id\":%q,\"result\":\"ok\"}\n", sessionID) +} + +func runStream(sessionID string) { + got := make(chan struct{}, 1) + go func() { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + if strings.Contains(scanner.Text(), "control_response") { + select { + case got <- struct{}{}: + default: + } + } + } + }() + emit(map[string]any{"type": "system", "subtype": "init", "session_id": sessionID}) + mode := os.Getenv("FAKE_CLAUDE_MODE") + switch mode { + case "ask": + emit(controlAsk(sessionID, "Bash", map[string]any{"command": "ls"})) + wait(got) + case "edit": + emit(controlAsk(sessionID, "Edit", map[string]any{"path": "a.go", "new_string": "x"})) + wait(got) + case "multiedit": + emit(controlAsk(sessionID, "MultiEdit", map[string]any{"file_path": "b.go", "new_string": "y"})) + wait(got) + case "question": + emit(controlAsk(sessionID, "AskUserQuestion", map[string]any{ + "question": "pick", + "options": []any{map[string]any{"label": "a"}, map[string]any{"label": "b"}}, + })) + wait(got) + case "form": + emit(controlAsk(sessionID, "mcp__server__form", map[string]any{"prompt": "fill", "fields": []string{"name"}})) + wait(got) + case "form_values": + emit(controlAsk(sessionID, "CustomForm", map[string]any{"prompt": "fill", "values": []string{"n"}})) + wait(got) + case "unknown": + emit(controlAsk(sessionID, "BrandNewTool", map[string]any{"foo": "bar"})) + wait(got) + case "other": + emit(map[string]any{ + "type": "control_request", + "request_id": "req-other", + "session_id": sessionID, + "request": map[string]any{"subtype": "set_max_thinking_tokens"}, + }) + wait(got) + case "hang": + time.Sleep(30 * time.Second) + case "fail": + os.Exit(1) + default: + emit(map[string]any{ + "type": "assistant", + "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "thinking", "thinking": "hmm"}, {"type": "text", "text": "ok"}}, + }, + }) + emit(map[string]any{ + "type": "assistant", + "message": map[string]any{ + "role": "assistant", + "content": []map[string]any{{"type": "tool_use", "name": "Bash", "input": map[string]any{"command": "pwd"}}}, + }, + }) + emit(map[string]any{"type": "system", "subtype": "notice", "result": "note"}) + } + emit(map[string]any{"type": "result", "subtype": "success", "session_id": sessionID, "result": "ok"}) +} + +func controlAsk(sessionID, tool string, input map[string]any) map[string]any { + return map[string]any{ + "type": "control_request", + "request_id": "req-1", + "session_id": sessionID, + "request": map[string]any{ + "subtype": "can_use_tool", + "tool_name": tool, + "input": input, + }, + } +} + +func wait(got chan struct{}) { + select { + case <-got: + case <-time.After(8 * time.Second): + } +} + +func emit(v any) { + body, _ := json.Marshal(v) + fmt.Println(string(body)) +} + +// copyJSONL 按 --fork-session 复制已落盘实录到新 session 文件。 +func copyJSONL(from, to string) { + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + if cfg == "" || from == "" || to == "" { + return + } + wd, _ := os.Getwd() + dir := filepath.Join(cfg, "projects", sanitize(wd)) + src, err := os.ReadFile(filepath.Join(dir, from+".jsonl")) + if err != nil { + writeJSONL(to, "") + return + } + _ = os.MkdirAll(dir, 0o755) + _ = os.WriteFile(filepath.Join(dir, to+".jsonl"), src, 0o644) +} + +func writeJSONL(sessionID, prompt string) { + cfg := os.Getenv("CLAUDE_CONFIG_DIR") + if cfg == "" { + return + } + wd, _ := os.Getwd() + dir := filepath.Join(cfg, "projects", sanitize(wd)) + _ = os.MkdirAll(dir, 0o755) + f, err := os.OpenFile(filepath.Join(dir, sessionID+".jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return + } + defer f.Close() + if prompt != "" { + fmt.Fprintf(f, "{\"type\":\"user\",\"message\":{\"role\":\"user\",\"content\":%q}}\n", prompt) + } + fmt.Fprintln(f, `{"type":"assistant","message":{"content":[{"type":"text","text":"ok"}]}}`) +} + +func sanitize(cwd string) string { + abs, err := filepath.Abs(cwd) + if err != nil { + abs = cwd + } + var b strings.Builder + for _, r := range abs { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return b.String() +} + +func has(args []string, want string) bool { + for _, arg := range args { + if arg == want { + return true + } + } + return false +} + +func flagValue(args []string, name string) string { + for i, arg := range args { + if arg == name && i+1 < len(args) { + return args[i+1] + } + if strings.HasPrefix(arg, name+"=") { + return strings.TrimPrefix(arg, name+"=") + } + } + return "" +} + +func lastPositional(args []string) string { + for i := len(args) - 1; i >= 0; i-- { + if strings.HasPrefix(args[i], "-") { + continue + } + if i > 0 && strings.HasPrefix(args[i-1], "-") && args[i-1] != "-p" && args[i-1] != "--print" { + continue + } + return args[i] + } + return "" +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/server/pkg/claude/testdata/transcript.jsonl b/server/pkg/claude/testdata/transcript.jsonl new file mode 100644 index 0000000..8111356 --- /dev/null +++ b/server/pkg/claude/testdata/transcript.jsonl @@ -0,0 +1,7 @@ +{"type":"user","message":{"role":"user","content":"hello"}} +{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"hmm"}]}} +{"type":"assistant","message":{"model":"claude-sonnet-4-6","usage":{"input_tokens":100,"cache_creation_input_tokens":20,"cache_read_input_tokens":50,"output_tokens":8},"content":[{"type":"text","text":"hi"}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"path":"a.go"}}]}} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"TodoWrite","input":{"plan":"do"}}]}} +{"type":"system","subtype":"title","title":"My Chat"} diff --git a/server/pkg/claude/transcript.go b/server/pkg/claude/transcript.go new file mode 100644 index 0000000..8382f01 --- /dev/null +++ b/server/pkg/claude/transcript.go @@ -0,0 +1,43 @@ +package claude + +// AppendUser 记下用户这一条。 +func AppendUser(sessionID, turnID, text string, input Input) error { + _, err := Get(sessionID) + _ = turnID + _ = text + _ = input + return err +} + +// AppendProgress 记下 Claude Code 的推理、正文、命令、改文件、方案或提示。 +func AppendProgress(sessionID, turnID string, item Progress) error { + if sessionID == "" { + return nil + } + _, err := Get(sessionID) + _ = turnID + _ = item + return err +} + +// Hydrate 按本机 Claude 已落下的记录回放。 +func Hydrate(sessionID string) ([]Progress, error) { + items, _, err := HydrateDetail(sessionID) + return items, err +} + +// HydrateDetail 回放实录并带上官方最后一次上下文用量。 +func HydrateDetail(sessionID string) ([]Progress, TokenUsage, error) { + if sessionID == "" { + return []Progress{}, TokenUsage{}, wrapErr(errInvalid, "session_id is required") + } + sess, err := Get(sessionID) + if err != nil { + return []Progress{}, TokenUsage{}, err + } + id := sess.ClaudeSessionID + if id == "" { + id = sess.ID + } + return ReadTranscriptAndUsage(id) +} diff --git a/server/pkg/claude/turn.go b/server/pkg/claude/turn.go new file mode 100644 index 0000000..2134945 --- /dev/null +++ b/server/pkg/claude/turn.go @@ -0,0 +1,129 @@ +package claude + +import "github.com/google/uuid" + +// Start 空闲则向 Claude Code 开新一轮;进行中则排队,不打断。 +func Start(sessionID, content string, input Input, mode InputMode) (string, error) { + if sessionID == "" { + return "", wrapErr(errInvalid, "session_id is required") + } + sess, err := Get(sessionID) + if err != nil { + return "", err + } + rt.mu.Lock() + archived := internLocked(sess.ID).Archived + active := internLocked(sess.ID).ActiveTurnID + rt.mu.Unlock() + if archived { + return "", wrapErr(errArchived, "session is archived") + } + if mode == InputModeQueue || active != "" { + return Queue(sessionID, content, input) + } + draft, err := TakeDraft(sessionID) + if err != nil { + return "", err + } + merged := mergeInput(content, input, draft) + settings, err := Effective(sessionID) + if err != nil { + return "", err + } + turnID := uuid.NewString() + if err := ClaimActiveTurn(sessionID, turnID); err != nil { + return "", err + } + sess, err = Get(sessionID) + if err != nil { + return "", err + } + claudeSessionID := sess.ClaudeSessionID + if claudeSessionID == "" { + claudeSessionID, err = StartSession(settings.Cwd, settings) + if err != nil { + _ = ClearActiveTurn(sessionID, turnID) + return "", err + } + if err := BindClaudeSession(sessionID, claudeSessionID); err != nil { + _ = ClearActiveTurn(sessionID, turnID) + return "", err + } + } else if err := ResumeSession(claudeSessionID); err != nil { + _ = ClearActiveTurn(sessionID, turnID) + return "", err + } + if err := startTurn(turnID, sessionID, claudeSessionID, merged, settings); err != nil { + _ = ClearActiveTurn(sessionID, turnID) + return "", err + } + if err := AppendUser(sessionID, turnID, content, merged); err != nil { + return "", err + } + return turnID, nil +} + +// Queue 等当前 Claude Code 一轮结束或被打断后再开。 +func Queue(sessionID, content string, input Input) (string, error) { + if sessionID == "" { + return "", wrapErr(errInvalid, "session_id is required") + } + if _, err := Get(sessionID); err != nil { + return "", err + } + draft, _ := TakeDraft(sessionID) + merged := mergeInput(content, input, draft) + turnID := uuid.NewString() + rt.mu.Lock() + internLocked(sessionID).Queue = append(internLocked(sessionID).Queue, queuedTurn{Content: content, Input: merged}) + rt.mu.Unlock() + if err := AppendUser(sessionID, turnID, content, merged); err != nil { + return "", err + } + return turnID, nil +} + +func drainQueue(sessionID string) { + rt.mu.Lock() + sess := internLocked(sessionID) + if sess.Archived || sess.ActiveTurnID != "" || len(sess.Queue) == 0 { + rt.mu.Unlock() + return + } + item := sess.Queue[0] + sess.Queue = sess.Queue[1:] + rt.mu.Unlock() + _, _ = Start(sessionID, item.Content, item.Input, InputModeStart) +} + +// Cancel 用户手动打断当前一轮,并向 Claude Code 传播取消。打断后再开排队里的下一条。 +func Cancel(turnID string) error { + if turnID == "" { + return nil + } + rt.mu.Lock() + turn := rt.turns[turnID] + sessionID := "" + claudeID := "" + if turn != nil { + sessionID = turn.SessionID + claudeID = turn.ClaudeSessionID + turn.Status = TurnCancelled + } + rt.mu.Unlock() + if turn == nil { + return nil + } + _ = Interrupt(claudeID, turnID) + _ = ClearActiveTurn(sessionID, turnID) + if sessionID != "" { + drainQueue(sessionID) + } + return nil +} + +// Continue 反问有了结果后让 Claude Code 继续。 +func Continue(turnID string) error { + _ = turnID + return nil +} diff --git a/server/pkg/claude/types.go b/server/pkg/claude/types.go new file mode 100644 index 0000000..7954838 --- /dev/null +++ b/server/pkg/claude/types.go @@ -0,0 +1,169 @@ +package claude + +// 本包对接本机 Claude Code。类型与内部拆分对齐;会话、实录、模型、权限档和配置从本机 Claude 读,不落库。 + +// EngineStatus 是本机 Claude Code 是否可用来开回合。 +type EngineStatus struct { + Available bool `json:"available"` + Authorized bool `json:"authorized"` + Version string `json:"version"` + Hint string `json:"hint"` // 不可用时给人看的原因,如未安装或未授权。 +} + +// ModelInfo 是一条 Claude Code 模型及其推理强度。 +type ModelInfo struct { + ID string `json:"id"` + Efforts []string `json:"efforts"` // 该 Claude 模型支持的推理强度。 + DefaultEffort string `json:"default_effort"` + Hidden bool `json:"hidden"` + IsDefault bool `json:"is_default"` +} + +// ModeInfo 是一条 Claude Code 官方权限档。 +type ModeInfo struct { + ID string `json:"id"` // default、acceptEdits、plan、bypassPermissions、auto、dontAsk;manual 视为 default。 + Kind string `json:"kind"` // permission。 +} + +// Session 是一条只走 Claude Code 的对话;字段从本机 Claude 读,不落本模块的库。 +type Session struct { + ID string `json:"id"` + ClaudeSessionID string `json:"claude_session_id"` // Claude session 编号,首次开回合后才有。 + Title string `json:"title"` + ActiveTurnID string `json:"active_turn_id"` // 同时只能有一个进行中的回合。 + Archived bool `json:"archived"` + CreatedAt int64 `json:"created_at"` // 本机 JSONL 第一条 timestamp,没有则用文件 mtime;Unix 秒。 + UpdatedAt int64 `json:"updated_at"` // 本机 JSONL 最后一条 timestamp,没有则用文件 mtime;Unix 秒。 +} + +// Settings 是这个对话里生效的 Claude 配置;只有改过的项交给 Claude Code。 +type Settings struct { + Model string `json:"model"` + Effort string `json:"effort"` + PermissionMode string `json:"permission_mode"` // Claude 的官方权限档。 + Cwd string `json:"cwd"` // 仓库根。 + Overridden []string `json:"overridden"` // 用户改过、需要交给 Claude Code 的字段名。 +} + +// CommandAction 是斜杠名与官方扩展按钮共用的动作种类。 +type CommandAction string // apply_settings | turn | session | attach | hint + +const ( + CommandApplySettings CommandAction = "apply_settings" + CommandTurn CommandAction = "turn" + CommandSession CommandAction = "session" + CommandAttach CommandAction = "attach" + CommandHint CommandAction = "hint" +) + +// CommandSpec 是一条与 Claude 斜杠、官方扩展按钮同名的命令。 +type CommandSpec struct { + Name string `json:"name"` // 与 Claude 斜杠同名,如 model、plan、fork、mcp。 + Action CommandAction `json:"action"` + Hint string `json:"hint"` // hint 时给人看的话。 +} + +// CommandResult 是不能在本模块落地时给人看的说明。 +type CommandResult struct { + Hint string `json:"hint"` +} + +// Input 是本条将要带给 Claude Code 的正文、文件提及和图片。 +type Input struct { + Text string `json:"text"` + Mentions []string `json:"mentions"` // 仓库内路径,对 Claude Code 的文件提及。 + Images []string `json:"images"` // 本地图片路径。 +} + +// InputMode 决定空闲则开新一轮,还是进行中只排队。 +type InputMode string // start | queue + +const ( + InputModeStart InputMode = "start" + InputModeQueue InputMode = "queue" +) + +// TurnStatus 是一轮 Claude Code 工作的状态。 +type TurnStatus string // queued | running | waiting_approval | completed | failed | cancelled + +const ( + TurnQueued TurnStatus = "queued" + TurnRunning TurnStatus = "running" + TurnWaitingApproval TurnStatus = "waiting_approval" + TurnCompleted TurnStatus = "completed" + TurnFailed TurnStatus = "failed" + TurnCancelled TurnStatus = "cancelled" +) + +// Turn 是一次用户请求对应的那一轮 Claude Code 工作。 +type Turn struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Status TurnStatus `json:"status"` +} + +// ProgressKind 是给人看的 Claude Code 进展种类。 +type ProgressKind string // user | text | reasoning | command | file_change | plan | notice + +const ( + ProgressKindUser ProgressKind = "user" + ProgressKindText ProgressKind = "text" + ProgressKindReasoning ProgressKind = "reasoning" + ProgressKindCommand ProgressKind = "command" + ProgressKindFileChange ProgressKind = "file_change" + ProgressKindPlan ProgressKind = "plan" + ProgressKindNotice ProgressKind = "notice" +) + +// Progress 是一条给人看、从本机 Claude 回放的进展。 +type Progress struct { + Kind ProgressKind `json:"kind"` + Text string `json:"text"` + Command string `json:"command"` + Paths []string `json:"paths"` + Diff string `json:"diff"` +} + +// TokenUsage 是一条对话当前占了多少上下文,对齐官方 statusline 的 context_window。 +type TokenUsage struct { + Used int64 `json:"used"` // input + cache_creation + cache_read。 + Window int64 `json:"window"` // 当前模型窗口;默认 200000,带 [1m] 为 1000000。 +} + +// DecisionScope 是人对已知反问的生效范围。 +type DecisionScope string // once | session + +const ( + DecisionOnce DecisionScope = "once" + DecisionSession DecisionScope = "session" +) + +// AskKind 是 Claude Code 已知的反问种类。 +type AskKind string // command | file_change | question | form + +const ( + AskKindCommand AskKind = "command" + AskKindFileChange AskKind = "file_change" + AskKindQuestion AskKind = "question" + AskKindForm AskKind = "form" +) + +// ApprovalAsk 是一条 Claude Code 已知的反问。 +type ApprovalAsk struct { + Kind AskKind `json:"kind"` + Command string `json:"command"` + Paths []string `json:"paths"` + Diff string `json:"diff"` + Prompt string `json:"prompt"` // 选择题或表单给人看的题面。 + Options []string `json:"options"` // 选择题的选项。 + Fields []string `json:"fields"` // MCP 表单字段名。 + ExternalRequestID string `json:"external_request_id"` // 用来回给 Claude Code 的那张问票。 +} + +// AskAnswer 是人对已知反问的作答。 +type AskAnswer struct { + Approved bool `json:"approved"` + Scope DecisionScope `json:"scope"` + Choice string `json:"choice"` // 选择题选中的项。 + Values []string `json:"values"` // 表单填写结果。 +} diff --git a/server/pkg/codex/command.go b/server/pkg/codex/command.go index d1078e0..e9f6ec0 100644 --- a/server/pkg/codex/command.go +++ b/server/pkg/codex/command.go @@ -6,7 +6,7 @@ type CommandAction string const ( ActionApplySettings CommandAction = "apply_settings" // 交给官方配置改值。 ActionTurn CommandAction = "turn" // 交给回合,如发送、打断。 - ActionSession CommandAction = "session" // 交给会话,如分叉、归档、改标题。 + ActionSession CommandAction = "session" // 交给会话,如 fork、归档、改标题。 ActionAttach CommandAction = "attach" // 交给附件,如挂文件、贴图。 ActionHint CommandAction = "hint" // 本模块不落地,只提示去终端改 Codex 配置。 )