From e51e21086baadbcca9617ad4d9070c40c2da9816 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E8=89=BA=E8=BD=A9?= Date: Tue, 26 May 2026 16:03:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(websession):=20=E4=BC=98=E5=8C=96Claude?= =?UTF-8?q?=E9=92=A9=E5=AD=90=E8=AE=BE=E7=BD=AE=E4=B8=8E=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加针对NativeSessionID的答案保存与删除支持,实现会话数据的双向镜像 - 用JavaScript脚本替代CCR配置文件注入,简化配置管理并支持动态合并Hook设置 - 更新构建Claude进程环境变量,确保CCR运行时使用Shim路径 - 移除旧的配置注入和钩子过滤代码,改用更灵活的Shim机制 - 新增单元测试验证会话答案镜像和Shim注入行为,增加Windows下模拟执行脚本支持 - 统一管理Claude代码路由钩子,避免重复注入及配置冲突 --- service/websession/claude_hooks.go | 250 ++++++++++++----------- service/websession/claude_stream.go | 2 +- service/websession/claude_stream_test.go | 140 +++++++------ service/websession/manager.go | 35 +++- service/websession/manager_test.go | 36 ++++ 5 files changed, 272 insertions(+), 191 deletions(-) diff --git a/service/websession/claude_hooks.go b/service/websession/claude_hooks.go index 15fec60..c6b6d9c 100644 --- a/service/websession/claude_hooks.go +++ b/service/websession/claude_hooks.go @@ -9,6 +9,7 @@ import ( "path/filepath" "strings" + "code-kanban/model/tables" "code-kanban/utils" ) @@ -79,6 +80,24 @@ func (m *Manager) writeClaudeHookAnswer( return os.WriteFile(m.store.claudeHookAnswerPath(sessionID, toolUseID), encoded, 0o644) } +func (m *Manager) writeClaudeHookAnswerForSession( + session tables.WebSessionTable, + toolUseID string, + payload claudeHookAnswerFile, +) error { + if err := m.writeClaudeHookAnswer(session.ID, toolUseID, payload); err != nil { + return err + } + nativeSessionID := "" + if session.NativeSessionID != nil { + nativeSessionID = strings.TrimSpace(*session.NativeSessionID) + } + if nativeSessionID == "" || nativeSessionID == strings.TrimSpace(session.ID) { + return nil + } + return m.writeClaudeHookAnswer(nativeSessionID, toolUseID, payload) +} + func (m *Manager) readClaudeHookAnswer(sessionID, toolUseID string) (claudeHookAnswerFile, error) { data, err := os.ReadFile(m.store.claudeHookAnswerPath(sessionID, toolUseID)) if err != nil { @@ -98,6 +117,18 @@ func (m *Manager) deleteClaudeHookAnswer(sessionID, toolUseID string) { _ = os.Remove(m.store.claudeHookAnswerPath(sessionID, toolUseID)) } +func (m *Manager) deleteClaudeHookAnswerForSession(session tables.WebSessionTable, toolUseID string) { + m.deleteClaudeHookAnswer(session.ID, toolUseID) + if session.NativeSessionID == nil { + return + } + nativeSessionID := strings.TrimSpace(*session.NativeSessionID) + if nativeSessionID == "" || nativeSessionID == strings.TrimSpace(session.ID) { + return + } + m.deleteClaudeHookAnswer(nativeSessionID, toolUseID) +} + func (m *Manager) ensureClaudeHookServer() (string, error) { m.claudeHookOnce.Do(func() { listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -136,54 +167,118 @@ func (m *Manager) ensureClaudeHookServer() (string, error) { } func (m *Manager) ensureCCRClaudeHookSettings() error { - if _, err := m.ensureClaudeHookServer(); err != nil { + if settingsPath, err := m.ensureClaudeHookServer(); err != nil { return err + } else if strings.TrimSpace(settingsPath) == "" { + return fmt.Errorf("claude hook settings path is not configured") } m.ccrHookMu.Lock() defer m.ccrHookMu.Unlock() if m.ccrHookReady { return nil } - m.ccrHookErr = m.writeCCRClaudeHookSettings() + m.ccrHookErr = m.writeCCRClaudeHookShim() if m.ccrHookErr == nil { m.ccrHookReady = true } return m.ccrHookErr } -func (m *Manager) writeCCRClaudeHookSettings() error { - if strings.TrimSpace(m.cfg.CCRConfigPath) == "" { - return fmt.Errorf("claude code router config path is not configured") - } - data, err := os.ReadFile(m.cfg.CCRConfigPath) - if err != nil { - return fmt.Errorf("read claude code router config: %w", err) +func (m *Manager) writeCCRClaudeHookShim() error { + if m.store == nil || strings.TrimSpace(m.store.rootDir) == "" { + return fmt.Errorf("web session store is not configured") } - var config map[string]any - if err := json.Unmarshal(data, &config); err != nil { - return fmt.Errorf("parse claude code router config: %w", err) - } - if config == nil { - config = map[string]any{} + shimDir := filepath.Join(m.store.rootDir, "claude-code-router") + if err := os.MkdirAll(shimDir, 0o755); err != nil { + return err } - claudeSettings, _ := config["claudeCodeSettings"].(map[string]any) - if claudeSettings == nil { - claudeSettings = map[string]any{} - config["claudeCodeSettings"] = claudeSettings + scriptPath := filepath.Join(shimDir, "claude-settings-shim.js") + cmdPath := filepath.Join(shimDir, "claude-settings-shim.cmd") + script := fmt.Sprintf(`const fs = require("fs"); +const { spawn } = require("child_process"); + +const realClaude = %q; +const hookSettingsPath = %q; + +function readJSON(path) { + return JSON.parse(fs.readFileSync(path, "utf8")); +} + +function mergeUniqueStrings(target, source) { + const seen = new Set((Array.isArray(target) ? target : []).filter(Boolean)); + for (const value of Array.isArray(source) ? source : []) { + if (value && !seen.has(value)) { + seen.add(value); + } + } + return Array.from(seen); +} + +function isCodeKanbanHook(hook) { + return hook && typeof hook === "object" && String(hook.url || hook.command || "").includes("/claude-hooks/pre-tool-use"); +} + +function mergeHooks(target, source) { + const next = target && typeof target === "object" && !Array.isArray(target) ? target : {}; + const incoming = source && typeof source === "object" && !Array.isArray(source) ? source : {}; + for (const [eventName, entries] of Object.entries(incoming)) { + const existing = Array.isArray(next[eventName]) ? next[eventName] : []; + const filtered = existing + .map((entry) => { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) return entry; + const hooks = entry.hooks.filter((hook) => !isCodeKanbanHook(hook)); + return hooks.length ? { ...entry, hooks } : null; + }) + .filter(Boolean); + next[eventName] = filtered.concat(Array.isArray(entries) ? entries : []); + } + return next; +} + +try { + const args = process.argv.slice(2); + const settingsIndex = args.lastIndexOf("--settings"); + if (settingsIndex >= 0 && args[settingsIndex + 1]) { + const settingsPath = args[settingsIndex + 1]; + const settings = readJSON(settingsPath); + const hookSettings = readJSON(hookSettingsPath); + settings.allowedHttpHookUrls = mergeUniqueStrings(settings.allowedHttpHookUrls, hookSettings.allowedHttpHookUrls); + settings.hooks = mergeHooks(settings.hooks, hookSettings.hooks); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n"); + } +} catch (error) { + console.error("CodeKanban Claude settings shim failed:", error && error.message ? error.message : error); +} + +const child = spawn(realClaude, process.argv.slice(2), { + stdio: "inherit", + env: process.env, +}); +child.on("exit", (code, signal) => { + if (signal) process.kill(process.pid, signal); + process.exit(code || 0); +}); +child.on("error", (error) => { + console.error(error && error.message ? error.message : error); + process.exit(1); +}); +`, m.cfg.ClaudePath, m.claudeHookSettingsPath) + if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { + return err } - injectClaudeHookSettings(claudeSettings, m.claudeHookBaseURL, m.claudeHookToken) - encoded, err := json.MarshalIndent(config, "", " ") - if err != nil { + cmd := fmt.Sprintf("@echo off\r\nnode \"%%~dp0%s\" %%*\r\nexit /b %%ERRORLEVEL%%\r\n", filepath.Base(scriptPath)) + if err := os.WriteFile(cmdPath, []byte(cmd), 0o755); err != nil { return err } - encoded = append(encoded, '\n') - return os.WriteFile(m.cfg.CCRConfigPath, encoded, 0o644) + m.ccrHookClaudePath = cmdPath + return nil } func (m *Manager) claudeHookSettings() map[string]any { + hookURL := codeKanbanClaudeHookURL(m.claudeHookBaseURL) return map[string]any{ "allowedHttpHookUrls": []string{ - m.claudeHookBaseURL, + hookURL, }, "hooks": map[string]any{ "PreToolUse": codeKanbanClaudeHookEntries(m.claudeHookBaseURL, m.claudeHookToken), @@ -191,14 +286,19 @@ func (m *Manager) claudeHookSettings() map[string]any { } } +func codeKanbanClaudeHookURL(baseURL string) string { + return baseURL + "/claude-hooks/pre-tool-use" +} + func codeKanbanClaudeHookEntries(baseURL, token string) []map[string]any { + hookURL := codeKanbanClaudeHookURL(baseURL) return []map[string]any{ { "matcher": "AskUserQuestion", "hooks": []map[string]any{ { "type": "http", - "url": baseURL + "/claude-hooks/pre-tool-use", + "url": hookURL, "headers": map[string]any{ "Authorization": "Bearer " + token, }, @@ -210,7 +310,7 @@ func codeKanbanClaudeHookEntries(baseURL, token string) []map[string]any { "hooks": []map[string]any{ { "type": "http", - "url": baseURL + "/claude-hooks/pre-tool-use", + "url": hookURL, "headers": map[string]any{ "Authorization": "Bearer " + token, }, @@ -220,102 +320,6 @@ func codeKanbanClaudeHookEntries(baseURL, token string) []map[string]any { } } -func injectClaudeHookSettings(settings map[string]any, baseURL, token string) { - settings["allowedHttpHookUrls"] = appendUniqueStringValues(settings["allowedHttpHookUrls"], baseURL) - hooks, _ := settings["hooks"].(map[string]any) - if hooks == nil { - hooks = map[string]any{} - settings["hooks"] = hooks - } - existingEntries, _ := hooks["PreToolUse"].([]any) - filtered := make([]any, 0, len(existingEntries)+2) - for _, entry := range existingEntries { - entryMap, ok := entry.(map[string]any) - if !ok { - filtered = append(filtered, entry) - continue - } - matcher, _ := entryMap["matcher"].(string) - if matcher == "AskUserQuestion" || matcher == "ExitPlanMode" { - if cleaned, keep := removeCodeKanbanClaudeHooks(entryMap); keep { - filtered = append(filtered, cleaned) - } - continue - } - filtered = append(filtered, entry) - } - for _, entry := range codeKanbanClaudeHookEntries(baseURL, token) { - filtered = append(filtered, entry) - } - hooks["PreToolUse"] = filtered -} - -func removeCodeKanbanClaudeHooks(entry map[string]any) (map[string]any, bool) { - rawHooks, ok := entry["hooks"].([]any) - if !ok { - return entry, true - } - filteredHooks := make([]any, 0, len(rawHooks)) - for _, hook := range rawHooks { - if isCodeKanbanClaudeHook(hook) { - continue - } - filteredHooks = append(filteredHooks, hook) - } - if len(filteredHooks) == 0 { - return nil, false - } - cleaned := make(map[string]any, len(entry)) - for key, value := range entry { - cleaned[key] = value - } - cleaned["hooks"] = filteredHooks - return cleaned, true -} - -func isCodeKanbanClaudeHook(hook any) bool { - hookMap, ok := hook.(map[string]any) - if !ok { - return false - } - hookURL, _ := hookMap["url"].(string) - return strings.Contains(hookURL, "/claude-hooks/pre-tool-use") -} - -func appendUniqueStringValues(current any, values ...string) []string { - result := []string{} - seen := map[string]struct{}{} - add := func(value string) { - value = strings.TrimSpace(value) - if value == "" { - return - } - if _, ok := seen[value]; ok { - return - } - seen[value] = struct{}{} - result = append(result, value) - } - switch typed := current.(type) { - case []any: - for _, item := range typed { - if value, ok := item.(string); ok { - add(value) - } - } - case []string: - for _, value := range typed { - add(value) - } - case string: - add(typed) - } - for _, value := range values { - add(value) - } - return result -} - func (m *Manager) handleClaudePreToolUseHook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/service/websession/claude_stream.go b/service/websession/claude_stream.go index e4fbb68..064969d 100644 --- a/service/websession/claude_stream.go +++ b/service/websession/claude_stream.go @@ -195,7 +195,7 @@ func (m *Manager) buildClaudeResumeCommand(ctx context.Context, session tables.W } cmd := m.buildClaudeCommand(ctx, claudeRuntime, args) cmd.Dir = session.Cwd - cmd.Env = os.Environ() + cmd.Env = m.claudeCommandEnv(claudeRuntime) return cmd, nil } diff --git a/service/websession/claude_stream_test.go b/service/websession/claude_stream_test.go index b3fb1a8..e81e65f 100644 --- a/service/websession/claude_stream_test.go +++ b/service/websession/claude_stream_test.go @@ -17,6 +17,16 @@ import ( "go.uber.org/zap" ) +func envContainsValue(env []string, key, value string) bool { + prefix := key + "=" + for _, item := range env { + if item == prefix+value { + return true + } + } + return false +} + func TestBuildExecCommandClaudeUsesStreamJSONInput(t *testing.T) { store, err := newStore(t.TempDir()) if err != nil { @@ -73,34 +83,8 @@ func TestBuildExecCommandClaudeRouterUsesCCRCodeAndInjectsHookSettings(t *testin if err != nil { t.Fatalf("newStore returned error: %v", err) } - ccrConfigPath := filepath.Join(t.TempDir(), "config.json") - initialConfig := `{ - "Router": {"default": "provider,model"}, - "claudeCodeSettings": { - "allowedHttpHookUrls": ["https://example.com/custom"], - "hooks": { - "PreToolUse": [ - { - "matcher": "AskUserQuestion", - "hooks": [ - {"type": "http", "url": "https://example.com/custom-ask"} - ] - }, - { - "matcher": "ExitPlanMode", - "hooks": [ - {"type": "http", "url": "http://127.0.0.1:1/claude-hooks/pre-tool-use"} - ] - } - ] - } - } -}` - if err := os.WriteFile(ccrConfigPath, []byte(initialConfig), 0o644); err != nil { - t.Fatalf("failed to write CCR config: %v", err) - } manager := &Manager{ - cfg: Config{DataDir: t.TempDir(), ClaudePath: "claude", CCRPath: "ccr", CCRConfigPath: ccrConfigPath}, + cfg: Config{DataDir: t.TempDir(), ClaudePath: "claude", CCRPath: "ccr"}, store: store, logger: zap.NewNop(), runs: map[string]*activeRun{}, @@ -133,58 +117,54 @@ func TestBuildExecCommandClaudeRouterUsesCCRCodeAndInjectsHookSettings(t *testin } } if strings.Contains(joinedArgs, "--settings") { - t.Fatalf("expected CCR runtime to let ccr generate one merged settings file, got %v", cmd.Args) + t.Fatalf("expected CCR runtime to let ccr code own --settings args, got %v", cmd.Args) } - data, err := os.ReadFile(ccrConfigPath) + if manager.ccrHookClaudePath == "" { + t.Fatalf("expected CCR runtime to configure a Claude settings shim") + } + if !envContainsValue(cmd.Env, "CLAUDE_PATH", manager.ccrHookClaudePath) { + t.Fatalf("expected CCR runtime to pass shim via CLAUDE_PATH, got %v", cmd.Env) + } + shimData, err := os.ReadFile(strings.TrimSuffix(manager.ccrHookClaudePath, ".cmd") + ".js") if err != nil { - t.Fatalf("failed to read CCR config: %v", err) + t.Fatalf("failed to read CodeKanban CCR shim: %v", err) } - if !strings.Contains(string(data), "claudeCodeSettings") || - !strings.Contains(string(data), "AskUserQuestion") || - !strings.Contains(string(data), "ExitPlanMode") || - !strings.Contains(string(data), "allowedHttpHookUrls") { - t.Fatalf("expected CCR config to include injected CodeKanban hooks, got %s", string(data)) + if !strings.Contains(string(shimData), "settings.allowedHttpHookUrls = mergeUniqueStrings") || + !strings.Contains(string(shimData), "settings.hooks = mergeHooks") { + t.Fatalf("expected CodeKanban CCR shim to merge hook settings, got %s", string(shimData)) + } + settingsData, err := os.ReadFile(manager.claudeHookSettingsPath) + if err != nil { + t.Fatalf("failed to read Claude hook settings: %v", err) } - if !strings.Contains(string(data), "https://example.com/custom-ask") { - t.Fatalf("expected existing user hook to be preserved, got %s", string(data)) + if strings.Contains(string(settingsData), `"http://127.0.0.1:`) && + !strings.Contains(string(settingsData), "/claude-hooks/pre-tool-use") { + t.Fatalf("expected allowedHttpHookUrls to allow the full hook URL, got %s", string(settingsData)) } - if strings.Contains(string(data), "http://127.0.0.1:1/claude-hooks/pre-tool-use") { - t.Fatalf("expected stale CodeKanban hook to be replaced, got %s", string(data)) + if !strings.Contains(string(settingsData), `"matcher":"AskUserQuestion"`) || + !strings.Contains(string(settingsData), `"matcher":"ExitPlanMode"`) { + t.Fatalf("expected hook settings for AskUserQuestion and ExitPlanMode, got %s", string(settingsData)) } } -func TestEnsureCCRClaudeHookSettingsRetriesAfterFailure(t *testing.T) { +func TestEnsureCCRClaudeHookSettingsDoesNotRequireCCRConfig(t *testing.T) { store, err := newStore(t.TempDir()) if err != nil { t.Fatalf("newStore returned error: %v", err) } - ccrConfigPath := filepath.Join(t.TempDir(), "missing", "config.json") manager := &Manager{ - cfg: Config{DataDir: t.TempDir(), ClaudePath: "claude", CCRPath: "ccr", CCRConfigPath: ccrConfigPath}, + cfg: Config{DataDir: t.TempDir(), ClaudePath: "claude", CCRPath: "ccr", CCRConfigPath: filepath.Join(t.TempDir(), "missing", "config.json")}, store: store, logger: zap.NewNop(), runs: map[string]*activeRun{}, clients: map[*client]struct{}{}, } - if err := manager.ensureCCRClaudeHookSettings(); err == nil { - t.Fatal("expected first CCR hook settings write to fail") - } - if err := os.MkdirAll(filepath.Dir(ccrConfigPath), 0o755); err != nil { - t.Fatalf("failed to create CCR config dir: %v", err) - } - if err := os.WriteFile(ccrConfigPath, []byte(`{"Router":{"default":"provider,model"}}`), 0o644); err != nil { - t.Fatalf("failed to write CCR config: %v", err) - } if err := manager.ensureCCRClaudeHookSettings(); err != nil { - t.Fatalf("expected second CCR hook settings write to retry and succeed, got %v", err) - } - data, err := os.ReadFile(ccrConfigPath) - if err != nil { - t.Fatalf("failed to read CCR config: %v", err) + t.Fatalf("expected CCR hook setup to use the shim without reading CCR config, got %v", err) } - if !strings.Contains(string(data), "claudeCodeSettings") { - t.Fatalf("expected CCR config to include injected settings after retry, got %s", string(data)) + if manager.ccrHookClaudePath == "" { + t.Fatal("expected CCR hook setup to configure the Claude shim path") } } @@ -636,6 +616,44 @@ func TestClaudeHookServerDefersAndAllowsAskUserQuestion(t *testing.T) { } } +func TestClaudeHookAnswerIsMirroredToNativeSessionID(t *testing.T) { + store, err := newStore(t.TempDir()) + if err != nil { + t.Fatalf("newStore returned error: %v", err) + } + manager := &Manager{store: store} + nativeSessionID := "claude-native-session" + session := tables.WebSessionTable{ + NativeSessionID: &nativeSessionID, + } + session.ID = "web-session-1" + payload := claudeHookAnswerFile{ + Answers: map[string]string{ + "What should happen next?": "Implement", + }, + } + + if err := manager.writeClaudeHookAnswerForSession(session, "tool-1", payload); err != nil { + t.Fatalf("writeClaudeHookAnswerForSession returned error: %v", err) + } + for _, sessionID := range []string{session.ID, nativeSessionID} { + answer, err := manager.readClaudeHookAnswer(sessionID, "tool-1") + if err != nil { + t.Fatalf("readClaudeHookAnswer(%q) returned error: %v", sessionID, err) + } + if got := answer.Answers["What should happen next?"]; got != "Implement" { + t.Fatalf("expected mirrored answer for %q, got %#v", sessionID, answer.Answers) + } + } + + manager.deleteClaudeHookAnswerForSession(session, "tool-1") + for _, sessionID := range []string{session.ID, nativeSessionID} { + if _, err := os.Stat(manager.store.claudeHookAnswerPath(sessionID, "tool-1")); !os.IsNotExist(err) { + t.Fatalf("expected answer for %q to be cleaned up, got err=%v", sessionID, err) + } + } +} + func TestRespondToUserInputClaudeResumesAfterDeferredTool(t *testing.T) { cleanup := initTestDB(t) defer cleanup() @@ -706,6 +724,12 @@ func TestRespondToUserInputClaudeResumesAfterDeferredTool(t *testing.T) { if _, err := os.Stat(manager.store.claudeHookAnswerPath(created.ID, "tool_ask_resume")); !os.IsNotExist(err) { t.Fatalf("expected deferred answer file to be cleaned up, got err=%v", err) } + if record.NativeSessionID == nil || strings.TrimSpace(*record.NativeSessionID) == "" { + t.Fatalf("expected native Claude session id to be stored") + } + if _, err := os.Stat(manager.store.claudeHookAnswerPath(*record.NativeSessionID, "tool_ask_resume")); !os.IsNotExist(err) { + t.Fatalf("expected native deferred answer file to be cleaned up, got err=%v", err) + } } func httpPostJSON(url string, auth string, body map[string]any) (map[string]any, error) { diff --git a/service/websession/manager.go b/service/websession/manager.go index 5503341..11d4905 100644 --- a/service/websession/manager.go +++ b/service/websession/manager.go @@ -83,6 +83,7 @@ type Manager struct { ccrHookMu sync.Mutex ccrHookReady bool ccrHookErr error + ccrHookClaudePath string } type clientKind string @@ -2714,7 +2715,7 @@ func (m *Manager) runSession(ctx context.Context, run *activeRun, session tables ) if run.deferredUserInput { if pending, ok := run.pendingUserInputRequest(); ok { - m.deleteClaudeHookAnswer(session.ID, pending.ItemID) + m.deleteClaudeHookAnswerForSession(session, pending.ItemID) } } m.cancelAutoRetryTimer(session.ID) @@ -2833,7 +2834,7 @@ func (m *Manager) runClaudeResumeSession(ctx context.Context, run *activeRun, se ) if run.deferredUserInput { if pending, ok := run.pendingUserInputRequest(); ok { - m.deleteClaudeHookAnswer(session.ID, pending.ItemID) + m.deleteClaudeHookAnswerForSession(session, pending.ItemID) } } m.broadcastSessionSummary(context.Background(), session.ID) @@ -3100,9 +3101,6 @@ func (m *Manager) handleClaudeEvent(session tables.WebSessionTable, run *activeR toolID = utils.NewID() } toolName := strings.TrimSpace(stringValue(block["name"])) - if toolName == "AskUserQuestion" { - continue - } if toolName == "ExitPlanMode" { run.markCompletedPlanTool() input := decodeRawObject(block["input"]) @@ -3309,11 +3307,11 @@ func (m *Manager) handleClaudeUserEvent(session tables.WebSessionTable, run *act continue } if pending, ok := run.pendingUserInputRequest(); ok && strings.TrimSpace(pending.ItemID) == toolUseID { - run.clearPendingServerRequest() contentText := strings.TrimSpace(claudeToolResultContentText(block["content"])) if contentText == "" { contentText = claudeToolUseResultSummary(raw["toolUseResult"]) } + run.clearPendingServerRequest() payload := map[string]any{ "iid": toolUseID, } @@ -3958,7 +3956,7 @@ func (m *Manager) buildExecCommand(ctx context.Context, session tables.WebSessio } cmd := m.buildClaudeCommand(ctx, claudeRuntime, args) cmd.Dir = session.Cwd - cmd.Env = os.Environ() + cmd.Env = m.claudeCommandEnv(claudeRuntime) return cmd, stdin, true, nil case AgentCodex: args := []string{"exec", "--json", "--skip-git-repo-check"} @@ -4021,6 +4019,25 @@ func (m *Manager) buildClaudeCommand(ctx context.Context, runtime ClaudeRuntime, return exec.CommandContext(ctx, m.cfg.ClaudePath, args...) } +func (m *Manager) claudeCommandEnv(runtime ClaudeRuntime) []string { + env := os.Environ() + if normalizeClaudeRuntime(runtime) != ClaudeRuntimeCCR || strings.TrimSpace(m.ccrHookClaudePath) == "" { + return env + } + return upsertEnv(env, "CLAUDE_PATH", m.ccrHookClaudePath) +} + +func upsertEnv(env []string, key, value string) []string { + prefix := key + "=" + for i, item := range env { + if strings.HasPrefix(item, prefix) { + env[i] = prefix + value + return env + } + } + return append(env, prefix+value) +} + func (m *Manager) respondToApproval(sessionID, action string) error { m.mu.RLock() run, ok := m.runs[sessionID] @@ -4047,7 +4064,7 @@ func (m *Manager) respondToApproval(sessionID, action string) error { if action != "reject" { decision = "allow" } - if err := m.writeClaudeHookAnswer(sessionID, pending.ItemID, claudeHookAnswerFile{ + if err := m.writeClaudeHookAnswerForSession(record, pending.ItemID, claudeHookAnswerFile{ PermissionDecision: decision, }); err != nil { return err @@ -4174,7 +4191,7 @@ func (m *Manager) respondToUserInput(sessionID, itemID string, answers map[strin if len(answerFile.Answers) == 0 { return fmt.Errorf("no answers were provided") } - if err := m.writeClaudeHookAnswer(sessionID, pending.ItemID, answerFile); err != nil { + if err := m.writeClaudeHookAnswerForSession(record, pending.ItemID, answerFile); err != nil { return err } if err := m.startClaudeDeferredResume(context.Background(), record, pending); err != nil { diff --git a/service/websession/manager_test.go b/service/websession/manager_test.go index c5a18d7..e1fd84c 100644 --- a/service/websession/manager_test.go +++ b/service/websession/manager_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -4423,6 +4424,41 @@ cat >/dev/null func writeFakeClaudeDeferredCLI(t *testing.T) string { t.Helper() + if runtime.GOOS == "windows" { + dir := t.TempDir() + stateFile := filepath.Join(dir, "claude-deferred-state.txt") + psPath := filepath.Join(dir, "fake-claude-deferred.ps1") + cmdPath := filepath.Join(dir, "fake-claude-deferred.cmd") + psStateFile := strings.ReplaceAll(stateFile, "'", "''") + script := `$stateFile = '` + psStateFile + `' +[Console]::In.ReadToEnd() | Out-Null +$count = 0 +if (Test-Path -LiteralPath $stateFile) { + $raw = Get-Content -LiteralPath $stateFile -Raw + [int]::TryParse(($raw.Trim()), [ref]$count) | Out-Null +} +$count += 1 +Set-Content -LiteralPath $stateFile -Value $count -NoNewline +if ($count -eq 1) { + Write-Output '{"type":"system","subtype":"init","session_id":"claude-session-test"}' + Write-Output '{"type":"assistant","uuid":"assistant_tool","message":{"type":"message","role":"assistant","id":"assistant_tool_msg","content":[{"type":"tool_use","id":"tool_ask_resume","name":"AskUserQuestion","input":{"questions":[{"header":"Direction","question":"What should happen next?","multiSelect":false,"options":[{"label":"Implement","description":"Start coding now."},{"label":"Plan","description":"Stay in planning mode."}]}]}}],"stop_reason":"tool_use"}}' + Write-Output '{"type":"result","session_id":"claude-session-test","stop_reason":"tool_deferred","deferred_tool_use":{"id":"tool_ask_resume","name":"AskUserQuestion","input":{"questions":[{"header":"Direction","question":"What should happen next?","multiSelect":false,"options":[{"label":"Implement","description":"Start coding now."},{"label":"Plan","description":"Stay in planning mode."}]}]}}}' + exit 0 +} +Write-Output '{"type":"system","subtype":"init","session_id":"claude-session-test"}' +Write-Output '{"type":"assistant","uuid":"assistant_done","message":{"type":"message","role":"assistant","id":"assistant_done_msg","content":[{"type":"text","text":"continuing after the answer"}],"stop_reason":"end_turn"}}' +Write-Output '{"type":"result","session_id":"claude-session-test","stop_reason":"end_turn"}' +` + if err := os.WriteFile(psPath, []byte(script), 0o644); err != nil { + t.Fatalf("write fake claude deferred ps1 failed: %v", err) + } + cmd := "@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0fake-claude-deferred.ps1\"\r\nexit /b %ERRORLEVEL%\r\n" + if err := os.WriteFile(cmdPath, []byte(cmd), 0o755); err != nil { + t.Fatalf("write fake claude deferred cmd failed: %v", err) + } + return cmdPath + } + path := filepath.Join(t.TempDir(), "fake-claude-deferred.sh") script := `#!/bin/sh state_file="` + filepath.Join(t.TempDir(), "claude-deferred-state.txt") + `"