+
);
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 配置。
)