From 6253c19ae28b3dc9ff35aab27982e759982d78fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E8=89=BA=E8=BD=A9?= Date: Mon, 25 May 2026 14:08:16 +0800 Subject: [PATCH] feat(web-session): support Claude Code Router runtime --- api/web_session.go | 2 + model/sqlc_gen/schema_sqlite.sql | 3 +- model/tables/web_session.go | 1 + packages/codekanban-cli/src/core.js | 1 + packages/codekanban-cli/src/runtime.js | 17 +- packages/codekanban-cli/test/runtime.test.js | 53 +++++ packages/node-sdk/src/client.js | 2 + packages/node-sdk/src/command-builder.js | 6 +- .../src/web-session-command-channel.js | 11 + packages/node-sdk/src/web-session-shared.js | 1 + packages/node-sdk/test/client.test.js | 27 +++ .../node-sdk/test/command-builder.test.js | 24 ++ service/websession/claude_hooks.go | 215 +++++++++++++++--- service/websession/claude_stream.go | 19 +- service/websession/claude_stream_test.go | 123 ++++++++++ service/websession/manager.go | 111 ++++++++- service/websession/store.go | 5 +- service/websession/types.go | 9 + service/websession/wire.go | 2 + static/index.html | 4 +- ui/src/api/webSession.ts | 2 + ui/src/components/kanban/KanbanBoard.vue | 12 +- .../terminal/AISessionHistoryDialog.vue | 4 +- ui/src/components/terminal/TerminalPanel.vue | 19 +- .../web-session/WebSessionPanel.vue | 85 +++++++ .../web-session/webSessionModelOptions.ts | 6 + ui/src/stores/settings.ts | 18 ++ ui/src/stores/webSession.ts | 8 + ui/src/types/models.ts | 1 + utils/ai_assistant2/detector.go | 5 + utils/ai_assistant2/detector_test.go | 15 ++ 31 files changed, 750 insertions(+), 61 deletions(-) diff --git a/api/web_session.go b/api/web_session.go index 8ef0608..6ee8098 100644 --- a/api/web_session.go +++ b/api/web_session.go @@ -200,6 +200,7 @@ func (c *webSessionController) registerHTTP(app *fiber.App, group *huma.Group) { Body struct { WorktreeID string `json:"worktreeId"` Agent string `json:"agent"` + ClaudeRuntime string `json:"claudeRuntime"` Model string `json:"model"` ReasoningEffort string `json:"reasoningEffort"` WorkflowMode string `json:"workflowMode"` @@ -243,6 +244,7 @@ func (c *webSessionController) registerHTTP(app *fiber.App, group *huma.Group) { ProjectID: input.ProjectID, WorktreeID: input.Body.WorktreeID, Agent: websession.Agent(input.Body.Agent), + ClaudeRuntime: websession.ClaudeRuntime(input.Body.ClaudeRuntime), Model: input.Body.Model, ReasoningEffort: websession.ReasoningEffort(input.Body.ReasoningEffort), WorkflowMode: workflowMode, diff --git a/model/sqlc_gen/schema_sqlite.sql b/model/sqlc_gen/schema_sqlite.sql index 719829a..afe0c68 100644 --- a/model/sqlc_gen/schema_sqlite.sql +++ b/model/sqlc_gen/schema_sqlite.sql @@ -54,7 +54,7 @@ CREATE UNIQUE INDEX "idx_session_type" ON "ai_sessions"("session_id","type"); CREATE INDEX "idx_ai_sessions_deleted_at" ON "ai_sessions"("deleted_at"); -CREATE TABLE "web_sessions" ("id" text NOT NULL,"created_at" datetime,"updated_at" datetime,"deleted_at" datetime,"project_id" text NOT NULL,"worktree_id" text,"order_index" real NOT NULL DEFAULT 0,"agent" text NOT NULL,"backend" text NOT NULL DEFAULT "legacy_exec","title" text NOT NULL,"title_auto" boolean NOT NULL DEFAULT false,"model" text,"reasoning_effort" text,"workflow_mode" text NOT NULL DEFAULT "default","permission_level" text NOT NULL DEFAULT "elevated","auto_retry_enabled" boolean NOT NULL DEFAULT false,"auto_retry_scope" text NOT NULL DEFAULT "network_only","auto_retry_preset" text NOT NULL DEFAULT "gentle_stop","permission_mode" text,"cwd" text NOT NULL,"native_session_id" text,"status" text NOT NULL,"assistant_state" text,"has_unread" boolean NOT NULL DEFAULT false,"archived_at" datetime,"activity_at" datetime,"status_updated_at" datetime,"assistant_state_updated_at" datetime,"source_kind" text NOT NULL DEFAULT "codex_app_server","sync_state" text NOT NULL DEFAULT "missing","last_sync_mode" text,"source_created_at" datetime,"source_updated_at" datetime,"last_synced_at" datetime,"thread_path" text,"thread_preview" text,"turn_count" integer NOT NULL DEFAULT 0,"item_count" integer NOT NULL DEFAULT 0,"last_message_at" datetime,"last_event_seq" integer NOT NULL DEFAULT 0,"total_input_tokens" integer NOT NULL DEFAULT 0,"total_cached_input_tokens" integer NOT NULL DEFAULT 0,"total_output_tokens" integer NOT NULL DEFAULT 0,"total_cost" real NOT NULL DEFAULT 0,"last_completed_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_cached_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_usage_updated_at" datetime,"latest_token_count_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_output_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_total_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_updated_at" datetime,"session_context_window_tokens" integer NOT NULL DEFAULT 0,"session_context_window_observed_at" datetime,"context_baseline_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_cached_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_output_tokens" integer NOT NULL DEFAULT 0,"last_context_compaction_at" datetime,"auto_retry_attempt" integer NOT NULL DEFAULT 0,"auto_retry_next_at" datetime,"auto_retry_last_error_code" text,"last_error" text,"sync_error" text,PRIMARY KEY ("id")); +CREATE TABLE "web_sessions" ("id" text NOT NULL,"created_at" datetime,"updated_at" datetime,"deleted_at" datetime,"project_id" text NOT NULL,"worktree_id" text,"order_index" real NOT NULL DEFAULT 0,"agent" text NOT NULL,"claude_runtime" text NOT NULL DEFAULT "claude","backend" text NOT NULL DEFAULT "legacy_exec","title" text NOT NULL,"title_auto" boolean NOT NULL DEFAULT false,"model" text,"reasoning_effort" text,"workflow_mode" text NOT NULL DEFAULT "default","permission_level" text NOT NULL DEFAULT "elevated","auto_retry_enabled" boolean NOT NULL DEFAULT false,"auto_retry_scope" text NOT NULL DEFAULT "network_only","auto_retry_preset" text NOT NULL DEFAULT "gentle_stop","permission_mode" text,"cwd" text NOT NULL,"native_session_id" text,"status" text NOT NULL,"assistant_state" text,"has_unread" boolean NOT NULL DEFAULT false,"archived_at" datetime,"activity_at" datetime,"status_updated_at" datetime,"assistant_state_updated_at" datetime,"source_kind" text NOT NULL DEFAULT "codex_app_server","sync_state" text NOT NULL DEFAULT "missing","last_sync_mode" text,"source_created_at" datetime,"source_updated_at" datetime,"last_synced_at" datetime,"thread_path" text,"thread_preview" text,"turn_count" integer NOT NULL DEFAULT 0,"item_count" integer NOT NULL DEFAULT 0,"last_message_at" datetime,"last_event_seq" integer NOT NULL DEFAULT 0,"total_input_tokens" integer NOT NULL DEFAULT 0,"total_cached_input_tokens" integer NOT NULL DEFAULT 0,"total_output_tokens" integer NOT NULL DEFAULT 0,"total_cost" real NOT NULL DEFAULT 0,"last_completed_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_cached_input_tokens" integer NOT NULL DEFAULT 0,"last_completed_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_turn_output_tokens" integer NOT NULL DEFAULT 0,"latest_turn_usage_updated_at" datetime,"latest_token_count_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_cached_input_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_output_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_total_tokens" integer NOT NULL DEFAULT 0,"latest_token_count_updated_at" datetime,"session_context_window_tokens" integer NOT NULL DEFAULT 0,"session_context_window_observed_at" datetime,"context_baseline_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_cached_input_tokens" integer NOT NULL DEFAULT 0,"context_baseline_output_tokens" integer NOT NULL DEFAULT 0,"last_context_compaction_at" datetime,"auto_retry_attempt" integer NOT NULL DEFAULT 0,"auto_retry_next_at" datetime,"auto_retry_last_error_code" text,"last_error" text,"sync_error" text,PRIMARY KEY ("id")); CREATE INDEX "idx_web_sessions_source_updated_at" ON "web_sessions"("source_updated_at"); CREATE INDEX "idx_web_sessions_sync_state" ON "web_sessions"("sync_state"); CREATE INDEX "idx_web_sessions_status_updated_at" ON "web_sessions"("status_updated_at"); @@ -102,4 +102,3 @@ CREATE INDEX "idx_task_ai_sessions_ai_session_id" ON "task_ai_sessions"("ai_sess CREATE UNIQUE INDEX "idx_task_ai_session" ON "task_ai_sessions"("task_id","ai_session_id"); CREATE INDEX "idx_task_ai_sessions_task_id" ON "task_ai_sessions"("task_id"); CREATE INDEX "idx_task_ai_sessions_deleted_at" ON "task_ai_sessions"("deleted_at"); - diff --git a/model/tables/web_session.go b/model/tables/web_session.go index ad76589..e466b42 100644 --- a/model/tables/web_session.go +++ b/model/tables/web_session.go @@ -16,6 +16,7 @@ type WebSessionTable struct { OrderIndex float64 `gorm:"type:real;not null;default:0;index" json:"orderIndex"` Agent string `gorm:"type:text;not null;index" json:"agent"` + ClaudeRuntime string `gorm:"type:text;not null;default:claude" json:"claudeRuntime"` Backend string `gorm:"type:text;not null;default:legacy_exec" json:"-"` Title string `gorm:"type:text;not null" json:"title"` TitleAuto bool `gorm:"type:boolean;not null;default:false" json:"-"` diff --git a/packages/codekanban-cli/src/core.js b/packages/codekanban-cli/src/core.js index c862eb2..82c0a27 100644 --- a/packages/codekanban-cli/src/core.js +++ b/packages/codekanban-cli/src/core.js @@ -20,6 +20,7 @@ const VALUE_FLAGS = new Set([ '--prompt', '--text', '--agent', + '--claude-runtime', '--model', '--profile', '--sandbox', diff --git a/packages/codekanban-cli/src/runtime.js b/packages/codekanban-cli/src/runtime.js index 65c5314..d259262 100644 --- a/packages/codekanban-cli/src/runtime.js +++ b/packages/codekanban-cli/src/runtime.js @@ -12,6 +12,14 @@ function readFlagValue(argv, index, flag) { return value; } +function readRawFlagValue(argv, index, flag) { + const value = argv[index + 1]; + if (value == null) { + throw new Error(`${flag} requires a value`); + } + return value; +} + function parseIntegerFlag(value, fieldName) { if (value == null || value === '') { return undefined; @@ -83,6 +91,10 @@ function parseCliArgs(argv) { flags.agent = readFlagValue(argv, index, token); index += 1; break; + case '--claude-runtime': + flags.claudeRuntime = readFlagValue(argv, index, token); + index += 1; + break; case '--model': flags.model = readFlagValue(argv, index, token); index += 1; @@ -108,7 +120,7 @@ function parseCliArgs(argv) { index += 1; break; case '--extra-arg': - flags.extraArgs.push(readFlagValue(argv, index, token)); + flags.extraArgs.push(readRawFlagValue(argv, index, token)); index += 1; break; case '--title': @@ -282,6 +294,7 @@ Common options: --project-id Project identifier --path Local project path --session-id Session identifier + --claude-runtime Claude launcher for workflow terminal mode: claude or ccr --help Show this help text Examples: @@ -620,6 +633,7 @@ export async function runCli(argv, options = {}) { if (scope === 'workflow' && action === 'command') { const result = buildAgentLaunchSpec({ agent: flags.agent, + claudeRuntime: flags.claudeRuntime, profile: flags.profile, permissions: buildPermissions(flags), extraArgs: flags.extraArgs, @@ -652,6 +666,7 @@ export async function runCli(argv, options = {}) { worktreeId: flags.worktreeId, prompt: flags.prompt, agent: flags.agent, + claudeRuntime: flags.claudeRuntime, profile: flags.profile, permissions: buildPermissions(flags), extraArgs: flags.extraArgs, diff --git a/packages/codekanban-cli/test/runtime.test.js b/packages/codekanban-cli/test/runtime.test.js index d08dc67..9a1b767 100644 --- a/packages/codekanban-cli/test/runtime.test.js +++ b/packages/codekanban-cli/test/runtime.test.js @@ -79,6 +79,59 @@ test('CLI --help prints usage text', { concurrency: false }, async () => { assert.equal(result.stderr, ''); }); +test('CLI workflow command supports Claude Code Router runtime', { concurrency: false }, async () => { + const result = await runCliCaptured([ + 'workflow', + 'command', + '--agent', + 'claude', + '--claude-runtime', + 'ccr', + '--extra-arg', + '--model', + '--extra-arg', + 'sonnet', + '--prompt', + 'Hello', + ]); + + assert.equal(result.exitCode, 0); + const payload = JSON.parse(result.stdout); + assert.equal(payload.agent, 'claude'); + assert.equal(payload.claudeRuntime, 'ccr'); + assert.equal(payload.command, 'ccr code --model sonnet'); + assert.deepEqual(payload.argv, ['ccr', 'code', '--model', 'sonnet']); +}); + +test('CLI workflow start forwards Claude runtime to the SDK client', { concurrency: false }, async () => { + const calls = []; + const result = await runCliCaptured([ + 'workflow', + 'start', + '--base-url', + 'http://127.0.0.1:3000', + '--project-id', + 'p1', + '--agent', + 'claude', + '--claude-runtime', + 'ccr', + '--prompt', + 'Hello', + ], { + clientFactory: () => ({ + async startWorkflow(input) { + calls.push(input); + return { command: 'ccr code', claudeRuntime: input.claudeRuntime }; + }, + }), + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].claudeRuntime, 'ccr'); + assert.equal(JSON.parse(result.stdout).claudeRuntime, 'ccr'); +}); + test('CLI web-session create prints the created session JSON', { concurrency: false }, async () => { const handlers = new Map([ diff --git a/packages/node-sdk/src/client.js b/packages/node-sdk/src/client.js index e33f5f5..33de4f5 100644 --- a/packages/node-sdk/src/client.js +++ b/packages/node-sdk/src/client.js @@ -1003,6 +1003,7 @@ export class CodeKanbanClient { body: { worktreeId: ensureString(worktree?.id, "worktreeId"), agent, + claudeRuntime: ensureOptionalString(input.claudeRuntime) || "claude", model: ensureOptionalString(input.model) || defaultWebSessionModel(agent), reasoningEffort: @@ -1878,6 +1879,7 @@ export class CodeKanbanClient { worktree, terminalSession: terminal, agent: launch.agent, + claudeRuntime: launch.claudeRuntime, profile: launch.profile, command: launch.command, prompt: launch.prompt, diff --git a/packages/node-sdk/src/command-builder.js b/packages/node-sdk/src/command-builder.js index 454e8f0..d0f9133 100644 --- a/packages/node-sdk/src/command-builder.js +++ b/packages/node-sdk/src/command-builder.js @@ -5,6 +5,7 @@ export const SANDBOX_MODES = ['read-only', 'workspace-write', 'danger-full-acces export const APPROVAL_POLICIES = ['untrusted', 'on-request', 'never']; export const WORKFLOW_PROFILES = ['plan', 'standard', 'yolo']; export const AGENTS = ['codex', 'claude']; +export const CLAUDE_RUNTIMES = ['claude', 'ccr']; const KNOWN_STRUCTURED_FLAGS = new Set([ '-s', @@ -60,15 +61,18 @@ export function buildAgentLaunchSpec(options = {}) { const extraArgs = ensureArrayOfStrings(options.extraArgs, 'extraArgs'); if (agent === 'claude') { + const claudeRuntime = + validateEnum(options.claudeRuntime || 'claude', CLAUDE_RUNTIMES, 'claudeRuntime') || 'claude'; if (profile !== 'standard') { throw new CodeKanbanValidationError('claude only supports the standard profile in v1'); } if (options.permissions) { throw new CodeKanbanValidationError('structured permissions are only supported for codex in v1'); } - const argv = ['claude', ...extraArgs]; + const argv = claudeRuntime === 'ccr' ? ['ccr', 'code', ...extraArgs] : ['claude', ...extraArgs]; return { agent, + claudeRuntime, profile, argv, command: toCommandString(argv), diff --git a/packages/node-sdk/src/web-session-command-channel.js b/packages/node-sdk/src/web-session-command-channel.js index 265dc1f..7bc86d5 100644 --- a/packages/node-sdk/src/web-session-command-channel.js +++ b/packages/node-sdk/src/web-session-command-channel.js @@ -119,6 +119,7 @@ export class WebSessionCommandChannel { pid: ensureString(input.projectId, "projectId"), wid: ensureOptionalString(input.worktreeId), ag: ensureString(input.agent, "agent"), + cr: ensureOptionalString(input.claudeRuntime), md: ensureOptionalString(input.model), re: ensureOptionalString(input.reasoningEffort), wm: ensureOptionalString(input.workflowMode), @@ -230,6 +231,16 @@ export class WebSessionCommandChannel { }); } + async updateClaudeRuntime(sessionId, input = {}) { + return await this._executeCommand({ + operation: "set_cr", + sessionId, + payload: { + cr: ensureString(input.claudeRuntime, "claudeRuntime"), + }, + }); + } + async updateReasoningEffort(sessionId, input = {}) { return await this._executeCommand({ operation: "set_re", diff --git a/packages/node-sdk/src/web-session-shared.js b/packages/node-sdk/src/web-session-shared.js index 9f6b03f..0965fbd 100644 --- a/packages/node-sdk/src/web-session-shared.js +++ b/packages/node-sdk/src/web-session-shared.js @@ -264,6 +264,7 @@ export function normalizeWebSessionSummaryFromWire(value) { worktreeId: trimmedString(value?.wid) || null, orderIndex: numberValue(value?.oi, 0), agent: trimmedString(value?.ag) || "codex", + claudeRuntime: trimmedString(value?.cr) === "ccr" ? "ccr" : "claude", title: trimmedString(value?.ttl), model: trimmedString(value?.md), reasoningEffort: trimmedString(value?.re) || "default", diff --git a/packages/node-sdk/test/client.test.js b/packages/node-sdk/test/client.test.js index bc4950b..6bcda49 100644 --- a/packages/node-sdk/test/client.test.js +++ b/packages/node-sdk/test/client.test.js @@ -143,6 +143,33 @@ test('startWorkflow creates a terminal and sends command plus prompt', async () assert.match(FakeWebSocket.instances[0].sent[1].data, /planning mode/i); }); +test('startWorkflow launches Claude through CCR when requested', async () => { + FakeWebSocket.instances.length = 0; + const handlers = new Map([ + ['GET /api/v1/projects', () => createJsonResponse({ items: [{ id: 'p1', path: 'D:/repo/demo', name: 'demo' }] })], + ['GET /api/v1/projects/p1/worktrees', () => createJsonResponse({ items: [{ id: 'w1', path: 'D:/repo/demo', isMain: true }] })], + ['POST /api/v1/projects/p1/worktrees/w1/terminals', () => createJsonResponse({ item: { id: 't1', wsPath: '/api/v1/terminal/ws?sessionId=t1', wsUrl: '/api/v1/terminal/ws?sessionId=t1', title: 'demo', projectId: 'p1', worktreeId: 'w1', workingDir: 'D:/repo/demo' } }, 201)], + ]); + + const client = new CodeKanbanClient({ + baseURL: 'http://127.0.0.1:3000', + fetchImpl: createFetchMock(handlers), + WebSocketImpl: FakeWebSocket, + }); + + const result = await client.startWorkflow({ + path: 'D:/repo/demo', + agent: 'claude', + claudeRuntime: 'ccr', + prompt: 'Inspect', + }); + + assert.equal(result.claudeRuntime, 'ccr'); + assert.equal(FakeWebSocket.instances.length, 1); + assert.match(FakeWebSocket.instances[0].sent[0].data, /^ccr code\r$/); + assert.match(FakeWebSocket.instances[0].sent[1].data, /^Inspect\r$/); +}); + test('listSessions returns terminal and ai summaries', async () => { const handlers = new Map([ ['GET /api/v1/projects/p1', () => createJsonResponse({ item: { id: 'p1', path: 'D:/repo/demo', name: 'demo' } })], diff --git a/packages/node-sdk/test/command-builder.test.js b/packages/node-sdk/test/command-builder.test.js index fafc83b..c2b113e 100644 --- a/packages/node-sdk/test/command-builder.test.js +++ b/packages/node-sdk/test/command-builder.test.js @@ -72,3 +72,27 @@ test('claude only supports standard profile', () => { /claude only supports the standard profile/i, ); }); + +test('buildAgentLaunchSpec builds Claude CCR terminal command', () => { + const result = buildAgentLaunchSpec({ + agent: 'claude', + claudeRuntime: 'ccr', + prompt: 'Hello', + extraArgs: ['--model', 'sonnet'], + }); + + assert.equal(result.command, 'ccr code --model sonnet'); + assert.equal(result.claudeRuntime, 'ccr'); +}); + +test('buildAgentLaunchSpec rejects invalid Claude runtime', () => { + assert.throws( + () => + buildAgentLaunchSpec({ + agent: 'claude', + claudeRuntime: 'bad', + prompt: 'Hello', + }), + /claudeRuntime must be one of/i, + ); +}); diff --git a/service/websession/claude_hooks.go b/service/websession/claude_hooks.go index 9afa245..15fec60 100644 --- a/service/websession/claude_hooks.go +++ b/service/websession/claude_hooks.go @@ -113,39 +113,7 @@ func (m *Manager) ensureClaudeHookServer() (string, error) { m.claudeHookBaseURL = "http://" + listener.Addr().String() m.claudeHookSettingsPath = filepath.Join(m.store.rootDir, "claude-hook-settings.json") - settings := map[string]any{ - "allowedHttpHookUrls": []string{ - m.claudeHookBaseURL, - }, - "hooks": map[string]any{ - "PreToolUse": []map[string]any{ - { - "matcher": "AskUserQuestion", - "hooks": []map[string]any{ - { - "type": "http", - "url": m.claudeHookBaseURL + "/claude-hooks/pre-tool-use", - "headers": map[string]any{ - "Authorization": "Bearer " + m.claudeHookToken, - }, - }, - }, - }, - { - "matcher": "ExitPlanMode", - "hooks": []map[string]any{ - { - "type": "http", - "url": m.claudeHookBaseURL + "/claude-hooks/pre-tool-use", - "headers": map[string]any{ - "Authorization": "Bearer " + m.claudeHookToken, - }, - }, - }, - }, - }, - }, - } + settings := m.claudeHookSettings() encoded, err := json.Marshal(settings) if err != nil { _ = listener.Close() @@ -167,6 +135,187 @@ func (m *Manager) ensureClaudeHookServer() (string, error) { return m.claudeHookSettingsPath, nil } +func (m *Manager) ensureCCRClaudeHookSettings() error { + if _, err := m.ensureClaudeHookServer(); err != nil { + return err + } + m.ccrHookMu.Lock() + defer m.ccrHookMu.Unlock() + if m.ccrHookReady { + return nil + } + m.ccrHookErr = m.writeCCRClaudeHookSettings() + 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) + } + 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{} + } + claudeSettings, _ := config["claudeCodeSettings"].(map[string]any) + if claudeSettings == nil { + claudeSettings = map[string]any{} + config["claudeCodeSettings"] = claudeSettings + } + injectClaudeHookSettings(claudeSettings, m.claudeHookBaseURL, m.claudeHookToken) + encoded, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + encoded = append(encoded, '\n') + return os.WriteFile(m.cfg.CCRConfigPath, encoded, 0o644) +} + +func (m *Manager) claudeHookSettings() map[string]any { + return map[string]any{ + "allowedHttpHookUrls": []string{ + m.claudeHookBaseURL, + }, + "hooks": map[string]any{ + "PreToolUse": codeKanbanClaudeHookEntries(m.claudeHookBaseURL, m.claudeHookToken), + }, + } +} + +func codeKanbanClaudeHookEntries(baseURL, token string) []map[string]any { + return []map[string]any{ + { + "matcher": "AskUserQuestion", + "hooks": []map[string]any{ + { + "type": "http", + "url": baseURL + "/claude-hooks/pre-tool-use", + "headers": map[string]any{ + "Authorization": "Bearer " + token, + }, + }, + }, + }, + { + "matcher": "ExitPlanMode", + "hooks": []map[string]any{ + { + "type": "http", + "url": baseURL + "/claude-hooks/pre-tool-use", + "headers": map[string]any{ + "Authorization": "Bearer " + token, + }, + }, + }, + }, + } +} + +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 0e7bb89..e4fbb68 100644 --- a/service/websession/claude_stream.go +++ b/service/websession/claude_stream.go @@ -149,17 +149,24 @@ func claudeUserMessagePayload(text string, attachments []Attachment, workflowMod } func (m *Manager) buildClaudeResumeCommand(ctx context.Context, session tables.WebSessionTable) (*exec.Cmd, error) { - settingsPath, err := m.ensureClaudeHookServer() - if err != nil { - return nil, err - } args := []string{ "-p", "--output-format", "stream-json", "--input-format", "stream-json", "--replay-user-messages", "--verbose", - "--settings", settingsPath, + } + claudeRuntime := effectiveClaudeRuntime(session) + if claudeRuntime == ClaudeRuntimeCCR { + if err := m.ensureCCRClaudeHookSettings(); err != nil { + return nil, err + } + } else { + settingsPath, err := m.ensureClaudeHookServer() + if err != nil { + return nil, err + } + args = append(args, "--settings", settingsPath) } workflowMode := effectiveWorkflowMode(session) permissionLevel := effectivePermissionLevel(session) @@ -186,7 +193,7 @@ func (m *Manager) buildClaudeResumeCommand(ctx context.Context, session tables.W if effort := claudeReasoningEffortArg(ReasoningEffort(session.ReasoningEffort)); effort != "" { args = append(args, "--effort", effort) } - cmd := exec.CommandContext(ctx, m.cfg.ClaudePath, args...) + cmd := m.buildClaudeCommand(ctx, claudeRuntime, args) cmd.Dir = session.Cwd cmd.Env = os.Environ() return cmd, nil diff --git a/service/websession/claude_stream_test.go b/service/websession/claude_stream_test.go index ff95643..b3fb1a8 100644 --- a/service/websession/claude_stream_test.go +++ b/service/websession/claude_stream_test.go @@ -68,6 +68,126 @@ func TestBuildExecCommandClaudeUsesStreamJSONInput(t *testing.T) { } } +func TestBuildExecCommandClaudeRouterUsesCCRCodeAndInjectsHookSettings(t *testing.T) { + store, err := newStore(t.TempDir()) + 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}, + store: store, + logger: zap.NewNop(), + runs: map[string]*activeRun{}, + clients: map[*client]struct{}{}, + } + session := tables.WebSessionTable{ + Agent: string(AgentClaude), + ClaudeRuntime: string(ClaudeRuntimeCCR), + Model: "sonnet", + WorkflowMode: string(WorkflowModeDefault), + PermissionLevel: string(PermissionLevelElevated), + Cwd: "/tmp/project", + } + + cmd, _, _, err := manager.buildExecCommand(context.Background(), session, "hi", nil) + if err != nil { + t.Fatalf("buildExecCommand returned error: %v", err) + } + if len(cmd.Args) < 2 || cmd.Args[0] != "ccr" || cmd.Args[1] != "code" { + t.Fatalf("expected CCR runtime to launch ccr code, got %v", cmd.Args) + } + joinedArgs := strings.Join(cmd.Args, " ") + for _, expected := range []string{ + "--input-format stream-json", + "--output-format stream-json", + "--model sonnet", + } { + if !strings.Contains(joinedArgs, expected) { + t.Fatalf("expected args to contain %q, got %v", expected, cmd.Args) + } + } + if strings.Contains(joinedArgs, "--settings") { + t.Fatalf("expected CCR runtime to let ccr generate one merged settings file, got %v", cmd.Args) + } + data, err := os.ReadFile(ccrConfigPath) + if err != nil { + t.Fatalf("failed to read CCR config: %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(data), "https://example.com/custom-ask") { + t.Fatalf("expected existing user hook to be preserved, got %s", string(data)) + } + 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)) + } +} + +func TestEnsureCCRClaudeHookSettingsRetriesAfterFailure(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}, + 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) + } + if !strings.Contains(string(data), "claudeCodeSettings") { + t.Fatalf("expected CCR config to include injected settings after retry, got %s", string(data)) + } +} + func TestBuildExecCommandClaudeRejectsDefaultPermissionLevel(t *testing.T) { store, err := newStore(t.TempDir()) if err != nil { @@ -454,6 +574,9 @@ func TestClaudeHookServerDefersAndAllowsAskUserQuestion(t *testing.T) { if !strings.HasSuffix(settingsPath, "claude-hook-settings.json") { t.Fatalf("unexpected settings path %q", settingsPath) } + if !filepath.IsAbs(settingsPath) { + t.Fatalf("expected absolute settings path, got %q", settingsPath) + } requestBody := map[string]any{ "session_id": "session-1", diff --git a/service/websession/manager.go b/service/websession/manager.go index 70a9dca..5503341 100644 --- a/service/websession/manager.go +++ b/service/websession/manager.go @@ -49,6 +49,8 @@ type Config struct { DataDir string AttachmentSizeLimit int64 ClaudePath string + CCRPath string + CCRConfigPath string CodexPath string DefaultCodexSyncMode func() SyncMode ActiveCallTimeoutConfig func() utils.WebSessionActiveCallTimeoutConfig @@ -78,6 +80,9 @@ type Manager struct { claudeHookSettingsPath string claudeHookErr error claudeHookServer *http.Server + ccrHookMu sync.Mutex + ccrHookReady bool + ccrHookErr error } type clientKind string @@ -274,6 +279,12 @@ func NewManager(cfg Config, logger *zap.Logger) (*Manager, error) { if cfg.ClaudePath == "" { cfg.ClaudePath = getenvDefault("CLAUDE_PATH", "claude") } + if cfg.CCRPath == "" { + cfg.CCRPath = getenvDefault("CCR_PATH", "ccr") + } + if cfg.CCRConfigPath == "" { + cfg.CCRConfigPath = getenvDefault("CCR_CONFIG_PATH", defaultCCRConfigPath()) + } if cfg.CodexPath == "" { cfg.CodexPath = getenvDefault("CODEX_PATH", "codex") } @@ -663,6 +674,7 @@ func (m *Manager) CreateSession(ctx context.Context, params CreateParams) (Sessi WorktreeID: nilIfEmpty(worktreeID), OrderIndex: orderIndex, Agent: string(normalizeAgent(params.Agent)), + ClaudeRuntime: string(normalizeClaudeRuntime(params.ClaudeRuntime)), Backend: string(normalizeSessionBackend(params.Backend, normalizeAgent(params.Agent))), Title: title, TitleAuto: strings.TrimSpace(params.Title) == "", @@ -1405,6 +1417,24 @@ func (m *Manager) UpdateModel(ctx context.Context, sessionID, modelName string) }) } +func (m *Manager) UpdateClaudeRuntime( + ctx context.Context, + sessionID string, + runtime ClaudeRuntime, +) (SessionSummary, error) { + record, err := m.GetSession(ctx, sessionID) + if err != nil { + return SessionSummary{}, err + } + if normalizeAgent(Agent(record.Agent)) != AgentClaude { + return SessionSummary{}, fmt.Errorf("claude runtime is only supported for claude sessions") + } + return m.updateFields(ctx, sessionID, map[string]any{ + "claude_runtime": string(normalizeClaudeRuntime(runtime)), + "updated_at": time.Now(), + }) +} + func (m *Manager) UpdateReasoningEffort( ctx context.Context, sessionID string, @@ -1482,6 +1512,7 @@ func (m *Manager) UpdateAgent(ctx context.Context, sessionID string, agent Agent } return m.updateFields(ctx, sessionID, map[string]any{ "agent": string(normalized), + "claude_runtime": string(defaultClaudeRuntime(normalized)), "backend": string(defaultSessionBackend(normalized)), "model": defaultModel(normalized, ""), "reasoning_effort": string(defaultReasoningEffort(normalized, "")), @@ -1857,6 +1888,8 @@ func (m *Manager) HandleCommand(ctx context.Context, client *client, payload []b return m.handleRenameCommand(ctx, client, frame) case "set_md": return m.handleSetModelCommand(ctx, client, frame) + case "set_cr": + return m.handleSetClaudeRuntimeCommand(ctx, client, frame) case "set_re": return m.handleSetReasoningEffortCommand(ctx, client, frame) case "set_wm": @@ -2005,6 +2038,7 @@ func (m *Manager) handleCreateCommand(ctx context.Context, client *client, frame ProjectID string `json:"pid"` WorktreeID string `json:"wid"` Agent string `json:"ag"` + ClaudeRuntime string `json:"cr"` Model string `json:"md"` ReasoningEffort string `json:"re"` WorkflowMode string `json:"wm"` @@ -2035,6 +2069,7 @@ func (m *Manager) handleCreateCommand(ctx context.Context, client *client, frame ProjectID: payload.ProjectID, WorktreeID: payload.WorktreeID, Agent: Agent(payload.Agent), + ClaudeRuntime: ClaudeRuntime(payload.ClaudeRuntime), Model: payload.Model, ReasoningEffort: ReasoningEffort(payload.ReasoningEffort), WorkflowMode: workflowMode, @@ -2190,6 +2225,23 @@ func (m *Manager) handleSetModelCommand(ctx context.Context, client *client, fra return nil } +func (m *Manager) handleSetClaudeRuntimeCommand(ctx context.Context, client *client, frame wireCommandFrame) error { + var payload struct { + ClaudeRuntime string `json:"cr"` + } + if err := json.Unmarshal(frame.Payload, &payload); err != nil { + return client.send(newErrorFrame(frame.RequestID, frame.SessionID, "bad_req", "invalid claude runtime payload", false)) + } + if _, err := m.UpdateClaudeRuntime(ctx, frame.SessionID, ClaudeRuntime(payload.ClaudeRuntime)); err != nil { + return client.send(newErrorFrame(frame.RequestID, frame.SessionID, "bad_req", err.Error(), false)) + } + if err := client.send(newAckFrame(frame.RequestID, frame.Operation, frame.SessionID, nil)); err != nil { + return err + } + m.broadcastSessionSummary(ctx, frame.SessionID) + return nil +} + func (m *Manager) handleSetReasoningEffortCommand( ctx context.Context, client *client, @@ -3858,17 +3910,24 @@ func (m *Manager) buildExecCommand(ctx context.Context, session tables.WebSessio switch normalizeAgent(Agent(session.Agent)) { case AgentClaude: - settingsPath, err := m.ensureClaudeHookServer() - if err != nil { - return nil, nil, false, err - } args := []string{ "-p", "--output-format", "stream-json", "--input-format", "stream-json", "--replay-user-messages", "--verbose", - "--settings", settingsPath, + } + claudeRuntime := effectiveClaudeRuntime(session) + if claudeRuntime == ClaudeRuntimeCCR { + if err := m.ensureCCRClaudeHookSettings(); err != nil { + return nil, nil, false, err + } + } else { + settingsPath, err := m.ensureClaudeHookServer() + if err != nil { + return nil, nil, false, err + } + args = append(args, "--settings", settingsPath) } if err := validateWebSessionPermissionLevel(AgentClaude, permissionLevel); err != nil { return nil, nil, false, err @@ -3897,7 +3956,7 @@ func (m *Manager) buildExecCommand(ctx context.Context, session tables.WebSessio if err != nil { return nil, nil, false, err } - cmd := exec.CommandContext(ctx, m.cfg.ClaudePath, args...) + cmd := m.buildClaudeCommand(ctx, claudeRuntime, args) cmd.Dir = session.Cwd cmd.Env = os.Environ() return cmd, stdin, true, nil @@ -3954,6 +4013,14 @@ func (m *Manager) buildExecCommand(ctx context.Context, session tables.WebSessio } } +func (m *Manager) buildClaudeCommand(ctx context.Context, runtime ClaudeRuntime, args []string) *exec.Cmd { + if normalizeClaudeRuntime(runtime) == ClaudeRuntimeCCR { + ccrArgs := append([]string{"code"}, args...) + return exec.CommandContext(ctx, m.cfg.CCRPath, ccrArgs...) + } + return exec.CommandContext(ctx, m.cfg.ClaudePath, args...) +} + func (m *Manager) respondToApproval(sessionID, action string) error { m.mu.RLock() run, ok := m.runs[sessionID] @@ -4292,6 +4359,7 @@ func mapSessionRecord(record tables.WebSessionTable) SessionSummary { WorktreeID: record.WorktreeID, OrderIndex: record.OrderIndex, Agent: Agent(record.Agent), + ClaudeRuntime: effectiveClaudeRuntime(record), Title: record.Title, Model: record.Model, ReasoningEffort: ReasoningEffort(record.ReasoningEffort), @@ -4617,6 +4685,29 @@ func normalizeAgent(agent Agent) Agent { } } +func normalizeClaudeRuntime(runtime ClaudeRuntime) ClaudeRuntime { + switch strings.ToLower(strings.TrimSpace(string(runtime))) { + case string(ClaudeRuntimeCCR): + return ClaudeRuntimeCCR + default: + return ClaudeRuntimeNative + } +} + +func defaultClaudeRuntime(agent Agent) ClaudeRuntime { + if normalizeAgent(agent) != AgentClaude { + return ClaudeRuntimeNative + } + return ClaudeRuntimeNative +} + +func effectiveClaudeRuntime(record tables.WebSessionTable) ClaudeRuntime { + if normalizeAgent(Agent(record.Agent)) != AgentClaude { + return ClaudeRuntimeNative + } + return normalizeClaudeRuntime(ClaudeRuntime(record.ClaudeRuntime)) +} + func normalizeReasoningEffort(effort ReasoningEffort) ReasoningEffort { switch strings.ToLower(strings.TrimSpace(string(effort))) { case string(ReasoningEffortNone): @@ -5059,6 +5150,14 @@ func getenvDefault(key, fallback string) string { return fallback } +func defaultCCRConfigPath() string { + homeDir, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(homeDir) == "" { + return filepath.Join(".claude-code-router", "config.json") + } + return filepath.Join(homeDir, ".claude-code-router", "config.json") +} + func truncateString(value string, limit int) string { if limit <= 0 || len(value) <= limit { return value diff --git a/service/websession/store.go b/service/websession/store.go index 598088d..407fc76 100644 --- a/service/websession/store.go +++ b/service/websession/store.go @@ -14,7 +14,10 @@ type store struct { } func newStore(dataDir string) (*store, error) { - rootDir := filepath.Join(dataDir, "web-sessions") + rootDir, err := filepath.Abs(filepath.Join(dataDir, "web-sessions")) + if err != nil { + return nil, err + } attachmentsDir := filepath.Join(rootDir, "_attachments") if err := os.MkdirAll(rootDir, 0o755); err != nil { return nil, err diff --git a/service/websession/types.go b/service/websession/types.go index f09f403..93458ec 100644 --- a/service/websession/types.go +++ b/service/websession/types.go @@ -9,6 +9,13 @@ const ( AgentCodex Agent = "codex" ) +type ClaudeRuntime string + +const ( + ClaudeRuntimeNative ClaudeRuntime = "claude" + ClaudeRuntimeCCR ClaudeRuntime = "ccr" +) + type SessionBackend string const ( @@ -159,6 +166,7 @@ type SessionSummary struct { WorktreeID *string `json:"worktreeId,omitempty"` OrderIndex float64 `json:"orderIndex"` Agent Agent `json:"agent"` + ClaudeRuntime ClaudeRuntime `json:"claudeRuntime"` Title string `json:"title"` Model string `json:"model"` ReasoningEffort ReasoningEffort `json:"reasoningEffort"` @@ -390,6 +398,7 @@ type CreateParams struct { ProjectID string WorktreeID string Agent Agent + ClaudeRuntime ClaudeRuntime Backend SessionBackend Model string ReasoningEffort ReasoningEffort diff --git a/service/websession/wire.go b/service/websession/wire.go index c4a46f1..2d2a51f 100644 --- a/service/websession/wire.go +++ b/service/websession/wire.go @@ -54,6 +54,7 @@ type wireSess struct { WorktreeID *string `json:"wid,omitempty"` OrderIndex float64 `json:"oi"` Agent string `json:"ag"` + ClaudeRuntime string `json:"cr,omitempty"` Model string `json:"md"` ReasoningEffort string `json:"re"` WorkflowMode string `json:"wm"` @@ -371,6 +372,7 @@ func mapWireSession(session SessionSummary) *wireSess { WorktreeID: session.WorktreeID, OrderIndex: session.OrderIndex, Agent: string(session.Agent), + ClaudeRuntime: string(session.ClaudeRuntime), Model: session.Model, ReasoningEffort: string(session.ReasoningEffort), WorkflowMode: string(session.WorkflowMode), diff --git a/static/index.html b/static/index.html index e14f29b..5be2d47 100644 --- a/static/index.html +++ b/static/index.html @@ -7,8 +7,8 @@ Code Kanban - - + + diff --git a/ui/src/api/webSession.ts b/ui/src/api/webSession.ts index 65ea528..599fcb5 100644 --- a/ui/src/api/webSession.ts +++ b/ui/src/api/webSession.ts @@ -109,6 +109,7 @@ export const webSessionApi = { data: { worktreeId?: string; agent: 'claude' | 'codex'; + claudeRuntime?: 'claude' | 'ccr'; model?: string; reasoningEffort?: 'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'; workflowMode?: 'default' | 'plan'; @@ -125,6 +126,7 @@ export const webSessionApi = { .Post>(`/projects/${projectId}/web-sessions`, { worktreeId: data.worktreeId ?? '', agent: data.agent, + claudeRuntime: data.claudeRuntime ?? 'claude', model: data.model ?? '', reasoningEffort: data.reasoningEffort ?? 'default', workflowMode: data.workflowMode ?? 'default', diff --git a/ui/src/components/kanban/KanbanBoard.vue b/ui/src/components/kanban/KanbanBoard.vue index ce1afce..816c236 100644 --- a/ui/src/components/kanban/KanbanBoard.vue +++ b/ui/src/components/kanban/KanbanBoard.vue @@ -450,13 +450,13 @@ function normalizeTerminalEnter(value: string) { } function resolveAgentCommand(agent: Exclude): string { - const configured = terminalQuickActions.value - .find(action => action.id === agent) - ?.command?.trim(); - if (configured) { - return configured; + const commandFor = (id: string) => + terminalQuickActions.value.find(action => action.id === id && action.enabled)?.command?.trim() ?? + ''; + if (agent === 'claude') { + return commandFor('claude') || commandFor('ccr') || 'claude'; } - return agent === 'claude' ? 'claude' : 'codex'; + return commandFor('codex') || 'codex'; } function sendWithRetry(sessionId: string, input: string): Promise { diff --git a/ui/src/components/terminal/AISessionHistoryDialog.vue b/ui/src/components/terminal/AISessionHistoryDialog.vue index b366219..4909ff4 100644 --- a/ui/src/components/terminal/AISessionHistoryDialog.vue +++ b/ui/src/components/terminal/AISessionHistoryDialog.vue @@ -352,6 +352,7 @@ interface ItemResponse { const props = defineProps<{ projectId: string; + claudeCommand?: string; }>(); const showModal = defineModel('show', { default: false }); @@ -570,7 +571,8 @@ async function copyPath(path: string) { } function getResumeCommand(session: AISessionSummary) { - return `claude --resume ${session.sessionId}`; + const command = props.claudeCommand?.trim() || 'claude'; + return `${command} --resume ${session.sessionId}`; } async function copyResumeCommand(session: AISessionSummary) { diff --git a/ui/src/components/terminal/TerminalPanel.vue b/ui/src/components/terminal/TerminalPanel.vue index 5821162..c0cd639 100644 --- a/ui/src/components/terminal/TerminalPanel.vue +++ b/ui/src/components/terminal/TerminalPanel.vue @@ -445,6 +445,7 @@ v-if="projectIdRef" v-model:show="showAISessionHistory" :project-id="projectIdRef" + :claude-command="resolveAgentCommand('claude')" @resume="handleResumeSession" /> action.id === id && action.enabled)?.command?.trim() ?? + '' + ); +} + +function resolveAgentCommand(agent: 'claude' | 'codex') { + if (agent === 'claude') { + return resolveQuickActionCommand('claude') || resolveQuickActionCommand('ccr') || 'claude'; + } + return resolveQuickActionCommand('codex') || 'codex'; +} + async function handleRunQuickAction(action: TerminalQuickAction) { if (!props.projectId) { message.warning(t('terminal.pleaseSelectProject')); @@ -2463,8 +2478,8 @@ async function handleResumeSession(claudeSessionId: string, sessionType: string) return; } - // Build the resume command - const resumeCommand = `claude --resume ${claudeSessionId}`; + // Build the resume command using the configured Claude terminal launcher. + const resumeCommand = `${resolveAgentCommand('claude')} --resume ${claudeSessionId}`; // Listen for the terminal ready event, then send the command const handleReady = (payload: ServerMessage) => { diff --git a/ui/src/components/web-session/WebSessionPanel.vue b/ui/src/components/web-session/WebSessionPanel.vue index 4935657..ecf19aa 100644 --- a/ui/src/components/web-session/WebSessionPanel.vue +++ b/ui/src/components/web-session/WebSessionPanel.vue @@ -1063,6 +1063,14 @@ size="small" :options="modelOptions" /> + ('codex'); +const draftClaudeRuntime = ref('claude'); const draftModel = ref(defaultModelForAgent('codex')); const draftReasoningEffort = ref<'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'>('xhigh'); const draftWorkflowMode = ref<'default' | 'plan'>('default'); @@ -3617,6 +3628,9 @@ const mobileComposerSummaryTokens = computed(() => { { key: 'agent', label: selectedAgentLabel.value }, { key: 'model', label: selectedModelLabel.value }, ]; + if (selectedAgent.value === 'claude') { + tokens.push({ key: 'claude-runtime', label: selectedClaudeRuntimeLabel.value }); + } if (selectedAgent.value === 'codex') { tokens.push({ key: 'reasoning', label: selectedReasoningEffortLabel.value }); } @@ -5179,6 +5193,7 @@ function normalizeDraftSession( worktreeId: typeof session.worktreeId === 'string' ? session.worktreeId || null : null, orderIndex: Number.MAX_SAFE_INTEGER - index, agent, + claudeRuntime: session.claudeRuntime === 'ccr' ? 'ccr' : 'claude', title: typeof session.title === 'string' && session.title.trim() ? session.title.trim() @@ -5611,6 +5626,7 @@ function createDraftSession(forceAgent?: 'claude' | 'codex') { worktreeId: context.worktreeId, orderIndex: Number.MAX_SAFE_INTEGER - draftSessions.value.length, agent: nextAgent, + claudeRuntime: source?.claudeRuntime === 'ccr' ? 'ccr' : draftClaudeRuntime.value, title: buildDraftTitle(nextAgent), model: source?.model || draftModel.value || defaultModelForAgent(nextAgent), reasoningEffort: @@ -6165,6 +6181,10 @@ const modelSelectMenuProps = { class: 'web-session-model-select-menu', style: { minWidth: '132px', maxWidth: '180px' }, }; +const claudeRuntimeSelectMenuProps = { + class: 'web-session-claude-runtime-select-menu', + style: { minWidth: '172px' }, +}; function renderAgentDropdownLabel(option: DropdownOption) { const value = String(option.key ?? option.value ?? ''); @@ -6318,6 +6338,16 @@ const selectedModelDisplayLabel = computed(() => getKnownModelLabel(selectedMode const modelSelectStyle = computed(() => ({ width: `${resolveModelSelectWidth(selectedModelDisplayLabel.value)}px`, })); +const claudeRuntimeOptions = computed(() => + CLAUDE_RUNTIME_OPTIONS.map(option => ({ + ...option, + label: option.menuLabel ?? option.label, + })) +); +const selectedClaudeRuntimeLabel = computed(() => { + const runtime = selectedClaudeRuntime.value; + return CLAUDE_RUNTIME_OPTIONS.find(option => option.value === runtime)?.label ?? 'Claude Code'; +}); const modelOptions = computed(() => { const activeModel = currentSession.value?.model ?? draftModel.value; @@ -6363,6 +6393,9 @@ const selectedAgent = computed({ set: value => { const next = value as 'claude' | 'codex'; draftAgent.value = next; + if (next === 'codex') { + draftClaudeRuntime.value = 'claude'; + } if (next === 'claude' && draftModel.value.startsWith('gpt-')) { draftModel.value = defaultModelForAgent(next); } @@ -6377,6 +6410,12 @@ const selectedAgent = computed({ updateActiveDraftSession(current => ({ ...current, agent: next, + claudeRuntime: + next === 'claude' + ? current.claudeRuntime === 'ccr' + ? 'ccr' + : draftClaudeRuntime.value + : 'claude', model: next === 'claude' && current.model.startsWith('gpt-') ? defaultModelForAgent(next) @@ -6437,6 +6476,31 @@ const selectedModel = computed({ }, }); +const selectedClaudeRuntime = computed({ + get: () => currentSession.value?.claudeRuntime ?? draftClaudeRuntime.value, + set: value => { + const next: WebSessionClaudeRuntimeOption = value === 'ccr' ? 'ccr' : 'claude'; + draftClaudeRuntime.value = next; + if (isDraftSession(currentSession.value)) { + updateActiveDraftSession(current => ({ + ...current, + claudeRuntime: next, + updatedAt: new Date().toISOString(), + })); + return; + } + if (currentRealSession.value) { + const noticeKey = getRuntimeSwitchNoticeKey(); + void webSessionStore + .updateClaudeRuntime(currentRealSession.value.id, next) + .then(() => showRuntimeSwitchNotice(noticeKey)) + .catch(error => { + message.error(error instanceof Error ? error.message : t('common.error')); + }); + } + }, +}); + const selectedReasoningEffort = computed<'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'>({ get: () => currentSession.value?.reasoningEffort ?? draftReasoningEffort.value, set: value => { @@ -7799,6 +7863,12 @@ async function handleCreateSession(forceAgent?: 'claude' | 'codex') { const session = await webSessionStore.createSession(props.projectId, { worktreeId, agent, + claudeRuntime: + agent === 'claude' + ? source?.claudeRuntime === 'ccr' + ? 'ccr' + : draftClaudeRuntime.value + : 'claude', model: source?.model || draftModel.value || defaultModelForAgent(agent), reasoningEffort: source?.reasoningEffort || @@ -7826,6 +7896,7 @@ async function handleCreateSession(forceAgent?: 'claude' | 'codex') { }); } draftAgent.value = session.agent; + draftClaudeRuntime.value = session.claudeRuntime === 'ccr' ? 'ccr' : 'claude'; draftModel.value = session.model; draftReasoningEffort.value = session.reasoningEffort || defaultReasoningEffortForAgent(session.agent); @@ -7863,6 +7934,7 @@ async function handleStartDraftSession(forceAgent?: 'claude' | 'codex') { } const draft = createDraftSession(forceAgent); draftAgent.value = draft.agent; + draftClaudeRuntime.value = draft.claudeRuntime === 'ccr' ? 'ccr' : 'claude'; draftModel.value = draft.model || defaultModelForAgent(draft.agent); draftReasoningEffort.value = draft.reasoningEffort || defaultReasoningEffortForAgent(draft.agent); draftWorkflowMode.value = draft.workflowMode; @@ -9835,6 +9907,7 @@ watch( return; } draftAgent.value = session.agent; + draftClaudeRuntime.value = session.claudeRuntime === 'ccr' ? 'ccr' : 'claude'; draftModel.value = session.model || defaultModelForAgent(session.agent); draftReasoningEffort.value = session.reasoningEffort || defaultReasoningEffortForAgent(session.agent); @@ -13583,11 +13656,19 @@ defineExpose({ width: 82px; } +.claude-runtime-select { + width: 172px; +} + :global(.web-session-model-select-menu) { min-width: 132px !important; max-width: 180px !important; } +:global(.web-session-claude-runtime-select-menu) { + min-width: 172px !important; +} + :global(.web-session-model-select-menu .n-base-select-option__content) { max-width: 128px; overflow: hidden; @@ -14330,6 +14411,10 @@ defineExpose({ width: calc(50% - 4px); } + .claude-runtime-select { + width: 172px; + } + .composer-mode-switch { width: auto; } diff --git a/ui/src/components/web-session/webSessionModelOptions.ts b/ui/src/components/web-session/webSessionModelOptions.ts index 1b97a7d..0cb4940 100644 --- a/ui/src/components/web-session/webSessionModelOptions.ts +++ b/ui/src/components/web-session/webSessionModelOptions.ts @@ -1,4 +1,5 @@ export type WebSessionAgentOption = 'claude' | 'codex'; +export type WebSessionClaudeRuntimeOption = 'claude' | 'ccr'; export type WebSessionModelOption = { label: string; @@ -15,6 +16,11 @@ export const CLAUDE_MODEL_OPTIONS: WebSessionModelOption[] = [ { label: 'Haiku', value: 'haiku' }, ]; +export const CLAUDE_RUNTIME_OPTIONS: WebSessionModelOption[] = [ + { label: 'Claude Code', value: 'claude' }, + { label: 'CCR', value: 'ccr', menuLabel: 'Claude Code Router' }, +]; + export const CODEX_PRIMARY_MODEL_OPTIONS: WebSessionModelOption[] = [ { label: '5.4', value: 'gpt-5.4', menuLabel: 'GPT-5.4' }, { label: '5.5', value: 'gpt-5.5', menuLabel: 'GPT-5.5' }, diff --git a/ui/src/stores/settings.ts b/ui/src/stores/settings.ts index a6af0ac..66cb140 100644 --- a/ui/src/stores/settings.ts +++ b/ui/src/stores/settings.ts @@ -302,6 +302,14 @@ export const DEFAULT_TERMINAL_QUICK_ACTIONS: TerminalQuickAction[] = [ enabled: true, stacked: false, }, + { + id: 'ccr', + name: 'Claude Code Router', + command: 'ccr code', + icon: 'claude', + enabled: true, + stacked: false, + }, { id: 'codex', name: 'Codex', @@ -1437,6 +1445,16 @@ function sanitizeTerminalQuickActions(value?: unknown): TerminalQuickAction[] { return DEFAULT_TERMINAL_QUICK_ACTIONS.map(action => ({ ...action })); } + for (const defaultAction of DEFAULT_TERMINAL_QUICK_ACTIONS) { + if (sanitized.length >= 12) { + break; + } + if (!usedIds.has(defaultAction.id)) { + sanitized.push({ ...defaultAction }); + usedIds.add(defaultAction.id); + } + } + return sanitized.slice(0, 12); } diff --git a/ui/src/stores/webSession.ts b/ui/src/stores/webSession.ts index bc61370..fc31b6d 100644 --- a/ui/src/stores/webSession.ts +++ b/ui/src/stores/webSession.ts @@ -39,6 +39,7 @@ type WireSession = { wid?: string | null; oi?: number; ag: 'claude' | 'codex'; + cr?: 'claude' | 'ccr'; md: string; re?: 'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'; wm: 'default' | 'plan'; @@ -1585,6 +1586,7 @@ export const useWebSessionStore = defineStore('web-session', () => { worktreeId: session.wid ?? null, orderIndex: Number(session.oi ?? 0), agent: session.ag, + claudeRuntime: session.cr === 'ccr' ? 'ccr' : 'claude', title: session.ttl, model: session.md, reasoningEffort: session.re ?? 'default', @@ -3856,6 +3858,10 @@ export const useWebSessionStore = defineStore('web-session', () => { await sendCommand('set_md', sessionId, { md: model }); } + async function updateClaudeRuntime(sessionId: string, claudeRuntime: 'claude' | 'ccr') { + await sendCommand('set_cr', sessionId, { cr: claudeRuntime }); + } + async function updateReasoningEffort( sessionId: string, reasoningEffort: 'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh' @@ -4030,6 +4036,7 @@ export const useWebSessionStore = defineStore('web-session', () => { payload: { worktreeId?: string; agent: 'claude' | 'codex'; + claudeRuntime?: 'claude' | 'ccr'; model?: string; reasoningEffort?: 'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'; workflowMode?: 'default' | 'plan'; @@ -4097,6 +4104,7 @@ export const useWebSessionStore = defineStore('web-session', () => { answerUserInput, loadMoreHistory, updateModel, + updateClaudeRuntime, updateReasoningEffort, updateWorkflowMode, updatePermissionLevel, diff --git a/ui/src/types/models.ts b/ui/src/types/models.ts index 3f3cd11..4d630b3 100644 --- a/ui/src/types/models.ts +++ b/ui/src/types/models.ts @@ -310,6 +310,7 @@ export interface WebSessionSummary { worktreeId?: string | null; orderIndex: number; agent: 'claude' | 'codex'; + claudeRuntime?: 'claude' | 'ccr'; title: string; model: string; reasoningEffort: 'default' | 'none' | 'low' | 'medium' | 'high' | 'xhigh'; diff --git a/utils/ai_assistant2/detector.go b/utils/ai_assistant2/detector.go index 5e89542..703f4c7 100644 --- a/utils/ai_assistant2/detector.go +++ b/utils/ai_assistant2/detector.go @@ -22,6 +22,11 @@ var defaultRules = []DetectionRule{ "@anthropic-ai/claude-code", "claude-code/cli.js", "claude-code/bin/", + "ccr code", + "ccr.cmd code", + "ccr.ps1 code", + "claude-code-router/dist/cli.js code", + "@musistudio/claude-code-router/dist/cli.js code", }, ExecutableNames: []string{ "claude", diff --git a/utils/ai_assistant2/detector_test.go b/utils/ai_assistant2/detector_test.go index 89c485a..daa76f2 100644 --- a/utils/ai_assistant2/detector_test.go +++ b/utils/ai_assistant2/detector_test.go @@ -56,6 +56,21 @@ func TestDetectFromCommand(t *testing.T) { command: `bash -lc "claude --dangerously-skip-permissions"`, want: types.AssistantTypeClaudeCode, }, + { + name: "claude code router direct command", + command: "ccr code --model sonnet", + want: types.AssistantTypeClaudeCode, + }, + { + name: "claude code router node cli command", + command: "node /usr/local/lib/node_modules/@musistudio/claude-code-router/dist/cli.js code --model sonnet", + want: types.AssistantTypeClaudeCode, + }, + { + name: "claude code router status is not assistant", + command: "ccr status", + want: types.AssistantTypeUnknown, + }, { name: "regular shell command is not ai assistant", command: `bash -lc "echo codex"`,