From 6eb24006f9d7bc39793baad5dda51cbd422cce32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 31 Jan 2026 00:51:17 +0000 Subject: [PATCH] feat: implement task plans feature (#131) --- .../f8829b44-c035-4de0-bd2e-9778dabf4782.md | 152 --- apps/backend/cmd/agentctl/main.go | 25 +- apps/backend/cmd/kandev/agents.go | 8 +- apps/backend/cmd/kandev/main.go | 11 +- .../internal/agent/lifecycle/container.go | 2 - .../internal/agent/lifecycle/manager.go | 28 +- .../internal/agent/lifecycle/runtime.go | 1 - .../agent/lifecycle/runtime_docker.go | 1 - .../agent/lifecycle/runtime_standalone.go | 1 - .../internal/agent/lifecycle/session.go | 12 +- .../internal/agent/lifecycle/streams.go | 14 +- .../backend/internal/agentctl/client/agent.go | 46 +- .../internal/agentctl/client/control.go | 3 +- .../agentctl/server/adapter/adapter.go | 18 +- .../server/adapter/transport/acp/adapter.go | 6 + .../server/adapter/transport/codex/adapter.go | 179 +--- .../adapter/transport/opencode/adapter.go | 6 + .../adapter/transport/streamjson/adapter.go | 6 + .../internal/agentctl/server/api/agent.go | 117 ++- .../internal/agentctl/server/api/server.go | 25 +- .../internal/agentctl/server/config/config.go | 37 +- .../agentctl/server/instance/instance.go | 4 - .../agentctl/server/instance/manager.go | 1 - .../agentctl/server/mcp/backend_client.go | 126 +++ .../internal/agentctl/server/mcp/handlers.go | 119 ++- .../internal/agentctl/server/mcp/server.go | 63 +- .../agentctl/server/process/manager.go | 70 +- .../agentctl/server/wsclient/client.go | 177 ---- apps/backend/internal/events/types.go | 7 + .../gateway/websocket/task_notifications.go | 3 + .../internal/integration/integration_test.go | 3 +- .../internal/integration/orchestrator_test.go | 3 +- .../backend/internal/mcp/handlers/handlers.go | 173 ++- apps/backend/internal/task/dto/dto.go | 27 + .../task/handlers/process_handlers_test.go | 14 + .../internal/task/handlers/task_handlers.go | 139 ++- apps/backend/internal/task/models/models.go | 11 + .../internal/task/repository/interface.go | 6 + .../internal/task/repository/sqlite/base.go | 19 + .../internal/task/repository/sqlite/plan.go | 85 ++ .../internal/task/service/plan_service.go | 195 ++++ .../internal/workflow/repository/sqlite.go | 6 +- apps/backend/pkg/websocket/actions.go | 11 + apps/pnpm-lock.yaml | 981 +++++++++++++++++- apps/web/app/globals.css | 172 +++ .../task/chat/chat-input-container.tsx | 23 - apps/web/components/task/markdown-editor.tsx | 66 ++ .../web/components/task/task-center-panel.tsx | 99 +- apps/web/components/task/task-chat-panel.tsx | 48 - .../web/components/task/task-plan-dialogs.tsx | 75 ++ apps/web/components/task/task-plan-panel.tsx | 185 ++++ .../web/components/task/task-plan-toolbar.tsx | 220 ++++ .../hooks/domains/session/use-task-plan.ts | 131 +++ apps/web/lib/api/domains/plan-api.ts | 81 ++ apps/web/lib/state/slices/index.ts | 1 + .../lib/state/slices/session/session-slice.ts | 22 + apps/web/lib/state/slices/session/types.ts | 15 +- apps/web/lib/state/store.ts | 8 + apps/web/lib/types/backend.ts | 16 + apps/web/lib/types/http.ts | 15 + apps/web/lib/utils.ts | 23 + apps/web/lib/ws/handlers/task-plans.ts | 31 + apps/web/lib/ws/router.ts | 2 + apps/web/package.json | 9 + 64 files changed, 3447 insertions(+), 736 deletions(-) delete mode 100644 .kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md create mode 100644 apps/backend/internal/agentctl/server/mcp/backend_client.go delete mode 100644 apps/backend/internal/agentctl/server/wsclient/client.go create mode 100644 apps/backend/internal/task/repository/sqlite/plan.go create mode 100644 apps/backend/internal/task/service/plan_service.go create mode 100644 apps/web/components/task/markdown-editor.tsx create mode 100644 apps/web/components/task/task-plan-dialogs.tsx create mode 100644 apps/web/components/task/task-plan-panel.tsx create mode 100644 apps/web/components/task/task-plan-toolbar.tsx create mode 100644 apps/web/hooks/domains/session/use-task-plan.ts create mode 100644 apps/web/lib/api/domains/plan-api.ts create mode 100644 apps/web/lib/ws/handlers/task-plans.ts diff --git a/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md b/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md deleted file mode 100644 index ec52f89de4..0000000000 --- a/.kandev/plans/f8829b44-c035-4de0-bd2e-9778dabf4782.md +++ /dev/null @@ -1,152 +0,0 @@ -# File Reference Feature Implementation Plan - -## Overview -Implement file referencing in chat input with `@filename` autocomplete. When a user types `@`, they should see autocomplete suggestions for files in the workspace. Selected files are passed as **path references** in the prompt - the agent can then read them from its workspace using its own tools. - -**Key insight**: The agent runs in the workspace where files exist. We don't need to embed file contents - just tell the agent which files the user is referring to. The agent can read them itself. - -## Requirements Analysis - -1. **Frontend: `@filename` Autocomplete** - - Detect `@` prefix followed by path characters in chat input - - Debounce search requests (only search when user stops typing ~300ms) - - Call backend to search/filter files in the workspace - - Display matching files as autocomplete options - - Insert selected file reference into the chat input (keep as `@path/to/file.ts`) - -2. **Backend: File Search Endpoint** - - New WebSocket action `workspace.files.search` for searching files - - Accept session_id (to route to correct agentctl) and query string - - Return matching file paths (fuzzy match) - -3. **Prompt Format** - - Keep `@filepath` references in the message as-is - - Agent sees: "Look at @src/main.ts and fix the bug" - - Agent uses its tools to read the file when needed - -## Architecture Flow - -``` -User types @main.ts → Frontend debounces 300ms → WS request workspace.files.search - ↓ - Backend routes to agentctl - ↓ - agentctl searches worktree (git ls-files) - ↓ - Returns matching files - ↓ - Frontend shows autocomplete - ↓ -User selects file → Insert @src/main.ts in input - ↓ -User submits → message.add with content "Look at @src/main.ts" - ↓ - Agent receives prompt, reads file using its tools -``` - -## Files to Modify/Create - -### Backend (Go) - -1. **`apps/backend/pkg/websocket/actions.go`** - - Add `ActionWorkspaceFilesSearch = "workspace.files.search"` - -2. **`apps/backend/internal/agentctl/server/process/workspace_tracker.go`** - - Add `SearchFiles(query string, limit int) ([]string, error)` method - - Use fuzzy/substring matching on cached file list from `git ls-files` - -3. **`apps/backend/internal/agentctl/server/api/workspace.go`** - - Add `handleFileSearch` HTTP endpoint: `GET /api/v1/workspace/search?q=&limit=` - -4. **`apps/backend/internal/agentctl/client/client.go`** - - Add `SearchFiles(ctx, query, limit) ([]string, error)` method - -5. **`apps/backend/internal/agent/handlers/workspace_file_handlers.go`** - - Add `wsSearchFiles` handler for `workspace.files.search` - -6. **`apps/backend/internal/agentctl/types/streams/file.go`** - - Add `FileSearchRequest` and `FileSearchResponse` types - -### Frontend (TypeScript) - -1. **`apps/web/hooks/use-chat-completions.ts`** - - Add file path completion alongside prompt completions - - Accept sessionId prop for file search context - - Use async completion source with debouncing - -2. **`apps/web/lib/ws/workspace-files.ts`** - - Add `searchWorkspaceFiles(client, sessionId, query, limit)` function - -3. **`apps/web/lib/types/backend.ts`** - - Add `FileSearchResponse` type - -4. **`apps/web/components/task/task-chat-input.tsx`** - - Pass sessionId to useChatCompletions hook - -5. **`apps/web/components/task/task-chat-panel.tsx`** - - Pass sessionId down to TaskChatInput - -## Implementation Approach - -### Phase 1: Backend File Search -1. Add `FileSearchResponse` type in `types/streams/file.go` -2. Add `SearchFiles` method in `workspace_tracker.go` (fuzzy match on git ls-files) -3. Add `handleFileSearch` HTTP endpoint in `workspace.go` -4. Add `SearchFiles` client method in `client.go` -5. Add WS action constant in `actions.go` -6. Add `wsSearchFiles` handler in `workspace_file_handlers.go` - -### Phase 2: Frontend Autocomplete -1. Add `searchWorkspaceFiles` function in `workspace-files.ts` -2. Add `FileSearchResponse` type in `backend.ts` -3. Extend `useChatCompletions` to accept sessionId and add file completions -4. Update `TaskChatInput` to receive and pass sessionId -5. Update `TaskChatPanel` to pass sessionId to input - -## Fuzzy Search Algorithm - -Simple substring matching with scoring: -- Exact filename match: highest score -- Filename contains query: high score -- Path contains query: medium score -- Sort by score, return top N results - -```go -func (wt *WorkspaceTracker) SearchFiles(query string, limit int) []string { - query = strings.ToLower(query) - var matches []scoredMatch - - for _, file := range wt.currentFiles.Files { - path := strings.ToLower(file.Path) - name := filepath.Base(path) - - score := 0 - if name == query { score = 100 } - else if strings.Contains(name, query) { score = 50 } - else if strings.Contains(path, query) { score = 25 } - - if score > 0 { - matches = append(matches, scoredMatch{path: file.Path, score: score}) - } - } - - sort.Slice(matches, func(i, j int) bool { return matches[i].score > matches[j].score }) - // Return top `limit` results -} -``` - -## Risks & Considerations - -1. **Performance**: Large repos could have many files. Solutions: - - Limit search results (default 20) - - Cache file list on workspace tracker (already done via git ls-files) - - Client-side debouncing (300ms) - -2. **Session Requirement**: File search requires an active session. Solutions: - - Only show file completions when sessionId is available - - Gracefully return empty results if no session - -3. **Concurrent Searches**: User typing fast could queue many searches. Solutions: - - Debouncing handles this naturally - - Each new search supersedes previous results - diff --git a/apps/backend/cmd/agentctl/main.go b/apps/backend/cmd/agentctl/main.go index c45cfce003..d78f69eaaf 100644 --- a/apps/backend/cmd/agentctl/main.go +++ b/apps/backend/cmd/agentctl/main.go @@ -22,7 +22,6 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/instance" mcpserver "github.com/kandev/kandev/internal/agentctl/server/mcp" "github.com/kandev/kandev/internal/agentctl/server/process" - "github.com/kandev/kandev/internal/agentctl/server/wsclient" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" "go.uber.org/zap" @@ -84,24 +83,16 @@ func run(cfg *config.Config, log *logger.Logger) { // Set the server factory to create API servers for each instance instMgr.SetServerFactory(func(instCfg *config.InstanceConfig, procMgr *process.Manager, instLog *logger.Logger) http.Handler { - var mcpSrv *mcpserver.Server + // Create MCP backend client for bidirectional communication through agent stream + // MCP requests from agents are sent through the agent stream WebSocket to the backend + mcpBackendClient := mcpserver.NewChannelBackendClient() - // Create MCP server if backend WS URL is configured - if instCfg.BackendWsURL != "" { - wsClient := wsclient.New(instCfg.BackendWsURL, instLog) - if err := wsClient.Connect(context.Background()); err != nil { - instLog.Error("failed to connect MCP WS client to backend", - zap.Error(err), - zap.String("backend_ws_url", instCfg.BackendWsURL)) - } else { - mcpSrv = mcpserver.New(wsClient, instCfg.SessionID, instLog) - instLog.Info("MCP server enabled", - zap.String("backend_ws_url", instCfg.BackendWsURL), - zap.String("session_id", instCfg.SessionID)) - } - } + // Create MCP server using the channel-based backend client + mcpSrv := mcpserver.New(mcpBackendClient, instCfg.SessionID, instLog) + instLog.Info("MCP server enabled (channel-based)", + zap.String("session_id", instCfg.SessionID)) - return api.NewServer(instCfg, procMgr, mcpSrv, instLog).Router() + return api.NewServer(instCfg, procMgr, mcpSrv, mcpBackendClient, instLog).Router() }) // Create control server diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index 8220277a32..bc5172f612 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "os" "go.uber.org/zap" @@ -93,11 +92,8 @@ func provideLifecycleManager( log, ) - // Set backend WS URL for MCP tunneling - // agentctl instances will connect back to this URL to forward MCP tool calls - backendWsURL := fmt.Sprintf("ws://%s:%d/ws", cfg.Server.Host, cfg.Server.Port) - lifecycleMgr.SetBackendWsURL(backendWsURL) - log.Info("Backend WS URL configured for MCP tunneling", zap.String("url", backendWsURL)) + // MCP handler is set later in main.go after MCP handlers are registered + // via lifecycleMgr.SetMCPHandler(gateway.Dispatcher) if err := lifecycleMgr.Start(ctx); err != nil { return nil, nil, err diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 0d4333d395..6c2c917b4d 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -51,6 +51,7 @@ import ( // Task Service packages taskcontroller "github.com/kandev/kandev/internal/task/controller" taskhandlers "github.com/kandev/kandev/internal/task/handlers" + taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" userhandlers "github.com/kandev/kandev/internal/user/handlers" @@ -512,7 +513,8 @@ func main() { // Task Service handlers (HTTP + WebSocket) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, orchestratorSvc, log) + planService := taskservice.NewPlanService(taskRepo, eventBus, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, orchestratorSvc, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) @@ -558,12 +560,17 @@ func main() { workflowCtrl, clarificationStore, msgCreator, - taskRepo, + planService, log, ) mcpHandlers.RegisterHandlers(gateway.Dispatcher) log.Debug("Registered MCP handlers (WebSocket)") + // Set MCP handler for lifecycle manager to dispatch MCP requests from agents + // MCP requests flow: agent -> agentctl -> agent stream (WS) -> backend -> dispatcher + lifecycleMgr.SetMCPHandler(gateway.Dispatcher) + log.Debug("MCP handler configured for agent lifecycle manager") + debughandlers.RegisterRoutes(router, log) log.Debug("Registered Debug handlers (HTTP)") diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index cb74244a5b..f1574a9859 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -30,7 +30,6 @@ type ContainerConfig struct { InstanceID string MainRepoGitDir string // Path to main repo's .git directory (for worktrees) McpServers []McpServerConfig - BackendWsURL string // WebSocket URL to Kandev backend for MCP tunneling } // ContainerManager handles Docker container lifecycle operations @@ -115,7 +114,6 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe Env: config.Credentials, AutoStart: false, McpServers: mcpServers, - BackendWsURL: config.BackendWsURL, SessionID: config.SessionID, } diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 00eba1b992..a9c88b7735 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -72,9 +72,9 @@ type Manager struct { // Workspace info provider for on-demand instance creation workspaceInfoProvider WorkspaceInfoProvider - // backendWsURL is the WebSocket URL to the Kandev backend for MCP tunneling - // This is passed to agentctl instances so they can forward MCP tool calls - backendWsURL string + // mcpHandler is the MCP request dispatcher for handling MCP requests + // from agentctl instances through the agent stream. + mcpHandler agentctl.MCPHandler // Background cleanup cleanupInterval time.Duration @@ -140,6 +140,7 @@ func NewManager( } // Initialize stream manager with callbacks that delegate to manager methods + // mcpHandler will be set later via SetMCPHandler mgr.streamManager = NewStreamManager(log, StreamCallbacks{ OnAgentEvent: mgr.handleAgentEvent, OnGitStatus: mgr.handleGitStatusUpdate, @@ -150,7 +151,7 @@ func NewManager( OnShellExit: mgr.handleShellExit, OnProcessOutput: mgr.handleProcessOutput, OnProcessStatus: mgr.handleProcessStatus, - }) + }, nil) // Set session manager dependencies for full orchestration sessionManager.SetDependencies(eventPublisher, mgr.streamManager, executionStore, historyManager) @@ -174,13 +175,18 @@ func (m *Manager) SetWorktreeManager(worktreeMgr *worktree.Manager) { m.worktreeMgr = worktreeMgr } -// SetBackendWsURL sets the WebSocket URL to the Kandev backend for MCP tunneling. +// SetMCPHandler sets the MCP request handler for dispatching MCP tool calls. // -// This URL is passed to agentctl instances so they can establish a WebSocket connection -// back to the backend and forward MCP tool calls through the tunnel. -// Format: ws://host:port/ws (e.g., ws://localhost:8080/ws) -func (m *Manager) SetBackendWsURL(url string) { - m.backendWsURL = url +// MCP requests from agents flow through the agent stream (WebSocket) to the backend, +// where they are dispatched to this handler. This enables agents to use MCP tools +// like listing workspaces, boards, tasks, and asking user questions. +// +// This must be called before agents start making MCP calls. Typically set during +// initialization after the MCP handlers are created. +func (m *Manager) SetMCPHandler(handler agentctl.MCPHandler) { + m.mcpHandler = handler + // Update the stream manager with the handler + m.streamManager.mcpHandler = handler } // SetWorkspaceInfoProvider sets the provider for workspace information. @@ -455,7 +461,6 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work WorkspacePath: info.WorkspacePath, Protocol: string(agentConfig.Protocol), AgentConfig: agentConfig, - BackendWsURL: m.backendWsURL, } runtimeInstance, err := rt.CreateInstance(ctx, req) @@ -783,7 +788,6 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti Metadata: metadata, AgentConfig: agentConfig, McpServers: mcpServers, - BackendWsURL: m.backendWsURL, } runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq) diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index 6a7ebc3242..be93c84923 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -67,7 +67,6 @@ type RuntimeCreateRequest struct { Metadata map[string]interface{} McpServers []McpServerConfig AgentConfig *registry.AgentTypeConfig // Agent type info needed by runtimes - BackendWsURL string // WebSocket URL to Kandev backend for MCP tunneling } // RuntimeInstance represents an agentctl instance created by a runtime. diff --git a/apps/backend/internal/agent/lifecycle/runtime_docker.go b/apps/backend/internal/agent/lifecycle/runtime_docker.go index 0aee70e314..2a6f5cc2b8 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_docker.go +++ b/apps/backend/internal/agent/lifecycle/runtime_docker.go @@ -70,7 +70,6 @@ func (r *DockerRuntime) CreateInstance(ctx context.Context, req *RuntimeCreateRe MainRepoGitDir: mainRepoGitDir, Credentials: req.Env, // Env contains credentials from the caller McpServers: req.McpServers, - BackendWsURL: req.BackendWsURL, } // Use ContainerManager to launch container diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 8c82ec180d..f3148d493e 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -108,7 +108,6 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea Env: env, AutoStart: false, McpServers: mcpServers, - BackendWsURL: req.BackendWsURL, SessionID: req.SessionID, } diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 026ec19bd3..2897920cf1 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -183,12 +183,12 @@ func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentType } // injectSessionContext prepends session context information to the prompt. -// This allows the agent to know which session ID to use when calling MCP tools like ask_user_question. -func (sm *SessionManager) injectSessionContext(sessionID, prompt string) string { - if sessionID == "" { +// This allows the agent to know which session ID and task ID to use when calling MCP tools. +func (sm *SessionManager) injectSessionContext(taskID, sessionID, prompt string) string { + if sessionID == "" && taskID == "" { return prompt } - return fmt.Sprintf("\nKandev Session ID: %s\nUse this session ID when calling Kandev MCP tools that require a session_id parameter.\n\n\n%s", sessionID, prompt) + return fmt.Sprintf("\nKandev Task ID: %s\nKandev Session ID: %s\nUse these IDs when calling Kandev MCP tools that require task_id or session_id parameters.\n\n\n%s", taskID, sessionID, prompt) } // loadSession loads an existing session via ACP session/load @@ -318,8 +318,8 @@ func (sm *SessionManager) InitializeAndPrompt( // For resumed sessions (fork_session pattern): If we have session history but no new task description, // we still need to send a resume context prompt so the agent knows about the prior conversation. if taskDescription != "" { - // Inject session ID context so the agent knows which session to use for MCP tools - promptWithSessionContext := sm.injectSessionContext(execution.SessionID, taskDescription) + // Inject task and session ID context so the agent knows which IDs to use for MCP tools + promptWithSessionContext := sm.injectSessionContext(execution.TaskID, execution.SessionID, taskDescription) // Check if we need to inject resume context (fork_session pattern for ACP agents) effectivePrompt := sm.getResumeContextPrompt(agentConfig, execution.SessionID, promptWithSessionContext) diff --git a/apps/backend/internal/agent/lifecycle/streams.go b/apps/backend/internal/agent/lifecycle/streams.go index d4231c7312..0e834091a0 100644 --- a/apps/backend/internal/agent/lifecycle/streams.go +++ b/apps/backend/internal/agent/lifecycle/streams.go @@ -25,15 +25,17 @@ type StreamCallbacks struct { // StreamManager manages WebSocket streams to agent executions type StreamManager struct { - logger *logger.Logger - callbacks StreamCallbacks + logger *logger.Logger + callbacks StreamCallbacks + mcpHandler agentctl.MCPHandler } // NewStreamManager creates a new StreamManager -func NewStreamManager(log *logger.Logger, callbacks StreamCallbacks) *StreamManager { +func NewStreamManager(log *logger.Logger, callbacks StreamCallbacks, mcpHandler agentctl.MCPHandler) *StreamManager { return &StreamManager{ - logger: log.WithFields(zap.String("component", "stream-manager")), - callbacks: callbacks, + logger: log.WithFields(zap.String("component", "stream-manager")), + callbacks: callbacks, + mcpHandler: mcpHandler, } } @@ -94,7 +96,7 @@ func (sm *StreamManager) connectUpdatesStream(execution *AgentExecution, ready c if sm.callbacks.OnAgentEvent != nil { sm.callbacks.OnAgentEvent(execution, event) } - }) + }, sm.mcpHandler) // Signal that the stream connection attempt is complete (success or failure) // StreamUpdates returns immediately after establishing the WebSocket connection diff --git a/apps/backend/internal/agentctl/client/agent.go b/apps/backend/internal/agentctl/client/agent.go index 0828c64737..5a53a14123 100644 --- a/apps/backend/internal/agentctl/client/agent.go +++ b/apps/backend/internal/agentctl/client/agent.go @@ -6,12 +6,14 @@ import ( "encoding/json" "fmt" "net/http" + "sync" "github.com/coder/acp-go-sdk" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/constants" + ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -252,9 +254,16 @@ func (c *Client) Prompt(ctx context.Context, text string) (*PromptResponse, erro // Note: AgentEvent, PermissionNotification, PermissionOption, and // PermissionRespondRequest are re-exported from streams package at the top of this file. +// MCPHandler is the interface for handling MCP requests from agentctl. +type MCPHandler interface { + // Dispatch handles an MCP request and returns a response. + Dispatch(ctx context.Context, msg *ws.Message) (*ws.Message, error) +} + // StreamUpdates opens a WebSocket connection for streaming agent events. // Events include message chunks, reasoning, tool calls, plan updates, and completion/error. -func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent)) error { +// If mcpHandler is provided, MCP requests from agentctl will be dispatched to it and responses sent back. +func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent), mcpHandler MCPHandler) error { wsURL := "ws" + c.baseURL[4:] + "/api/v1/agent/stream" conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) @@ -268,6 +277,14 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent)) er c.logger.Info("connected to updates stream", zap.String("url", wsURL)) + // Use mutex for writing responses + var writeMu sync.Mutex + writeMessage := func(data []byte) error { + writeMu.Lock() + defer writeMu.Unlock() + return conn.WriteMessage(websocket.TextMessage, data) + } + // Read messages in a goroutine go func() { defer func() { @@ -290,6 +307,33 @@ func (c *Client) StreamUpdates(ctx context.Context, handler func(AgentEvent)) er return } + // Try to parse as ws.Message to check if it's an MCP request + var wsMsg ws.Message + if err := json.Unmarshal(message, &wsMsg); err == nil && wsMsg.Type == ws.MessageTypeRequest { + // This is an MCP request - dispatch it + if mcpHandler != nil { + go func(msg ws.Message) { + resp, err := mcpHandler.Dispatch(ctx, &msg) + if err != nil { + c.logger.Error("MCP dispatch error", zap.Error(err)) + resp, _ = ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + if resp != nil { + data, err := json.Marshal(resp) + if err != nil { + c.logger.Error("failed to marshal MCP response", zap.Error(err)) + return + } + if err := writeMessage(data); err != nil { + c.logger.Debug("failed to write MCP response", zap.Error(err)) + } + } + }(wsMsg) + } + continue + } + + // Not an MCP request, parse as agent event var event AgentEvent if err := json.Unmarshal(message, &event); err != nil { c.logger.Warn("failed to parse agent event", zap.Error(err)) diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index 6aee9dd253..9027b4d5d6 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -43,8 +43,7 @@ type CreateInstanceRequest struct { Env map[string]string `json:"env,omitempty"` AutoStart bool `json:"auto_start,omitempty"` McpServers []McpServerConfig `json:"mcp_servers,omitempty"` - BackendWsURL string `json:"backend_ws_url,omitempty"` // WebSocket URL to Kandev backend for MCP tunneling - SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls + SessionID string `json:"session_id,omitempty"` // Task session ID for MCP tool calls } // CreateInstanceResponse contains the result of creating a new agent instance. diff --git a/apps/backend/internal/agentctl/server/adapter/adapter.go b/apps/backend/internal/agentctl/server/adapter/adapter.go index 8bc063efe3..07671e75d0 100644 --- a/apps/backend/internal/agentctl/server/adapter/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/adapter.go @@ -76,20 +76,26 @@ type AgentInfo struct { // Lifecycle: // 1. Create adapter with New*Adapter(cfg, logger) // 2. Call PrepareEnvironment() before starting the agent process -// 3. Start the agent process and obtain stdin/stdout pipes -// 4. Call Connect(stdin, stdout) to wire up communication -// 5. Call Initialize() to establish protocol handshake -// 6. Use NewSession/LoadSession/Prompt/Cancel for agent interactions -// 7. Call Close() when done +// 3. Call PrepareCommandArgs() to get extra command-line args +// 4. Start the agent process and obtain stdin/stdout pipes +// 5. Call Connect(stdin, stdout) to wire up communication +// 6. Call Initialize() to establish protocol handshake +// 7. Use NewSession/LoadSession/Prompt/Cancel for agent interactions +// 8. Call Close() when done type AgentAdapter interface { // PrepareEnvironment performs protocol-specific setup before the agent process starts. - // For Codex, this writes MCP servers to ~/.codex/config.toml. // For ACP, this is a no-op (MCP servers are passed through the protocol). // For OpenCode, this returns environment variables for server authentication. // Must be called before the agent subprocess is started. // Returns a map of environment variables to add to the subprocess environment. PrepareEnvironment() (map[string]string, error) + // PrepareCommandArgs returns extra command-line arguments for the agent process. + // For Codex, this returns -c flags for MCP servers and sandbox config. + // For other protocols, this returns nil (no extra args needed). + // Must be called after PrepareEnvironment and before starting the subprocess. + PrepareCommandArgs() []string + // Connect wires up the stdin/stdout pipes from the running agent subprocess. // Must be called after the subprocess is started and before Initialize. Connect(stdin io.Writer, stdout io.Reader) error diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 39a2504efe..915968f9a0 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -96,6 +96,12 @@ func (a *Adapter) PrepareEnvironment() (map[string]string, error) { return nil, nil } +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For ACP, no extra args are needed - MCP servers are passed through the protocol. +func (a *Adapter) PrepareCommandArgs() []string { + return nil +} + // Connect wires up the stdin/stdout pipes from the running agent subprocess. func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go index eae942b4ef..94bd34e519 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/codex/adapter.go @@ -7,8 +7,6 @@ import ( "encoding/json" "fmt" "io" - "os" - "path/filepath" "strings" "sync" @@ -17,7 +15,6 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/codex" - "github.com/pelletier/go-toml/v2" "go.uber.org/zap" ) @@ -114,21 +111,58 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { } } -// PrepareEnvironment writes sandbox settings and MCP servers to the Codex config file. -// Codex reads configuration from ~/.codex/config.toml at startup time. -// This sets sandbox_mode to workspace-write to enable file editing. +// PrepareEnvironment is a no-op for Codex. MCP servers and sandbox settings +// are now passed via command-line -c flags through PrepareCommandArgs(). func (a *Adapter) PrepareEnvironment() (map[string]string, error) { - a.logger.Info("PrepareEnvironment called", - zap.Int("mcp_server_count", len(a.cfg.McpServers))) - for i, srv := range a.cfg.McpServers { - a.logger.Info("MCP server config", - zap.Int("index", i), - zap.String("name", srv.Name), - zap.String("url", srv.URL), - zap.String("type", srv.Type), - zap.String("command", srv.Command)) + a.logger.Info("PrepareEnvironment called (no-op for Codex)") + return nil, nil +} + +// PrepareCommandArgs returns extra command-line arguments for the Codex process. +// This includes -c flags for MCP servers and sandbox configuration. +// Codex uses -c key=value flags to override config at runtime. +func (a *Adapter) PrepareCommandArgs() []string { + var args []string + + // Set sandbox_mode to workspace-write to enable file editing + args = append(args, "-c", "sandbox_mode=\"workspace-write\"") + + // Enable network access in sandbox + args = append(args, "-c", "sandbox_workspace_write.network_access=true") + + // Add MCP servers as -c flags + for _, server := range a.cfg.McpServers { + safeName := sanitizeCodexServerName(server.Name) + + if server.Type == "sse" || server.Type == "http" { + // HTTP/SSE transport - use url field + // Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility + url := server.URL + if url != "" { + url = convertSSEToStreamableHTTP(url) + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.url=\"%s\"", safeName, url)) + } + } else if server.Command != "" { + // STDIO transport - use command field + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.command=\"%s\"", safeName, server.Command)) + // Add args if present + if len(server.Args) > 0 { + // TOML array format: ["arg1", "arg2"] + quotedArgs := make([]string, len(server.Args)) + for i, arg := range server.Args { + quotedArgs[i] = fmt.Sprintf("\"%s\"", arg) + } + argsStr := "[" + strings.Join(quotedArgs, ", ") + "]" + args = append(args, "-c", fmt.Sprintf("mcp_servers.%s.args=%s", safeName, argsStr)) + } + } } - return nil, WriteCodexMcpConfig(a.cfg.McpServers, "", a.logger) + + a.logger.Info("PrepareCommandArgs", + zap.Int("mcp_server_count", len(a.cfg.McpServers)), + zap.Strings("extra_args", args)) + + return args } // Connect wires up the stdin/stdout pipes from the running agent subprocess. @@ -145,120 +179,7 @@ func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { return nil } -// WriteCodexMcpConfig writes MCP server configuration and sandbox settings to Codex's config.toml. -// It merges with existing config, preserving other settings and existing MCP servers. -// Codex reads configuration from ~/.codex/config.toml at startup time, not through the protocol. -// This function should be called before starting the Codex process. -// If homeDir is empty, it uses os.UserHomeDir(). -func WriteCodexMcpConfig(mcpServers []shared.McpServerConfig, homeDir string, log *logger.Logger) error { - // Determine config directory - if homeDir == "" { - var err error - homeDir, err = os.UserHomeDir() - if err != nil { - return fmt.Errorf("failed to get user home directory: %w", err) - } - } - configDir := filepath.Join(homeDir, ".codex") - configPath := filepath.Join(configDir, "config.toml") - // Create config directory if it doesn't exist - if err := os.MkdirAll(configDir, 0o755); err != nil { - return fmt.Errorf("failed to create codex config directory: %w", err) - } - - // Read existing config if it exists - existingData, err := os.ReadFile(configPath) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read existing codex config: %w", err) - } - - // Parse existing config into a generic map to preserve all fields - var rawConfig map[string]any - if len(existingData) > 0 { - if err := toml.Unmarshal(existingData, &rawConfig); err != nil { - if log != nil { - log.Warn("failed to parse existing codex config, will create new", - zap.String("path", configPath), - zap.Error(err)) - } - rawConfig = make(map[string]any) - } - } else { - rawConfig = make(map[string]any) - } - - // CRITICAL: Set sandbox_mode to workspace-write to enable file editing. - // This MUST be set in config.toml as it takes precedence over protocol settings. - // Without this, Codex defaults to read-only mode and cannot edit files. - rawConfig["sandbox_mode"] = "workspace-write" - - // Configure sandbox workspace-write settings - sandboxConfig, ok := rawConfig["sandbox_workspace_write"].(map[string]any) - if !ok { - sandboxConfig = make(map[string]any) - } - sandboxConfig["network_access"] = true - rawConfig["sandbox_workspace_write"] = sandboxConfig - - // Get or create mcp_servers section - mcpServersSection, ok := rawConfig["mcp_servers"].(map[string]any) - if !ok { - mcpServersSection = make(map[string]any) - } - - // Add/update our MCP servers - for _, server := range mcpServers { - safeName := sanitizeCodexServerName(server.Name) - serverConfig := make(map[string]any) - - if server.Type == "sse" || server.Type == "http" { - // HTTP/SSE transport - use url field - // Codex doesn't support SSE transport - it uses streamable HTTP which requires POST requests. - // Convert SSE URLs (/sse) to streamable HTTP URLs (/mcp) for Codex compatibility. - url := server.URL - if url != "" { - url = convertSSEToStreamableHTTP(url) - serverConfig["url"] = url - } - } else { - // STDIO transport - use command and args - if server.Command != "" { - serverConfig["command"] = server.Command - } - if len(server.Args) > 0 { - serverConfig["args"] = server.Args - } - } - - mcpServersSection[safeName] = serverConfig - } - - // Update the mcp_servers section in the config - if len(mcpServersSection) > 0 { - rawConfig["mcp_servers"] = mcpServersSection - } - - // Marshal back to TOML - output, err := toml.Marshal(rawConfig) - if err != nil { - return fmt.Errorf("failed to marshal codex config: %w", err) - } - - // Write config file - if err := os.WriteFile(configPath, output, 0o644); err != nil { - return fmt.Errorf("failed to write codex config: %w", err) - } - - if log != nil { - log.Info("wrote Codex config", - zap.String("path", configPath), - zap.String("sandbox_mode", "workspace-write"), - zap.Int("mcp_server_count", len(mcpServers))) - } - - return nil -} // sanitizeCodexServerName converts a server name to a valid TOML table name. // Replaces spaces and special characters with underscores. diff --git a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go index c4b7325f2f..8e4da05a4a 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/opencode/adapter.go @@ -180,6 +180,12 @@ func (a *Adapter) PrepareEnvironment() (map[string]string, error) { return env, nil } +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For OpenCode, no extra args are needed - MCP is configured via config file. +func (a *Adapter) PrepareCommandArgs() []string { + return nil +} + // Connect wires up the stdout pipe from the running agent subprocess. // For OpenCode, we only need stdout to parse the server URL. func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { diff --git a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go index dc3a21aa4f..2c77e5addc 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/streamjson/adapter.go @@ -131,6 +131,12 @@ func (a *Adapter) PrepareEnvironment() (map[string]string, error) { return nil, nil } +// PrepareCommandArgs returns extra command-line arguments for the agent process. +// For stream-json, no extra args are needed - MCP is configured via config files. +func (a *Adapter) PrepareCommandArgs() []string { + return nil +} + // Connect wires up the stdin/stdout pipes from the running agent subprocess. func (a *Adapter) Connect(stdin io.Writer, stdout io.Reader) error { a.mu.Lock() diff --git a/apps/backend/internal/agentctl/server/api/agent.go b/apps/backend/internal/agentctl/server/api/agent.go index d7ebd24c3b..61502c628f 100644 --- a/apps/backend/internal/agentctl/server/api/agent.go +++ b/apps/backend/internal/agentctl/server/api/agent.go @@ -5,12 +5,14 @@ import ( "encoding/json" "fmt" "net/http" + "sync" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/constants" + ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) @@ -274,35 +276,118 @@ func (s *Server) handleAgentPrompt(c *gin.Context) { } // handleAgentStreamWS streams agent session notifications via WebSocket. +// This is a bidirectional stream: +// - agentctl -> backend: agent events, MCP requests +// - backend -> agentctl: MCP responses func (s *Server) handleAgentStreamWS(c *gin.Context) { conn, err := s.upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { s.logger.Error("WebSocket upgrade failed", zap.Error(err)) return } - defer func() { - if err := conn.Close(); err != nil { - s.logger.Debug("failed to close ACP stream websocket", zap.Error(err)) - } - }() s.logger.Info("agent stream WebSocket connected") + ctx, cancel := context.WithCancel(c.Request.Context()) + defer cancel() + + // Use a mutex for writing to the WebSocket + var writeMu sync.Mutex + writeMessage := func(data []byte) error { + writeMu.Lock() + defer writeMu.Unlock() + return conn.WriteMessage(websocket.TextMessage, data) + } + // Get the session updates channel updatesCh := s.procMgr.GetUpdates() - // Stream session notifications to WebSocket - for notification := range updatesCh { - data, err := json.Marshal(notification) - if err != nil { - s.logger.Error("failed to marshal notification", zap.Error(err)) - continue - } - - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { - return - } + // Get MCP request channel (if MCP is enabled) + var mcpRequestCh <-chan *ws.Message + if s.mcpBackendClient != nil { + mcpRequestCh = s.mcpBackendClient.GetRequestChannel() } + + // WaitGroup for cleanup + var wg sync.WaitGroup + + // Read goroutine: reads MCP responses from the backend + wg.Add(1) + go func() { + defer wg.Done() + defer cancel() + for { + _, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + s.logger.Info("agent stream closed normally") + } else { + s.logger.Debug("agent stream read error", zap.Error(err)) + } + return + } + + // Parse as WebSocket message (MCP response) + var msg ws.Message + if err := json.Unmarshal(message, &msg); err != nil { + s.logger.Warn("failed to parse message", zap.Error(err)) + continue + } + + // Forward MCP response to the backend client + if s.mcpBackendClient != nil && (msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError) { + s.mcpBackendClient.HandleResponse(&msg) + } + } + }() + + // Write goroutine: sends agent events and MCP requests to the backend + wg.Add(1) + go func() { + defer wg.Done() + defer func() { + if err := conn.Close(); err != nil { + s.logger.Debug("failed to close agent stream websocket", zap.Error(err)) + } + }() + + for { + select { + case <-ctx.Done(): + return + case notification, ok := <-updatesCh: + if !ok { + return + } + data, err := json.Marshal(notification) + if err != nil { + s.logger.Error("failed to marshal notification", zap.Error(err)) + continue + } + if err := writeMessage(data); err != nil { + s.logger.Debug("failed to write notification", zap.Error(err)) + return + } + case mcpReq, ok := <-mcpRequestCh: + if !ok { + // MCP channel closed, continue without MCP + mcpRequestCh = nil + continue + } + data, err := json.Marshal(mcpReq) + if err != nil { + s.logger.Error("failed to marshal MCP request", zap.Error(err)) + continue + } + if err := writeMessage(data); err != nil { + s.logger.Debug("failed to write MCP request", zap.Error(err)) + return + } + } + } + }() + + wg.Wait() } // PermissionRespondRequest is a request to respond to a permission request diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 16fc0e254f..dbdd98c977 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -17,26 +17,29 @@ import ( // Server is the HTTP API server for a single agent instance. type Server struct { - cfg *config.InstanceConfig - procMgr *process.Manager - mcpServer *mcp.Server - logger *logger.Logger - router *gin.Engine + cfg *config.InstanceConfig + procMgr *process.Manager + mcpServer *mcp.Server + mcpBackendClient *mcp.ChannelBackendClient + logger *logger.Logger + router *gin.Engine upgrader websocket.Upgrader } // NewServer creates a new API server for an agent instance. // If mcpServer is provided, MCP routes will be registered. -func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *mcp.Server, log *logger.Logger) *Server { +// If mcpBackendClient is provided, the agent stream becomes bidirectional for MCP. +func NewServer(cfg *config.InstanceConfig, procMgr *process.Manager, mcpServer *mcp.Server, mcpBackendClient *mcp.ChannelBackendClient, log *logger.Logger) *Server { gin.SetMode(gin.ReleaseMode) s := &Server{ - cfg: cfg, - procMgr: procMgr, - mcpServer: mcpServer, - logger: log.WithFields(zap.String("component", "api-server")), - router: gin.New(), + cfg: cfg, + procMgr: procMgr, + mcpServer: mcpServer, + mcpBackendClient: mcpBackendClient, + logger: log.WithFields(zap.String("component", "api-server")), + router: gin.New(), upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins for container-local communication diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 0cbffd4199..62273912ad 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -137,10 +137,6 @@ type InstanceConfig struct { // ProcessBufferMaxBytes caps per-process output buffer size ProcessBufferMaxBytes int64 - // BackendWsURL is the WebSocket URL to connect back to the Kandev backend - // for MCP tool requests (e.g., ws://localhost:8080/ws) - BackendWsURL string - // SessionID is the session ID for this agent instance (used in MCP tool calls) SessionID string } @@ -210,14 +206,19 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst if len(overrides.McpServers) > 0 { cfg.McpServers = overrides.McpServers } - if overrides.BackendWsURL != "" { - cfg.BackendWsURL = overrides.BackendWsURL - } if overrides.SessionID != "" { cfg.SessionID = overrides.SessionID } } + // Inject local kandev MCP server for MCP tunneling through the agent stream + // This ensures the kandev MCP server is available for protocols that read MCP config + // at startup time (e.g., Codex via -c flags). The MCP server uses the agent stream + // WebSocket connection (bidirectional) to forward tool calls to the backend. + if port > 0 { + cfg.McpServers = injectKandevMcpServer(cfg.McpServers, port) + } + // Parse agent command into args cfg.AgentArgs = ParseCommand(cfg.AgentCommand) @@ -239,7 +240,6 @@ type InstanceOverrides struct { ApprovalPolicy string AgentType string McpServers []McpServerConfig - BackendWsURL string SessionID string } @@ -307,3 +307,24 @@ func getEnvBool(key string, defaultValue bool) bool { } return defaultValue } + +// injectKandevMcpServer prepends the local kandev MCP server to the list of MCP servers. +// This replaces any existing kandev server to avoid duplicates. +// The kandev MCP server provides tools like ask_user_question to the agent. +func injectKandevMcpServer(servers []McpServerConfig, port int) []McpServerConfig { + localKandevMcp := McpServerConfig{ + Name: "kandev", + Type: "sse", + URL: "http://localhost:" + strconv.Itoa(port) + "/sse", + } + + // Filter out any existing kandev server and prepend the local one + result := make([]McpServerConfig, 0, len(servers)+1) + result = append(result, localKandevMcp) + for _, srv := range servers { + if srv.Name != "kandev" { + result = append(result, srv) + } + } + return result +} diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index 42f1ca2c8a..494cbcc959 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -81,10 +81,6 @@ type CreateRequest struct { // McpServers is a list of MCP servers to configure for the agent. McpServers []McpServerConfig `json:"mcp_servers,omitempty"` - // BackendWsURL is the WebSocket URL to the Kandev backend for MCP tunneling. - // If set, the MCP server will be enabled and forward tool calls to the backend. - BackendWsURL string `json:"backend_ws_url,omitempty"` - // SessionID is the task session ID for MCP tool calls (used by ask_user_question). SessionID string `json:"session_id,omitempty"` } diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index 3965bb1e11..a074cd20ad 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -133,7 +133,6 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea Env: config.CollectAgentEnv(req.Env), AgentType: req.AgentType, McpServers: mcpServers, - BackendWsURL: req.BackendWsURL, SessionID: req.SessionID, } diff --git a/apps/backend/internal/agentctl/server/mcp/backend_client.go b/apps/backend/internal/agentctl/server/mcp/backend_client.go new file mode 100644 index 0000000000..617333ced6 --- /dev/null +++ b/apps/backend/internal/agentctl/server/mcp/backend_client.go @@ -0,0 +1,126 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// MCPRequest represents an MCP request to be sent to the backend. +type MCPRequest struct { + ID string `json:"id"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` +} + +// MCPResponse represents an MCP response from the backend. +type MCPResponse struct { + ID string `json:"id"` + Type string `json:"type"` // "response" or "error" + Payload json.RawMessage `json:"payload"` +} + +// ChannelBackendClient implements BackendClient using channels. +// It sends MCP requests through a channel that will be read by the agent stream handler, +// and receives responses through a callback mechanism. +type ChannelBackendClient struct { + requestCh chan *ws.Message + pending map[string]chan *ws.Message + pendingMu sync.Mutex +} + +// NewChannelBackendClient creates a new channel-based backend client. +func NewChannelBackendClient() *ChannelBackendClient { + return &ChannelBackendClient{ + requestCh: make(chan *ws.Message, 100), + pending: make(map[string]chan *ws.Message), + } +} + +// GetRequestChannel returns the channel for outgoing MCP requests. +// The agent stream handler should read from this channel and forward to the backend. +func (c *ChannelBackendClient) GetRequestChannel() <-chan *ws.Message { + return c.requestCh +} + +// HandleResponse handles an incoming MCP response from the backend. +// This should be called by the agent stream handler when it receives a response. +func (c *ChannelBackendClient) HandleResponse(msg *ws.Message) { + c.pendingMu.Lock() + ch, ok := c.pending[msg.ID] + delete(c.pending, msg.ID) + c.pendingMu.Unlock() + + if ok { + ch <- msg + } +} + +// RequestPayload sends a request to the backend and unmarshals the response. +func (c *ChannelBackendClient) RequestPayload(ctx context.Context, action string, payload, result interface{}) error { + id := uuid.New().String() + + msg, err := ws.NewRequest(id, action, payload) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Create response channel + respChan := make(chan *ws.Message, 1) + c.pendingMu.Lock() + c.pending[id] = respChan + c.pendingMu.Unlock() + + // Send request through channel + select { + case c.requestCh <- msg: + // Request sent + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return ctx.Err() + case <-time.After(5 * time.Second): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return fmt.Errorf("timeout sending request to agent stream") + } + + // Wait for response + select { + case resp := <-respChan: + if resp.Type == ws.MessageTypeError { + var ep struct { + Code string `json:"code"` + Message string `json:"message"` + } + if json.Unmarshal(resp.Payload, &ep) == nil { + return fmt.Errorf("backend error [%s]: %s", ep.Code, ep.Message) + } + return fmt.Errorf("backend error: %s", string(resp.Payload)) + } + if result != nil && len(resp.Payload) > 0 { + if err := json.Unmarshal(resp.Payload, result); err != nil { + return fmt.Errorf("failed to unmarshal response: %w", err) + } + } + return nil + case <-ctx.Done(): + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + return ctx.Err() + } +} + +// Close closes the request channel. +func (c *ChannelBackendClient) Close() { + close(c.requestCh) +} + diff --git a/apps/backend/internal/agentctl/server/mcp/handlers.go b/apps/backend/internal/agentctl/server/mcp/handlers.go index 8c30146c79..2a9bf35107 100644 --- a/apps/backend/internal/agentctl/server/mcp/handlers.go +++ b/apps/backend/internal/agentctl/server/mcp/handlers.go @@ -18,12 +18,16 @@ const ( ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" ActionMCPAskUserQuestion = "mcp.ask_user_question" + ActionMCPCreateTaskPlan = "mcp.create_task_plan" + ActionMCPGetTaskPlan = "mcp.get_task_plan" + ActionMCPUpdateTaskPlan = "mcp.update_task_plan" + ActionMCPDeleteTaskPlan = "mcp.delete_task_plan" ) func (s *Server) listWorkspacesHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { var result []map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPListWorkspaces, nil, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPListWorkspaces, nil, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -39,7 +43,7 @@ func (s *Server) listBoardsHandler() server.ToolHandlerFunc { } payload := map[string]string{"workspace_id": workspaceID} var result []map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPListBoards, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPListBoards, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -55,7 +59,7 @@ func (s *Server) listWorkflowStepsHandler() server.ToolHandlerFunc { } payload := map[string]string{"board_id": boardID} var result []map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPListWorkflowSteps, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPListWorkflowSteps, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -71,7 +75,7 @@ func (s *Server) listTasksHandler() server.ToolHandlerFunc { } payload := map[string]string{"board_id": boardID} var result []map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPListTasks, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPListTasks, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -107,7 +111,7 @@ func (s *Server) createTaskHandler() server.ToolHandlerFunc { "description": description, } var result map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPCreateTask, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPCreateTask, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -132,7 +136,7 @@ func (s *Server) updateTaskHandler() server.ToolHandlerFunc { payload["state"] = state } var result map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPUpdateTask, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPUpdateTask, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } data, _ := json.MarshalIndent(result, "", " ") @@ -202,7 +206,7 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { "context": questionCtx, } var result map[string]interface{} - if err := s.wsClient.RequestPayload(ctx, ActionMCPAskUserQuestion, payload, &result); err != nil { + if err := s.backend.RequestPayload(ctx, ActionMCPAskUserQuestion, payload, &result); err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -227,3 +231,104 @@ func (s *Server) askUserQuestionHandler() server.ToolHandlerFunc { } } +func (s *Server) createTaskPlanHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := req.RequireString("task_id") + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + content, err := req.RequireString("content") + if err != nil { + return mcp.NewToolResultError("content is required"), nil + } + title := req.GetString("title", "Plan") + + payload := map[string]interface{}{ + "task_id": taskID, + "content": content, + "title": title, + "created_by": "agent", + } + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ActionMCPCreateTaskPlan, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(fmt.Sprintf("Plan created successfully:\n%s", string(data))), nil + } +} + +func (s *Server) getTaskPlanHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := req.RequireString("task_id") + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + + payload := map[string]string{"task_id": taskID} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ActionMCPGetTaskPlan, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Check if plan exists + if len(result) == 0 { + return mcp.NewToolResultText("No plan exists for this task yet."), nil + } + + // Return the plan content for easy reading + if content, ok := result["content"].(string); ok { + return mcp.NewToolResultText(fmt.Sprintf("# Task Plan\n\n%s", content)), nil + } + + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + +func (s *Server) updateTaskPlanHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := req.RequireString("task_id") + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + content, err := req.RequireString("content") + if err != nil { + return mcp.NewToolResultError("content is required"), nil + } + title := req.GetString("title", "") + + payload := map[string]interface{}{ + "task_id": taskID, + "content": content, + "created_by": "agent", + } + if title != "" { + payload["title"] = title + } + + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ActionMCPUpdateTaskPlan, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(fmt.Sprintf("Plan updated successfully:\n%s", string(data))), nil + } +} + +func (s *Server) deleteTaskPlanHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := req.RequireString("task_id") + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + + payload := map[string]string{"task_id": taskID} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ActionMCPDeleteTaskPlan, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText("Plan deleted successfully."), nil + } +} + diff --git a/apps/backend/internal/agentctl/server/mcp/server.go b/apps/backend/internal/agentctl/server/mcp/server.go index 85fa20e25c..3948e1b7b9 100644 --- a/apps/backend/internal/agentctl/server/mcp/server.go +++ b/apps/backend/internal/agentctl/server/mcp/server.go @@ -1,23 +1,28 @@ // Package mcp provides MCP server functionality for agentctl. -// It exposes MCP tools that forward requests to the Kandev backend via WebSocket. +// It exposes MCP tools that forward requests to the Kandev backend via the agent stream. package mcp import ( "context" - "net/http" "sync" "github.com/gin-gonic/gin" - "github.com/kandev/kandev/internal/agentctl/server/wsclient" "github.com/kandev/kandev/internal/common/logger" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "go.uber.org/zap" ) -// Server wraps the MCP server with WebSocket client for backend communication. +// BackendClient is the interface for communicating with the Kandev backend. +// MCP tool handlers use this to forward requests to the backend. +type BackendClient interface { + // RequestPayload sends a request to the backend and unmarshals the response. + RequestPayload(ctx context.Context, action string, payload, result interface{}) error +} + +// Server wraps the MCP server with backend client for communication. type Server struct { - wsClient *wsclient.Client + backend BackendClient sessionID string mcpServer *server.MCPServer sseServer *server.SSEServer @@ -28,9 +33,9 @@ type Server struct { } // New creates a new MCP server for agentctl. -func New(wsClient *wsclient.Client, sessionID string, log *logger.Logger) *Server { +func New(backend BackendClient, sessionID string, log *logger.Logger) *Server { s := &Server{ - wsClient: wsClient, + backend: backend, sessionID: sessionID, logger: log.WithFields(zap.String("component", "mcp-server")), } @@ -187,13 +192,41 @@ Another example: s.askUserQuestionHandler(), ) - s.logger.Info("registered MCP tools", zap.Int("count", 7)) -} + s.mcpServer.AddTool( + mcp.NewTool("create_task_plan", + mcp.WithDescription("Create or save a task plan. Use this to save your implementation plan for the current task."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to create a plan for")), + mcp.WithString("content", mcp.Required(), mcp.Description("The plan content in markdown format")), + mcp.WithString("title", mcp.Description("Optional title for the plan (default: 'Plan')")), + ), + s.createTaskPlanHandler(), + ) -// WrapHandler wraps a handler function with error handling for gin. -func WrapHandler(h http.Handler) gin.HandlerFunc { - return func(c *gin.Context) { - h.ServeHTTP(c.Writer, c.Request) - } -} + s.mcpServer.AddTool( + mcp.NewTool("get_task_plan", + mcp.WithDescription("Get the current plan for a task. Use this to retrieve an existing plan, including any user edits."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to get the plan for")), + ), + s.getTaskPlanHandler(), + ) + s.mcpServer.AddTool( + mcp.NewTool("update_task_plan", + mcp.WithDescription("Update an existing task plan. Use this to modify the plan during implementation."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to update the plan for")), + mcp.WithString("content", mcp.Required(), mcp.Description("The updated plan content in markdown format")), + mcp.WithString("title", mcp.Description("Optional new title for the plan")), + ), + s.updateTaskPlanHandler(), + ) + + s.mcpServer.AddTool( + mcp.NewTool("delete_task_plan", + mcp.WithDescription("Delete a task plan."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to delete the plan for")), + ), + s.deleteTaskPlanHandler(), + ) + + s.logger.Info("registered MCP tools", zap.Int("count", 11)) +} diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index a7e7540eeb..1617883cb8 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -213,42 +213,12 @@ func (m *Manager) Start(ctx context.Context) error { m.exitCode.Store(-1) m.exitErr.Store(errorWrapper{err: nil}) - // Create command + // Validate command if len(m.cfg.AgentArgs) == 0 { m.status.Store(StatusError) return fmt.Errorf("no agent command configured") } - // NOTE: We intentionally don't use exec.CommandContext here because we don't want - // the HTTP request context to kill the agent process when the request completes. - m.cmd = exec.Command(m.cfg.AgentArgs[0], m.cfg.AgentArgs[1:]...) - m.cmd.Dir = m.cfg.WorkDir - m.cmd.Env = m.cfg.AgentEnv - // Create a new process group so we can kill all child processes together. - // This is important for adapters like OpenCode that spawn child processes - // (npx -> sh -> node -> opencode binary). - setProcGroup(m.cmd) - - // Set up pipes - var err error - m.stdin, err = m.cmd.StdinPipe() - if err != nil { - m.status.Store(StatusError) - return fmt.Errorf("failed to create stdin pipe: %w", err) - } - - m.stdout, err = m.cmd.StdoutPipe() - if err != nil { - m.status.Store(StatusError) - return fmt.Errorf("failed to create stdout pipe: %w", err) - } - - m.stderr, err = m.cmd.StderrPipe() - if err != nil { - m.status.Store(StatusError) - return fmt.Errorf("failed to create stderr pipe: %w", err) - } - // Build adapter config mcpServers := make([]adapter.McpServerConfig, len(m.cfg.McpServers)) for i, mcp := range m.cfg.McpServers { @@ -268,7 +238,7 @@ func (m *Manager) Start(ctx context.Context) error { AgentID: m.cfg.AgentType, // From registry (e.g., "auggie", "amp", "claude-code") } - // Create adapter before starting the process so we can call PrepareEnvironment + // Create adapter before starting the process so we can call PrepareEnvironment and PrepareCommandArgs if err := m.createAdapter(); err != nil { m.status.Store(StatusError) return fmt.Errorf("failed to create adapter: %w", err) @@ -285,9 +255,45 @@ func (m *Manager) Start(ctx context.Context) error { m.cfg.AgentEnv = append(m.cfg.AgentEnv, fmt.Sprintf("%s=%s", k, v)) } + // Get extra command args from the adapter (e.g., -c flags for Codex MCP config) + extraArgs := m.adapter.PrepareCommandArgs() + + // Build final command args + cmdArgs := append(m.cfg.AgentArgs[1:], extraArgs...) + + // NOTE: We intentionally don't use exec.CommandContext here because we don't want + // the HTTP request context to kill the agent process when the request completes. + m.cmd = exec.Command(m.cfg.AgentArgs[0], cmdArgs...) + m.cmd.Dir = m.cfg.WorkDir + m.cmd.Env = m.cfg.AgentEnv + // Create a new process group so we can kill all child processes together. + // This is important for adapters like OpenCode that spawn child processes + // (npx -> sh -> node -> opencode binary). + setProcGroup(m.cmd) + + // Set up pipes + m.stdin, err = m.cmd.StdinPipe() + if err != nil { + m.status.Store(StatusError) + return fmt.Errorf("failed to create stdin pipe: %w", err) + } + + m.stdout, err = m.cmd.StdoutPipe() + if err != nil { + m.status.Store(StatusError) + return fmt.Errorf("failed to create stdout pipe: %w", err) + } + + m.stderr, err = m.cmd.StderrPipe() + if err != nil { + m.status.Store(StatusError) + return fmt.Errorf("failed to create stderr pipe: %w", err) + } + // Start the process m.logger.Info("starting agent process", zap.Strings("args", m.cfg.AgentArgs), + zap.Strings("extra_args", extraArgs), zap.String("workdir", m.cfg.WorkDir), zap.Int("env_count", len(m.cfg.AgentEnv))) if err := m.cmd.Start(); err != nil { diff --git a/apps/backend/internal/agentctl/server/wsclient/client.go b/apps/backend/internal/agentctl/server/wsclient/client.go deleted file mode 100644 index d03630f36f..0000000000 --- a/apps/backend/internal/agentctl/server/wsclient/client.go +++ /dev/null @@ -1,177 +0,0 @@ -// Package wsclient provides a WebSocket client for connecting back to the Kandev backend. -package wsclient - -import ( - "context" - "encoding/json" - "fmt" - "sync" - "time" - - "github.com/google/uuid" - "github.com/gorilla/websocket" - "github.com/kandev/kandev/internal/common/logger" - ws "github.com/kandev/kandev/pkg/websocket" - "go.uber.org/zap" -) - -type Client struct { - url string - conn *websocket.Conn - logger *logger.Logger - pending map[string]chan *ws.Message - pendingMu sync.RWMutex - connected bool - connMu sync.RWMutex - writeMu sync.Mutex - reconnectInterval time.Duration - maxReconnectTries int -} - -func New(url string, log *logger.Logger) *Client { - return &Client{ - url: url, - logger: log.WithFields(zap.String("component", "wsclient")), - pending: make(map[string]chan *ws.Message), - reconnectInterval: 5 * time.Second, - maxReconnectTries: 10, - } -} - -func (c *Client) Connect(ctx context.Context) error { - c.connMu.Lock() - defer c.connMu.Unlock() - if c.connected { - return nil - } - conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.url, nil) - if err != nil { - return fmt.Errorf("failed to connect to backend: %w", err) - } - c.conn = conn - c.connected = true - c.logger.Info("connected to backend WebSocket", zap.String("url", c.url)) - go c.readLoop() - return nil -} - -func (c *Client) Close() error { - c.connMu.Lock() - defer c.connMu.Unlock() - if !c.connected { - return nil - } - c.connected = false - if c.conn != nil { - return c.conn.Close() - } - return nil -} - -func (c *Client) IsConnected() bool { - c.connMu.RLock() - defer c.connMu.RUnlock() - return c.connected -} - -func (c *Client) Request(ctx context.Context, action string, payload interface{}) (*ws.Message, error) { - if !c.IsConnected() { - return nil, fmt.Errorf("not connected to backend") - } - id := uuid.New().String() - msg, err := ws.NewRequest(id, action, payload) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - respChan := make(chan *ws.Message, 1) - c.pendingMu.Lock() - c.pending[id] = respChan - c.pendingMu.Unlock() - c.writeMu.Lock() - err = c.conn.WriteJSON(msg) - c.writeMu.Unlock() - if err != nil { - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, fmt.Errorf("failed to send request: %w", err) - } - c.logger.Debug("sent request", zap.String("action", action), zap.String("id", id)) - select { - case resp := <-respChan: - return resp, nil - case <-ctx.Done(): - c.pendingMu.Lock() - delete(c.pending, id) - c.pendingMu.Unlock() - return nil, ctx.Err() - } -} - -func (c *Client) RequestPayload(ctx context.Context, action string, payload, result interface{}) error { - resp, err := c.Request(ctx, action, payload) - if err != nil { - return err - } - if resp.Type == ws.MessageTypeError { - var ep struct{ Code, Message string } - if json.Unmarshal(resp.Payload, &ep) == nil { - return fmt.Errorf("backend error [%s]: %s", ep.Code, ep.Message) - } - return fmt.Errorf("backend error: %s", string(resp.Payload)) - } - if result != nil && len(resp.Payload) > 0 { - if err := json.Unmarshal(resp.Payload, result); err != nil { - return fmt.Errorf("failed to unmarshal response: %w", err) - } - } - return nil -} - -func (c *Client) readLoop() { - for { - c.connMu.RLock() - conn, connected := c.conn, c.connected - c.connMu.RUnlock() - if !connected || conn == nil { - return - } - var msg ws.Message - if err := conn.ReadJSON(&msg); err != nil { - if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - c.logger.Error("read error", zap.Error(err)) - } - c.handleDisconnect() - return - } - c.handleMessage(&msg) - } -} - -func (c *Client) handleMessage(msg *ws.Message) { - if msg.Type == ws.MessageTypeResponse || msg.Type == ws.MessageTypeError { - c.pendingMu.Lock() - ch, ok := c.pending[msg.ID] - delete(c.pending, msg.ID) - c.pendingMu.Unlock() - if ok { - ch <- msg - } - return - } -} - -func (c *Client) handleDisconnect() { - c.connMu.Lock() - c.connected = false - c.conn = nil - c.connMu.Unlock() - c.pendingMu.Lock() - for id, ch := range c.pending { - errMsg, _ := ws.NewError(id, "", ws.ErrorCodeInternalError, "connection lost", nil) - ch <- errMsg - delete(c.pending, id) - } - c.pendingMu.Unlock() -} - diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 7ecdf167ec..9551eaa00f 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -34,6 +34,13 @@ const ( TaskSessionStateChanged = "task_session.state_changed" ) +// Event types for task plans +const ( + TaskPlanCreated = "task_plan.created" + TaskPlanUpdated = "task_plan.updated" + TaskPlanDeleted = "task_plan.deleted" +) + // Event types for session turns const ( TurnStarted = "turn.started" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index d84c6bcaec..f381b49ce8 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -35,6 +35,9 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TaskUpdated, ws.ActionTaskUpdated) b.subscribe(eventBus, events.TaskDeleted, ws.ActionTaskDeleted) b.subscribe(eventBus, events.TaskStateChanged, ws.ActionTaskStateChanged) + b.subscribe(eventBus, events.TaskPlanCreated, ws.ActionTaskPlanCreated) + b.subscribe(eventBus, events.TaskPlanUpdated, ws.ActionTaskPlanUpdated) + b.subscribe(eventBus, events.TaskPlanDeleted, ws.ActionTaskPlanDeleted) b.subscribe(eventBus, events.RepositoryCreated, ws.ActionRepositoryCreated) b.subscribe(eventBus, events.RepositoryUpdated, ws.ActionRepositoryUpdated) b.subscribe(eventBus, events.RepositoryDeleted, ws.ActionRepositoryDeleted) diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 094aea082c..9222c636cd 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -112,7 +112,8 @@ func NewTestServer(t *testing.T) *TestServer { workflowCtrl := workflowcontroller.NewController(workflowSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log) + planService := taskservice.NewPlanService(taskRepo, eventBus, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 45148f6a95..5bcfaa6ce3 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -698,7 +698,8 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer { workflowCtrl := workflowcontroller.NewController(workflowSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) - taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log) + planService := taskservice.NewPlanService(taskRepo, eventBus, log) + taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, taskRepo, planService, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 57bfa3adf5..72b17a6384 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -6,28 +6,18 @@ package handlers import ( "context" "encoding/json" + "errors" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/task/controller" "github.com/kandev/kandev/internal/task/dto" - "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/service" workflowctrl "github.com/kandev/kandev/internal/workflow/controller" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) -// Action constants for MCP WebSocket messages -const ( - ActionMCPListWorkspaces = "mcp.list_workspaces" - ActionMCPListBoards = "mcp.list_boards" - ActionMCPListWorkflowSteps = "mcp.list_workflow_steps" - ActionMCPListTasks = "mcp.list_tasks" - ActionMCPCreateTask = "mcp.create_task" - ActionMCPUpdateTask = "mcp.update_task" - ActionMCPAskUserQuestion = "mcp.ask_user_question" -) - // ClarificationService defines the interface for clarification operations. type ClarificationService interface { CreateRequest(req *clarification.Request) string @@ -47,7 +37,7 @@ type Handlers struct { workflowCtrl *workflowctrl.Controller clarificationSvc ClarificationService messageCreator MessageCreator - repo repository.Repository + planService *service.PlanService logger *logger.Logger } @@ -59,7 +49,7 @@ func NewHandlers( workflowCtrl *workflowctrl.Controller, clarificationSvc ClarificationService, messageCreator MessageCreator, - repo repository.Repository, + planService *service.PlanService, log *logger.Logger, ) *Handlers { return &Handlers{ @@ -69,22 +59,26 @@ func NewHandlers( workflowCtrl: workflowCtrl, clarificationSvc: clarificationSvc, messageCreator: messageCreator, - repo: repo, + planService: planService, logger: log.WithFields(zap.String("component", "mcp-handlers")), } } // RegisterHandlers registers all MCP handlers with the dispatcher. func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { - d.RegisterFunc(ActionMCPListWorkspaces, h.handleListWorkspaces) - d.RegisterFunc(ActionMCPListBoards, h.handleListBoards) - d.RegisterFunc(ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) - d.RegisterFunc(ActionMCPListTasks, h.handleListTasks) - d.RegisterFunc(ActionMCPCreateTask, h.handleCreateTask) - d.RegisterFunc(ActionMCPUpdateTask, h.handleUpdateTask) - d.RegisterFunc(ActionMCPAskUserQuestion, h.handleAskUserQuestion) + d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) + d.RegisterFunc(ws.ActionMCPListBoards, h.handleListBoards) + d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) + d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) + d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) + d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) + d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion) + d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan) + d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan) + d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) + d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan) - h.logger.Info("registered MCP handlers", zap.Int("count", 7)) + h.logger.Info("registered MCP handlers", zap.Int("count", 11)) } // handleListWorkspaces lists all workspaces. @@ -237,18 +231,8 @@ func (h *Handlers) handleAskUserQuestion(ctx context.Context, msg *ws.Message) ( } } - // Look up task ID from session if not provided + // Use provided task ID (session -> task lookup is done by caller) taskID := req.TaskID - if taskID == "" && h.repo != nil { - session, err := h.repo.GetTaskSession(ctx, req.SessionID) - if err != nil { - h.logger.Warn("failed to look up session for task ID", - zap.String("session_id", req.SessionID), - zap.Error(err)) - } else { - taskID = session.TaskID - } - } // Create the clarification request clarificationReq := &clarification.Request{ @@ -294,3 +278,124 @@ func generateOptionID(questionIndex, optionIndex int) string { return "q" + string(rune('1'+questionIndex)) + "_opt" + string(rune('1'+optionIndex)) } +// handleCreateTaskPlan creates a new task plan. +func (h *Handlers) handleCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.Content == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil) + } + + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "agent" + } + + plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// handleGetTaskPlan retrieves a task plan. +func (h *Handlers) handleGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + plan, err := h.planService.GetPlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task plan", nil) + } + if plan == nil { + // Return empty object if no plan exists + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{}) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// handleUpdateTaskPlan updates an existing task plan. +func (h *Handlers) handleUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.Content == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "content is required", nil) + } + + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "agent" + } + + plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// handleDeleteTaskPlan deletes a task plan. +func (h *Handlers) handleDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + err := h.planService.DeletePlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 62cf567430..c56b144bcc 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -451,3 +451,30 @@ type ApproveSessionResponse struct { Session TaskSessionDTO `json:"session"` WorkflowStep WorkflowStepDTO `json:"workflow_step,omitempty"` // New step after approval } + +// TaskPlanDTO represents a task plan for API responses +type TaskPlanDTO struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// TaskPlanFromModel converts a TaskPlan model to a TaskPlanDTO. +func TaskPlanFromModel(plan *models.TaskPlan) *TaskPlanDTO { + if plan == nil { + return nil + } + return &TaskPlanDTO{ + ID: plan.ID, + TaskID: plan.TaskID, + Title: plan.Title, + Content: plan.Content, + CreatedBy: plan.CreatedBy, + CreatedAt: plan.CreatedAt, + UpdatedAt: plan.UpdatedAt, + } +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index fbb11a1011..ac05b728bf 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -339,6 +339,20 @@ func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionI return nil } +// Task Plan operations +func (m *mockRepository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { + return nil +} +func (m *mockRepository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { + return nil, nil +} +func (m *mockRepository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { + return nil +} +func (m *mockRepository) DeleteTaskPlan(ctx context.Context, taskID string) error { + return nil +} + func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index ecaf204263..43f98e3e0d 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "encoding/json" + "errors" "net/http" "github.com/gin-gonic/gin" @@ -11,6 +13,8 @@ import ( "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/controller" "github.com/kandev/kandev/internal/task/dto" + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/service" v1 "github.com/kandev/kandev/pkg/api/v1" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -19,6 +23,8 @@ import ( type TaskHandlers struct { controller *controller.TaskController orchestrator OrchestratorStarter + repo repository.Repository + planService *service.PlanService logger *logger.Logger } @@ -28,16 +34,18 @@ type OrchestratorStarter interface { StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string) (*executor.TaskExecution, error) } -func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) *TaskHandlers { +func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, repo repository.Repository, planService *service.PlanService, log *logger.Logger) *TaskHandlers { return &TaskHandlers{ controller: ctrl, orchestrator: orchestrator, + repo: repo, + planService: planService, logger: log.WithFields(zap.String("component", "task-task-handlers")), } } -func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) { - handlers := NewTaskHandlers(ctrl, orchestrator, log) +func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.TaskController, orchestrator OrchestratorStarter, repo repository.Repository, planService *service.PlanService, log *logger.Logger) { + handlers := NewTaskHandlers(ctrl, orchestrator, repo, planService, log) handlers.registerHTTP(router) handlers.registerWS(dispatcher) } @@ -70,6 +78,11 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots) dispatcher.RegisterFunc(ws.ActionSessionGitCommits, h.wsGetSessionCommits) dispatcher.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsGetCumulativeDiff) + // Task plan handlers + dispatcher.RegisterFunc(ws.ActionTaskPlanCreate, h.wsCreateTaskPlan) + dispatcher.RegisterFunc(ws.ActionTaskPlanGet, h.wsGetTaskPlan) + dispatcher.RegisterFunc(ws.ActionTaskPlanUpdate, h.wsUpdateTaskPlan) + dispatcher.RegisterFunc(ws.ActionTaskPlanDelete, h.wsDeleteTaskPlan) } // HTTP handlers @@ -685,3 +698,123 @@ func (h *TaskHandlers) wsGetCumulativeDiff(ctx context.Context, msg *ws.Message) "cumulative_diff": diff, }) } + + +// Task Plan Handlers + +// wsCreateTaskPlan creates a new task plan +func (h *TaskHandlers) wsCreateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + // Default to "user" for frontend requests + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "user" + } + + plan, err := h.planService.CreatePlan(ctx, service.CreatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsGetTaskPlan retrieves a task plan +func (h *TaskHandlers) wsGetTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + plan, err := h.planService.GetPlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get task plan", nil) + } + if plan == nil { + return ws.NewResponse(msg.ID, msg.Action, nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsUpdateTaskPlan updates an existing task plan +func (h *TaskHandlers) wsUpdateTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + // Default to "user" for frontend requests + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "user" + } + + plan, err := h.planService.UpdatePlan(ctx, service.UpdatePlanRequest{ + TaskID: req.TaskID, + Title: req.Title, + Content: req.Content, + CreatedBy: createdBy, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to update task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, dto.TaskPlanFromModel(plan)) +} + +// wsDeleteTaskPlan deletes a task plan +func (h *TaskHandlers) wsDeleteTaskPlan(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + err := h.planService.DeletePlan(ctx, req.TaskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrTaskPlanNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task plan not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete task plan: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) +} \ No newline at end of file diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index b61d87432c..01d26c12fd 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -412,6 +412,17 @@ type Environment struct { DeletedAt *time.Time `json:"deleted_at,omitempty"` } +// TaskPlan represents a plan associated with a task +type TaskPlan struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy string `json:"created_by"` // "agent" or "user" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // ToAPI converts internal Task to API type func (t *Task) ToAPI() *v1.Task { // Convert TaskRepository models to API types diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 3493a2cd12..b6eff0972c 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -137,6 +137,12 @@ type Repository interface { DeleteEnvironment(ctx context.Context, id string) error ListEnvironments(ctx context.Context) ([]*models.Environment, error) + // Task Plan operations + CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error + GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) + UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error + DeleteTaskPlan(ctx context.Context, taskID string) error + // Close closes the repository (for database connections) Close() error } diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 3cc5598f42..b54494b9dc 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -209,6 +209,25 @@ func (r *Repository) initSchema() error { if _, err := r.db.Exec(schema); err != nil { return err } + + // Task plans table + plansSchema := ` + CREATE TABLE IF NOT EXISTS task_plans ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT 'Plan', + content TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL DEFAULT 'agent', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_task_plans_task_id ON task_plans(task_id); + ` + if _, err := r.db.Exec(plansSchema); err != nil { + return err + } + // Session and message tables sessionSchema := ` CREATE TABLE IF NOT EXISTS task_session_messages ( diff --git a/apps/backend/internal/task/repository/sqlite/plan.go b/apps/backend/internal/task/repository/sqlite/plan.go new file mode 100644 index 0000000000..2e9d62e89a --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/plan.go @@ -0,0 +1,85 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" +) + +// CreateTaskPlan creates a new task plan. +func (r *Repository) CreateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { + if plan.ID == "" { + plan.ID = uuid.New().String() + } + now := time.Now().UTC() + plan.CreatedAt = now + plan.UpdatedAt = now + + if plan.Title == "" { + plan.Title = "Plan" + } + if plan.CreatedBy == "" { + plan.CreatedBy = "agent" + } + + _, err := r.db.ExecContext(ctx, ` + INSERT INTO task_plans (id, task_id, title, content, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, plan.ID, plan.TaskID, plan.Title, plan.Content, plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt) + return err +} + +// GetTaskPlan retrieves a task plan by task ID. +func (r *Repository) GetTaskPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { + plan := &models.TaskPlan{} + err := r.db.QueryRowContext(ctx, ` + SELECT id, task_id, title, content, created_by, created_at, updated_at + FROM task_plans WHERE task_id = ? + `, taskID).Scan(&plan.ID, &plan.TaskID, &plan.Title, &plan.Content, &plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, nil // Return nil, nil when no plan exists + } + if err != nil { + return nil, fmt.Errorf("failed to get task plan: %w", err) + } + return plan, nil +} + +// UpdateTaskPlan updates an existing task plan. +func (r *Repository) UpdateTaskPlan(ctx context.Context, plan *models.TaskPlan) error { + plan.UpdatedAt = time.Now().UTC() + + result, err := r.db.ExecContext(ctx, ` + UPDATE task_plans SET title = ?, content = ?, created_by = ?, updated_at = ? + WHERE task_id = ? + `, plan.Title, plan.Content, plan.CreatedBy, plan.UpdatedAt, plan.TaskID) + if err != nil { + return fmt.Errorf("failed to update task plan: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task plan not found for task: %s", plan.TaskID) + } + return nil +} + +// DeleteTaskPlan deletes a task plan by task ID. +func (r *Repository) DeleteTaskPlan(ctx context.Context, taskID string) error { + result, err := r.db.ExecContext(ctx, `DELETE FROM task_plans WHERE task_id = ?`, taskID) + if err != nil { + return fmt.Errorf("failed to delete task plan: %w", err) + } + + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task plan not found for task: %s", taskID) + } + return nil +} + diff --git a/apps/backend/internal/task/service/plan_service.go b/apps/backend/internal/task/service/plan_service.go new file mode 100644 index 0000000000..8c24af3022 --- /dev/null +++ b/apps/backend/internal/task/service/plan_service.go @@ -0,0 +1,195 @@ +package service + +import ( + "context" + "errors" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" +) + +var ( + ErrTaskPlanNotFound = errors.New("task plan not found") + ErrTaskIDRequired = errors.New("task_id is required") + ErrContentRequired = errors.New("content is required") +) + +// PlanService provides task plan business logic. +type PlanService struct { + repo repository.Repository + eventBus bus.EventBus + logger *logger.Logger +} + +// NewPlanService creates a new task plan service. +func NewPlanService(repo repository.Repository, eventBus bus.EventBus, log *logger.Logger) *PlanService { + return &PlanService{ + repo: repo, + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "plan-service")), + } +} + +// CreatePlanRequest contains parameters for creating a task plan. +type CreatePlanRequest struct { + TaskID string + Title string + Content string + CreatedBy string // "agent" or "user" +} + +// CreatePlan creates a new task plan. +func (s *PlanService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*models.TaskPlan, error) { + if req.TaskID == "" { + return nil, ErrTaskIDRequired + } + + title := req.Title + if title == "" { + title = "Plan" + } + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = "agent" + } + + plan := &models.TaskPlan{ + TaskID: req.TaskID, + Title: title, + Content: req.Content, + CreatedBy: createdBy, + } + + if err := s.repo.CreateTaskPlan(ctx, plan); err != nil { + s.logger.Error("failed to create task plan", zap.String("task_id", req.TaskID), zap.Error(err)) + return nil, err + } + + s.logger.Info("created task plan", zap.String("task_id", req.TaskID), zap.String("plan_id", plan.ID)) + s.publishEvent(ctx, events.TaskPlanCreated, plan) + + return plan, nil +} + +// GetPlan retrieves a task plan by task ID. +// Returns nil, nil if no plan exists. +func (s *PlanService) GetPlan(ctx context.Context, taskID string) (*models.TaskPlan, error) { + if taskID == "" { + return nil, ErrTaskIDRequired + } + + plan, err := s.repo.GetTaskPlan(ctx, taskID) + if err != nil { + s.logger.Error("failed to get task plan", zap.String("task_id", taskID), zap.Error(err)) + return nil, err + } + + return plan, nil +} + +// UpdatePlanRequest contains parameters for updating a task plan. +type UpdatePlanRequest struct { + TaskID string + Title string // Optional: if empty, preserves existing title + Content string + CreatedBy string // Optional: if empty, preserves existing or defaults to "user" +} + +// UpdatePlan updates an existing task plan. +func (s *PlanService) UpdatePlan(ctx context.Context, req UpdatePlanRequest) (*models.TaskPlan, error) { + if req.TaskID == "" { + return nil, ErrTaskIDRequired + } + + existing, err := s.repo.GetTaskPlan(ctx, req.TaskID) + if err != nil { + s.logger.Error("failed to get existing task plan", zap.String("task_id", req.TaskID), zap.Error(err)) + return nil, err + } + if existing == nil { + return nil, ErrTaskPlanNotFound + } + + // Preserve existing values if not provided + title := req.Title + if title == "" { + title = existing.Title + } + createdBy := req.CreatedBy + if createdBy == "" { + createdBy = existing.CreatedBy + } + + plan := &models.TaskPlan{ + ID: existing.ID, + TaskID: req.TaskID, + Title: title, + Content: req.Content, + CreatedBy: createdBy, + CreatedAt: existing.CreatedAt, + } + + if err := s.repo.UpdateTaskPlan(ctx, plan); err != nil { + s.logger.Error("failed to update task plan", zap.String("task_id", req.TaskID), zap.Error(err)) + return nil, err + } + + s.logger.Info("updated task plan", zap.String("task_id", req.TaskID)) + s.publishEvent(ctx, events.TaskPlanUpdated, plan) + + return plan, nil +} + +// DeletePlan deletes a task plan by task ID. +func (s *PlanService) DeletePlan(ctx context.Context, taskID string) error { + if taskID == "" { + return ErrTaskIDRequired + } + + // Get plan before deleting for event payload + existing, err := s.repo.GetTaskPlan(ctx, taskID) + if err != nil { + s.logger.Error("failed to get task plan for delete", zap.String("task_id", taskID), zap.Error(err)) + return err + } + if existing == nil { + return ErrTaskPlanNotFound + } + + if err := s.repo.DeleteTaskPlan(ctx, taskID); err != nil { + s.logger.Error("failed to delete task plan", zap.String("task_id", taskID), zap.Error(err)) + return err + } + + s.logger.Info("deleted task plan", zap.String("task_id", taskID)) + s.publishEvent(ctx, events.TaskPlanDeleted, existing) + + return nil +} + +// publishEvent publishes a task plan event to the event bus. +func (s *PlanService) publishEvent(ctx context.Context, eventType string, plan *models.TaskPlan) { + if s.eventBus == nil { + return + } + + payload := map[string]interface{}{ + "id": plan.ID, + "task_id": plan.TaskID, + "title": plan.Title, + "content": plan.Content, + "created_by": plan.CreatedBy, + "created_at": plan.CreatedAt, + "updated_at": plan.UpdatedAt, + } + + if err := s.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "plan-service", payload)); err != nil { + s.logger.Error("failed to publish task plan event", zap.String("event_type", eventType), zap.Error(err)) + } +} + diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index 316067f468..8b8f41e217 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -266,7 +266,7 @@ func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate { TaskState: "IN_PROGRESS", AutoStartAgent: true, PlanMode: true, - PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.", + PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nBefore creating the plan, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your plan in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.", OnCompleteStepID: "review-plan", AllowManualMove: true, }, @@ -290,7 +290,7 @@ func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate { Color: "bg-blue-500", TaskState: "IN_PROGRESS", AutoStartAgent: true, - PromptPrefix: "[IMPLEMENTATION PHASE]\nRead the plan from `.kandev/plans/{task_id}.md` and implement the task according to the plan.\nFollow the plan step by step.", + PromptPrefix: "[IMPLEMENTATION PHASE]\nBefore starting implementation, retrieve the task plan using get_task_plan_kandev with the task_id from the session context.\nReview the plan carefully, including any edits the user may have made.\nAcknowledge the plan and any user modifications before proceeding.\n\nThen implement the task following the plan step-by-step.\nYou can update the plan during implementation using update_task_plan_kandev if needed.", OnCompleteStepID: "review-code", AllowManualMove: true, }, @@ -349,7 +349,7 @@ func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemp TaskState: "IN_PROGRESS", AutoStartAgent: true, PlanMode: true, - PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.", + PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nBefore creating the design, ask the user clarifying questions if anything is unclear or ambiguous about the requirements. Use the ask_user_question_kandev tool to get answers before proceeding.\n\nIMPORTANT: Save your design using the create_task_plan_kandev MCP tool with the task_id provided in the session context.\nAfter saving the plan, STOP and wait for user review. The user will review your design in the UI and may edit it.\nDo not create any other files during this phase - only use the MCP tool to save the plan.", OnCompleteStepID: "review", AllowManualMove: true, }, diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 4faecd3a62..ed24d45b25 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -55,6 +55,10 @@ const ( ActionTaskDelete = "task.delete" ActionTaskMove = "task.move" ActionTaskState = "task.state" + ActionTaskPlanCreate = "task.plan.create" + ActionTaskPlanGet = "task.plan.get" + ActionTaskPlanUpdate = "task.plan.update" + ActionTaskPlanDelete = "task.plan.delete" ActionTaskSessionList = "task.session.list" ActionTaskSessionResume = "task.session.resume" @@ -119,6 +123,9 @@ const ( ActionTaskUpdated = "task.updated" ActionTaskDeleted = "task.deleted" ActionTaskStateChanged = "task.state_changed" + ActionTaskPlanCreated = "task.plan.created" + ActionTaskPlanUpdated = "task.plan.updated" + ActionTaskPlanDeleted = "task.plan.deleted" ActionAgentUpdated = "agent.updated" ActionAgentAvailableUpdated = "agent.available.updated" ActionWorkspaceCreated = "workspace.created" @@ -207,6 +214,10 @@ const ( ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" ActionMCPAskUserQuestion = "mcp.ask_user_question" + ActionMCPCreateTaskPlan = "mcp.create_task_plan" + ActionMCPGetTaskPlan = "mcp.get_task_plan" + ActionMCPUpdateTaskPlan = "mcp.update_task_plan" + ActionMCPDeleteTaskPlan = "mcp.delete_task_plan" ) // Error codes diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 7d25fdaa5c..21c92bf8f8 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -100,7 +100,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) tailwindcss: specifier: ^4.0.0 version: 4.1.18 @@ -303,6 +303,33 @@ importers: '@lezer/highlight': specifier: ^1.2.3 version: 1.2.3 + '@milkdown/core': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/ctx': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/plugin-listener': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/preset-commonmark': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/preset-gfm': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/prose': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/react': + specifier: ^7.18.0 + version: 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) + '@milkdown/theme-nord': + specifier: ^7.18.0 + version: 7.18.0 + '@milkdown/utils': + specifier: ^7.18.0 + version: 7.18.0 '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@19.2.3) @@ -420,7 +447,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.1 - version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) happy-dom: specifier: ^20.1.0 version: 20.1.0 @@ -604,6 +631,9 @@ packages: '@codemirror/commands@6.10.1': resolution: {integrity: sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==} + '@codemirror/lang-angular@0.1.4': + resolution: {integrity: sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==} + '@codemirror/lang-cpp@6.0.3': resolution: {integrity: sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==} @@ -622,24 +652,57 @@ packages: '@codemirror/lang-javascript@6.2.4': resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + '@codemirror/lang-jinja@6.0.0': + resolution: {integrity: sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} + '@codemirror/lang-less@6.0.2': + resolution: {integrity: sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==} + + '@codemirror/lang-liquid@6.3.1': + resolution: {integrity: sha512-S/jE/D7iij2Pu70AC65ME6AYWxOOcX20cSJvaPgY5w7m2sfxsArAcUAuUgm/CZCVmqoi9KiOlS7gj/gyLipABw==} + '@codemirror/lang-markdown@6.5.0': resolution: {integrity: sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==} + '@codemirror/lang-php@6.0.2': + resolution: {integrity: sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==} + '@codemirror/lang-python@6.2.1': resolution: {integrity: sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==} '@codemirror/lang-rust@6.0.2': resolution: {integrity: sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==} + '@codemirror/lang-sass@6.0.2': + resolution: {integrity: sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==} + + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + + '@codemirror/lang-vue@0.1.3': + resolution: {integrity: sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==} + + '@codemirror/lang-wast@6.0.2': + resolution: {integrity: sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==} + + '@codemirror/lang-xml@6.1.0': + resolution: {integrity: sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==} + '@codemirror/lang-yaml@6.1.2': resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} + '@codemirror/language-data@6.5.2': + resolution: {integrity: sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==} + '@codemirror/language@6.12.1': resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + '@codemirror/legacy-modes@6.5.2': + resolution: {integrity: sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==} + '@codemirror/lint@6.9.2': resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} @@ -1316,12 +1379,21 @@ packages: '@lezer/markdown@1.6.3': resolution: {integrity: sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==} + '@lezer/php@1.0.5': + resolution: {integrity: sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==} + '@lezer/python@1.1.18': resolution: {integrity: sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==} '@lezer/rust@1.0.2': resolution: {integrity: sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==} + '@lezer/sass@1.1.0': + resolution: {integrity: sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==} + + '@lezer/xml@1.0.6': + resolution: {integrity: sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==} + '@lezer/yaml@1.0.3': resolution: {integrity: sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==} @@ -1337,6 +1409,82 @@ packages: '@types/react': '>=16' react: '>=16' + '@milkdown/components@7.18.0': + resolution: {integrity: sha512-Zu/GMqy1byyxul/+/RWcpe02b7luhtW1SfTYNFZnaWPvIap5M9vG7pFeQNRqJe5cbfKI+bvW8Ubyb5BG2kb9Ug==} + peerDependencies: + '@codemirror/language': ^6 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 + + '@milkdown/core@7.18.0': + resolution: {integrity: sha512-BUVR/72XwrtM3qHTTtXtmCtGfuaAexvSxosYIXw7d6ElbLiLIe3bOXjGwwgLHW3xsq23VKmYMsFqWLUFt6uGDQ==} + + '@milkdown/crepe@7.18.0': + resolution: {integrity: sha512-GcHW6Use0MCRvFg6RQVN5EaeyMlxFxDEGbGwqApnBblxZi5PV9nlAAn0AfOhYvFHSDkQ3rQa5fuHQ0Bd0KobQQ==} + + '@milkdown/ctx@7.18.0': + resolution: {integrity: sha512-F+t8U/akpY7Vw+KD+z32Itr6lrVLAGTVO79DN436BnFK/J9kiPzTRfTet6fMOj3NlwO/24lUluiPZd7qbCmn8A==} + + '@milkdown/exception@7.18.0': + resolution: {integrity: sha512-sAyi4IqdChh4+lpgucmgDZNGjYuIRvJimZeMj0SdfdeHDABan5Nco3X+5yOGaBq1z9QOJG90+vEcEvUASHBmFw==} + + '@milkdown/kit@7.18.0': + resolution: {integrity: sha512-6C8c/bU+3Md/rlZFTqMmdVen2xSC80LYBOZ/G4+W39gsV7x/ux/HRdd8xk75a4IrHKgq6EJpGJ1yH8BvT7P+1A==} + + '@milkdown/plugin-block@7.18.0': + resolution: {integrity: sha512-+x00o7Vh5nQesw4j6QwtwCThdjSiH/jUvAzrTpwr8xvRmQnmztdfdJhPHxp48pK/sIEct3660HWuwDpdeAlmRw==} + + '@milkdown/plugin-clipboard@7.18.0': + resolution: {integrity: sha512-Gnp+GqkoLS1pKG9S2QfdvZQjfoJosQek5Yv5zOIj5X388yfVlguKNtCwnDCJKVEVws9e8PnhfPBmzr06713dZw==} + + '@milkdown/plugin-cursor@7.18.0': + resolution: {integrity: sha512-SsvFEeFMv1jrzVBnuAMyAwZzhjwCk4wmGjJEug41Ic+CT0YMUtVPJn5QVn7fjixR13kzkfaNDUPZ+sGNqIR2xw==} + + '@milkdown/plugin-history@7.18.0': + resolution: {integrity: sha512-hWM3rpad/THy267dXgEWRu9Arf+3j2KE8UN3jhqsUvVLZZ2ZetaPc2imHowJaLR8PwCb649+1RxL+IKrXizNKQ==} + + '@milkdown/plugin-indent@7.18.0': + resolution: {integrity: sha512-LAVMSsy6lWvy/QjvSazojUeW6v1lLFj5Fjv3YvqDNtP6/RSOIhHJs75aXbv92Kx43aRJnkh7EVy9Wu4OxSC70Q==} + + '@milkdown/plugin-listener@7.18.0': + resolution: {integrity: sha512-F2iPKdWYGJX5kMnmIeZeybQ5gZUwT/smNBbt/itPBn5cD4YRF1qmY/MxDs0+nvoN2NSxtEx5pHOtd5/E4mCf2A==} + + '@milkdown/plugin-slash@7.18.0': + resolution: {integrity: sha512-jBcaLswX1yKG97s0V1qFqk/0aR+LpWnTCHIrryNVRIRFYm7B6tITekkqwALlV2bqE1eykeN2j8yEyRQ63Wv05Q==} + + '@milkdown/plugin-tooltip@7.18.0': + resolution: {integrity: sha512-Z8WYSEFANhHPS2A8uMIcKGJ3vt0KKCJ80hffuJffudJT9FSIXieh1f8OKcKQuhcRHxRCRUApMcOOjOptiVaHvQ==} + + '@milkdown/plugin-trailing@7.18.0': + resolution: {integrity: sha512-AusCWoZSRfgsStdlmg+4sYZ08HLDDiHhesDCqiLCdo1bklNhzK/9q6gxdL1HP5xTn5a4xV9hUrI7E7M0JaKdug==} + + '@milkdown/plugin-upload@7.18.0': + resolution: {integrity: sha512-fsWwd6g6FX35Wg12KVE1Yu3wU8vM5hA567DufeHcik9LckdLJcZKf35JMJDUOAOkEdU3V91BKO47KUhBPFt1jA==} + + '@milkdown/preset-commonmark@7.18.0': + resolution: {integrity: sha512-L/F9vmhQKOjKJZTEEsKjDu/2KkMTDxBVQISk4w+j8KFWx9OpHBwqWqyHiDLTREbT7pJqLfyB96eXvfuMG4za5g==} + + '@milkdown/preset-gfm@7.18.0': + resolution: {integrity: sha512-NLfkd7HOaaMCMImXmBh8TX8KNkgKecM7YRHFEwb5D/SMLyBLyZs7lDfLEKPU9N52+vzgwMz8ceUSlCElmneTJg==} + + '@milkdown/prose@7.18.0': + resolution: {integrity: sha512-bRDfgVM6uKRaejvju/FWdQMryQc4kSSso+fnABUbvbCKitXnsgRPvclsddbt3J92anQwLRDWr/qotx1NcyDM1Q==} + + '@milkdown/react@7.18.0': + resolution: {integrity: sha512-hk7CN6YqhazUBOdY0Iyh3RjvRyjsl2vBsJyf54ua38hxmaAD13KbTnEWZs30OnryoP6cv9z74bHPMIc2UnSVIQ==} + peerDependencies: + react: '*' + react-dom: '*' + + '@milkdown/theme-nord@7.18.0': + resolution: {integrity: sha512-3t5Fb0Zwsmf2VDkhZm6U5C6/CWiU7UN+Z+/tpbOC1Stsid4CJ3y7j7m/3oRZlQyUEI07dHvAAOHqzyGoYl0FZw==} + + '@milkdown/transformer@7.18.0': + resolution: {integrity: sha512-AzTgqDktQw9nzgrpICjYNxScYwwnxmALPSyZ39Y0wNZJafi8QMVqLv4w2bhyYkxITXolPHdLAAsZXPKuMjrmNA==} + + '@milkdown/utils@7.18.0': + resolution: {integrity: sha512-+o/1sky+QwbS0Y92HthTupMFziJKhZUgF7IBS55Ft4Wjt63kX8PHaLC9KtewNawpzyM/CjPJ9ySCIa+C/06Bsg==} + '@modelcontextprotocol/sdk@1.25.2': resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} engines: {node: '>=18'} @@ -1436,6 +1584,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@ocavue/utils@1.3.1': + resolution: {integrity: sha512-jd8D6fUdkspMUYr2EE8mTj1GgPivXeHMyPeI2S9v7DEJcNN1DN2Wo/7xZ5H7vUycgfpr8ucv63aKh3NKURHc1Q==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -2483,6 +2634,15 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2506,6 +2666,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2725,12 +2888,41 @@ packages: '@vitest/utils@1.6.1': resolution: {integrity: sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==} + '@vue/compiler-core@3.5.27': + resolution: {integrity: sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==} + + '@vue/compiler-dom@3.5.27': + resolution: {integrity: sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==} + + '@vue/compiler-sfc@3.5.27': + resolution: {integrity: sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==} + + '@vue/compiler-ssr@3.5.27': + resolution: {integrity: sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==} + '@vue/reactivity@3.5.26': resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + '@vue/reactivity@3.5.27': + resolution: {integrity: sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==} + + '@vue/runtime-core@3.5.27': + resolution: {integrity: sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==} + + '@vue/runtime-dom@3.5.27': + resolution: {integrity: sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==} + + '@vue/server-renderer@3.5.27': + resolution: {integrity: sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==} + peerDependencies: + vue: 3.5.27 + '@vue/shared@3.5.26': resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + '@vue/shared@3.5.27': + resolution: {integrity: sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==} + '@xterm/addon-attach@0.12.0': resolution: {integrity: sha512-1lxvXM4JYSm60lbFmE8WMOy2oF2ip3Ye8jWorSAmwy7x8FiC53netEJ5RguL8+FSRj79MUQsNCb2hprY2QA2ig==} @@ -3031,6 +3223,10 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3252,6 +3448,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + dotenv@17.2.3: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} @@ -3300,6 +3499,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -3505,6 +3708,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -4143,6 +4349,10 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + katex@0.16.28: + resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==} + hasBin: true + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -4250,6 +4460,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -4290,6 +4503,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -4314,6 +4530,9 @@ packages: mdast-util-gfm@3.1.0: resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + mdast-util-math@3.0.0: + resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} + mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} @@ -4380,6 +4599,9 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-math@3.1.0: + resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} + micromark-extension-mdx-expression@3.0.1: resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} @@ -4524,6 +4746,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4660,6 +4887,9 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} @@ -4794,6 +5024,65 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.3.1: + resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-drop-indicator@0.1.3: + resolution: {integrity: sha512-fJV6G2tHIVXZLUuc60fS9ly1/GuGOlAZUm67S1El+kGFUYh27Hyv6hcGx3rrJ+Q/JZL5jnyAibIZYYWpPqE45g==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.0: + resolution: {integrity: sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-safari-ime-span@1.0.2: + resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-transform@1.11.0: + resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} + + prosemirror-view@1.41.5: + resolution: {integrity: sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==} + + prosemirror-virtual-cursor@0.4.2: + resolution: {integrity: sha512-pUMKnIuOhhnMcgIJUjhIQTVJruBEGxfMBVQSrK0g2qhGPDm1i12KdsVaFw15dYk+29tZcxjMeR7P5VDKwmbwJg==} + peerDependencies: + prosemirror-model: ^1.0.0 + prosemirror-state: ^1.0.0 + prosemirror-view: ^1.0.0 + peerDependenciesMeta: + prosemirror-model: + optional: true + prosemirror-state: + optional: true + prosemirror-view: + optional: true + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -4955,6 +5244,12 @@ packages: remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-inline-links@7.0.0: + resolution: {integrity: sha512-4uj1pPM+F495ySZhTIB6ay2oSkTsKgmYaKk/q5HIdhX2fuyLEegpjWa0VdJRJ01sgOqAFo7MBKdDUejIYBMVMQ==} + + remark-math@6.0.0: + resolution: {integrity: sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==} + remark-mdx@3.1.1: resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} @@ -4967,6 +5262,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remark@15.0.1: + resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -5010,6 +5308,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -5442,6 +5743,9 @@ packages: unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + unist-util-remove@3.1.1: resolution: {integrity: sha512-kfCqZK5YVY5yEa89tvpl7KnBBHu2c6CzMkqHUrlOqaRgGOMp0sMvwWOVrbAtj03KhovQB7i96Gda72v/EFE0vw==} @@ -5595,6 +5899,14 @@ packages: jsdom: optional: true + vue@3.5.27: + resolution: {integrity: sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -5980,6 +6292,15 @@ snapshots: '@codemirror/view': 6.39.10 '@lezer/common': 1.5.0 + '@codemirror/lang-angular@0.1.4': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + '@codemirror/lang-cpp@6.0.3': dependencies: '@codemirror/language': 6.12.1 @@ -6028,11 +6349,38 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/javascript': 1.5.4 + '@codemirror/lang-jinja@6.0.0': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.1 '@lezer/json': 1.0.3 + '@codemirror/lang-less@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + + '@codemirror/lang-liquid@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + '@codemirror/lang-markdown@6.5.0': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6043,6 +6391,14 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/markdown': 1.6.3 + '@codemirror/lang-php@6.0.2': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@lezer/common': 1.5.0 + '@lezer/php': 1.0.5 + '@codemirror/lang-python@6.2.1': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6056,6 +6412,48 @@ snapshots: '@codemirror/language': 6.12.1 '@lezer/rust': 1.0.2 + '@codemirror/lang-sass@6.0.2': + dependencies: + '@codemirror/lang-css': 6.3.1 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@lezer/common': 1.5.0 + '@lezer/sass': 1.1.0 + + '@codemirror/lang-sql@6.10.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + + '@codemirror/lang-vue@0.1.3': + dependencies: + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + + '@codemirror/lang-wast@6.0.2': + dependencies: + '@codemirror/language': 6.12.1 + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + + '@codemirror/lang-xml@6.1.0': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 + '@lezer/common': 1.5.0 + '@lezer/xml': 1.0.6 + '@codemirror/lang-yaml@6.1.2': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -6066,6 +6464,32 @@ snapshots: '@lezer/lr': 1.4.7 '@lezer/yaml': 1.0.3 + '@codemirror/language-data@6.5.2': + dependencies: + '@codemirror/lang-angular': 0.1.4 + '@codemirror/lang-cpp': 6.0.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-go': 6.0.1 + '@codemirror/lang-html': 6.4.11 + '@codemirror/lang-java': 6.0.2 + '@codemirror/lang-javascript': 6.2.4 + '@codemirror/lang-jinja': 6.0.0 + '@codemirror/lang-json': 6.0.2 + '@codemirror/lang-less': 6.0.2 + '@codemirror/lang-liquid': 6.3.1 + '@codemirror/lang-markdown': 6.5.0 + '@codemirror/lang-php': 6.0.2 + '@codemirror/lang-python': 6.2.1 + '@codemirror/lang-rust': 6.0.2 + '@codemirror/lang-sass': 6.0.2 + '@codemirror/lang-sql': 6.10.0 + '@codemirror/lang-vue': 0.1.3 + '@codemirror/lang-wast': 6.0.2 + '@codemirror/lang-xml': 6.1.0 + '@codemirror/lang-yaml': 6.1.2 + '@codemirror/language': 6.12.1 + '@codemirror/legacy-modes': 6.5.2 + '@codemirror/language@6.12.1': dependencies: '@codemirror/state': 6.5.3 @@ -6075,6 +6499,10 @@ snapshots: '@lezer/lr': 1.4.7 style-mod: 4.1.3 + '@codemirror/legacy-modes@6.5.2': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/lint@6.9.2': dependencies: '@codemirror/state': 6.5.3 @@ -6633,6 +7061,12 @@ snapshots: '@lezer/common': 1.5.0 '@lezer/highlight': 1.2.3 + '@lezer/php@1.0.5': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + '@lezer/python@1.1.18': dependencies: '@lezer/common': 1.5.0 @@ -6645,6 +7079,18 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.7 + '@lezer/sass@1.1.0': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + + '@lezer/xml@1.0.6': + dependencies: + '@lezer/common': 1.5.0 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.7 + '@lezer/yaml@1.0.3': dependencies: '@lezer/common': 1.5.0 @@ -6689,6 +7135,289 @@ snapshots: '@types/react': 19.2.8 react: 19.2.3 + '@milkdown/components@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.3 + '@codemirror/view': 6.39.10 + '@floating-ui/dom': 1.7.4 + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/plugin-tooltip': 7.18.0 + '@milkdown/preset-commonmark': 7.18.0 + '@milkdown/preset-gfm': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + '@milkdown/utils': 7.18.0 + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + dompurify: 3.3.1 + lodash-es: 4.17.23 + nanoid: 5.1.6 + unist-util-visit: 5.0.0 + vue: 3.5.27(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + - typescript + + '@milkdown/core@7.18.0': + dependencies: + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/crepe@7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3)': + dependencies: + '@codemirror/commands': 6.10.1 + '@codemirror/language': 6.12.1 + '@codemirror/language-data': 6.5.2 + '@codemirror/state': 6.5.3 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.39.10 + '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) + '@types/lodash-es': 4.17.12 + clsx: 2.1.1 + codemirror: 6.0.2 + katex: 0.16.28 + lodash-es: 4.17.23 + prosemirror-virtual-cursor: 0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5) + remark-math: 6.0.0 + unist-util-visit: 5.0.0 + vue: 3.5.27(typescript@5.9.3) + transitivePeerDependencies: + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + + '@milkdown/ctx@7.18.0': + dependencies: + '@milkdown/exception': 7.18.0 + + '@milkdown/exception@7.18.0': {} + + '@milkdown/kit@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3)': + dependencies: + '@milkdown/components': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/plugin-block': 7.18.0 + '@milkdown/plugin-clipboard': 7.18.0 + '@milkdown/plugin-cursor': 7.18.0 + '@milkdown/plugin-history': 7.18.0 + '@milkdown/plugin-indent': 7.18.0 + '@milkdown/plugin-listener': 7.18.0 + '@milkdown/plugin-slash': 7.18.0 + '@milkdown/plugin-tooltip': 7.18.0 + '@milkdown/plugin-trailing': 7.18.0 + '@milkdown/plugin-upload': 7.18.0 + '@milkdown/preset-commonmark': 7.18.0 + '@milkdown/preset-gfm': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + - supports-color + - typescript + + '@milkdown/plugin-block@7.18.0': + dependencies: + '@floating-ui/dom': 1.7.4 + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-clipboard@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-cursor@7.18.0': + dependencies: + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + prosemirror-drop-indicator: 0.1.3 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-history@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-indent@7.18.0': + dependencies: + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-listener@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-slash@7.18.0': + dependencies: + '@floating-ui/dom': 1.7.4 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-tooltip@7.18.0': + dependencies: + '@floating-ui/dom': 1.7.4 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + '@types/lodash-es': 4.17.12 + lodash-es: 4.17.23 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-trailing@7.18.0': + dependencies: + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - supports-color + + '@milkdown/plugin-upload@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/utils': 7.18.0 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-commonmark@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + '@milkdown/utils': 7.18.0 + remark-inline-links: 7.0.0 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + '@milkdown/preset-gfm@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/preset-commonmark': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + '@milkdown/utils': 7.18.0 + prosemirror-safari-ime-span: 1.0.2 + remark-gfm: 4.0.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/prose@7.18.0': + dependencies: + '@milkdown/exception': 7.18.0 + prosemirror-changeset: 2.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + + '@milkdown/react@7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)': + dependencies: + '@milkdown/crepe': 7.18.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(typescript@5.9.3) + '@milkdown/kit': 7.18.0(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)(typescript@5.9.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + - prosemirror-model + - prosemirror-state + - prosemirror-view + - supports-color + - typescript + + '@milkdown/theme-nord@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/prose': 7.18.0 + clsx: 2.1.1 + transitivePeerDependencies: + - supports-color + + '@milkdown/transformer@7.18.0': + dependencies: + '@milkdown/exception': 7.18.0 + '@milkdown/prose': 7.18.0 + remark: 15.0.1 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + '@milkdown/utils@7.18.0': + dependencies: + '@milkdown/core': 7.18.0 + '@milkdown/ctx': 7.18.0 + '@milkdown/exception': 7.18.0 + '@milkdown/prose': 7.18.0 + '@milkdown/transformer': 7.18.0 + nanoid: 5.1.6 + transitivePeerDependencies: + - supports-color + '@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.8(hono@4.11.3) @@ -6779,6 +7508,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@ocavue/utils@1.3.1': {} + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': @@ -7797,6 +8528,14 @@ snapshots: '@types/json5@0.0.29': {} + '@types/katex@0.16.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.23 + + '@types/lodash@4.17.23': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -7819,6 +8558,9 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -8053,12 +8795,66 @@ snapshots: loupe: 2.3.7 pretty-format: 29.7.0 + '@vue/compiler-core@3.5.27': + dependencies: + '@babel/parser': 7.28.6 + '@vue/shared': 3.5.27 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.27': + dependencies: + '@vue/compiler-core': 3.5.27 + '@vue/shared': 3.5.27 + + '@vue/compiler-sfc@3.5.27': + dependencies: + '@babel/parser': 7.28.6 + '@vue/compiler-core': 3.5.27 + '@vue/compiler-dom': 3.5.27 + '@vue/compiler-ssr': 3.5.27 + '@vue/shared': 3.5.27 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.27': + dependencies: + '@vue/compiler-dom': 3.5.27 + '@vue/shared': 3.5.27 + '@vue/reactivity@3.5.26': dependencies: '@vue/shared': 3.5.26 + '@vue/reactivity@3.5.27': + dependencies: + '@vue/shared': 3.5.27 + + '@vue/runtime-core@3.5.27': + dependencies: + '@vue/reactivity': 3.5.27 + '@vue/shared': 3.5.27 + + '@vue/runtime-dom@3.5.27': + dependencies: + '@vue/reactivity': 3.5.27 + '@vue/runtime-core': 3.5.27 + '@vue/shared': 3.5.27 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@vue/compiler-ssr': 3.5.27 + '@vue/shared': 3.5.27 + vue: 3.5.27(typescript@5.9.3) + '@vue/shared@3.5.26': {} + '@vue/shared@3.5.27': {} + '@xterm/addon-attach@0.12.0': {} '@xterm/addon-fit@0.11.0': {} @@ -8378,6 +9174,8 @@ snapshots: commander@14.0.2: {} + commander@8.3.0: {} + concat-map@0.0.1: {} confbox@0.1.8: {} @@ -8554,6 +9352,10 @@ snapshots: '@babel/runtime': 7.28.6 csstype: 3.2.3 + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@17.2.3: {} dunder-proto@1.0.1: @@ -8598,6 +9400,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@7.0.1: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -9061,6 +9865,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -9731,6 +10537,10 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + katex@0.16.28: + dependencies: + commander: 8.3.0 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9812,6 +10622,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.17.23: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} @@ -9851,6 +10663,12 @@ snapshots: math-intrinsics@1.1.0: {} + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -9932,6 +10750,18 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-math@3.0.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + longest-streak: 3.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + unist-util-remove-position: 5.0.0 + transitivePeerDependencies: + - supports-color + mdast-util-mdx-expression@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -10104,6 +10934,16 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-math@3.1.0: + dependencies: + '@types/katex': 0.16.8 + devlop: 1.1.0 + katex: 0.16.28 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: dependencies: '@types/estree': 1.0.8 @@ -10360,6 +11200,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -10525,6 +11367,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + orderedmap@2.1.1: {} + outvariant@1.4.3: {} own-keys@1.0.1: @@ -10652,6 +11496,98 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.3.1: + dependencies: + prosemirror-transform: 1.11.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-drop-indicator@0.1.3: + dependencies: + '@ocavue/utils': 1.3.1 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + + prosemirror-gapcursor@1.4.0: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-safari-ime-span@1.0.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + + prosemirror-transform@1.11.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.5: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.11.0 + + prosemirror-virtual-cursor@0.4.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5): + optionalDependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -10928,6 +11864,21 @@ snapshots: transitivePeerDependencies: - supports-color + remark-inline-links@7.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-definitions: 6.0.0 + unist-util-visit: 5.0.0 + + remark-math@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-math: 3.0.0 + micromark-extension-math: 3.1.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + remark-mdx@3.1.1: dependencies: mdast-util-mdx: 3.0.0 @@ -10958,6 +11909,15 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remark@15.0.1: + dependencies: + '@types/mdast': 4.0.4 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -11020,6 +11980,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.55.1 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -11570,6 +12532,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + unist-util-remove@3.1.1: dependencies: '@types/unist': 2.0.11 @@ -11765,6 +12732,16 @@ snapshots: - supports-color - terser + vue@3.5.27(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.27 + '@vue/compiler-sfc': 3.5.27 + '@vue/runtime-dom': 3.5.27 + '@vue/server-renderer': 3.5.27(vue@3.5.27(typescript@5.9.3)) + '@vue/shared': 3.5.27 + optionalDependencies: + typescript: 5.9.3 + w3c-keyname@2.2.8: {} web-streams-polyfill@3.3.3: {} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2700941986..b7801d2b6b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -440,3 +440,175 @@ .task-chat-editor .cm-content ::selection { background: color-mix(in oklab, var(--primary) 30%, var(--background)); } + +/* Milkdown Editor Styles */ +.milkdown-wrapper { + height: 100%; + overflow: auto; + background: #000000; /* Black background like chat */ + border-radius: 0.5rem; /* rounded-lg like chat */ +} + +.milkdown-wrapper:not(.dark) { + background: #ffffff; /* White background for light mode */ +} + +.milkdown-wrapper .milkdown { + height: 100%; + background: transparent; +} + +.milkdown-wrapper .editor { + padding: 0.75rem; + min-height: 100%; + outline: none; + background: transparent; +} + +.milkdown-wrapper .editor:focus { + outline: none; +} + +/* Override nord theme colors to match app theme */ +.milkdown-wrapper .milkdown { + --nord-background: transparent; + --nord-foreground: var(--foreground); + --nord-outline: var(--border); + --nord-primary: var(--primary); + --nord-secondary: var(--muted); + --nord-neutral: var(--muted-foreground); + --nord-solid: var(--card); + --nord-line: var(--border); + --nord-surface: var(--muted); + --nord-shadow: var(--border); +} + +/* Base text styling - match chat text-xs */ +.milkdown-wrapper .editor, +.milkdown-wrapper .editor * { + font-size: 0.75rem !important; + line-height: 1.625; + color: var(--foreground); +} + +/* Typography - headings */ +.milkdown-wrapper .editor h1, +.milkdown-wrapper .editor h2, +.milkdown-wrapper .editor h3, +.milkdown-wrapper .editor h4, +.milkdown-wrapper .editor h5, +.milkdown-wrapper .editor h6 { + font-weight: 700 !important; + color: var(--foreground) !important; + margin-top: 0.5rem !important; + margin-bottom: 0.25rem !important; + line-height: 1.3 !important; +} + +.milkdown-wrapper .editor h1 { font-size: 0.875rem !important; } +.milkdown-wrapper .editor h2 { font-size: 0.8125rem !important; } +.milkdown-wrapper .editor h3 { font-size: 0.75rem !important; } + +/* Paragraphs */ +.milkdown-wrapper .editor p { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + line-height: 1.625 !important; + font-size: 0.75rem !important; +} + +/* Lists */ +.milkdown-wrapper .editor ul { + list-style-type: disc !important; + padding-left: 1.25rem !important; + margin-top: 0.375rem !important; + margin-bottom: 0.375rem !important; + font-size: 0.75rem !important; +} + +.milkdown-wrapper .editor ol { + list-style-type: decimal !important; + padding-left: 1.25rem !important; + margin-top: 0.375rem !important; + margin-bottom: 0.375rem !important; + font-size: 0.75rem !important; +} + +.milkdown-wrapper .editor li { + margin-top: 0.125rem !important; + margin-bottom: 0.125rem !important; + font-size: 0.75rem !important; + line-height: 1.5 !important; +} + +/* Inline code - matching chat InlineCode component */ +.milkdown-wrapper .editor code { + background: rgb(59 130 246 / 0.2) !important; + color: rgb(96 165 250) !important; + padding: 0.125rem 0.375rem !important; + border-radius: 0.25rem !important; + font-size: 0.675rem !important; + font-family: var(--font-mono) !important; +} + +/* Code blocks */ +.milkdown-wrapper .editor pre { + background: #000000 !important; + padding: 0.5rem 0.75rem !important; + border-radius: 0.375rem !important; + overflow-x: auto !important; + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.milkdown-wrapper:not(.dark) .editor pre { + background: #f5f5f5 !important; +} + +.milkdown-wrapper .editor pre code { + background: transparent !important; + color: #d4d4d4 !important; + padding: 0 !important; + font-size: 0.625rem !important; + font-family: var(--font-mono) !important; +} + +.milkdown-wrapper:not(.dark) .editor pre code { + color: #1e1e1e !important; +} + +/* Blockquote */ +.milkdown-wrapper .editor blockquote { + border-left: 3px solid var(--primary) !important; + padding-left: 0.75rem !important; + margin-left: 0 !important; + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + color: var(--muted-foreground) !important; + font-style: italic !important; +} + +/* Links */ +.milkdown-wrapper .editor a { + color: var(--primary) !important; + text-decoration: underline !important; + text-underline-offset: 2px !important; +} + +/* Strong/Bold */ +.milkdown-wrapper .editor strong { + font-weight: 700; + color: var(--foreground); +} + +/* Emphasis */ +.milkdown-wrapper .editor em { + font-style: italic; +} + +/* Horizontal rule */ +.milkdown-wrapper .editor hr { + border: none !important; + border-top: 1px solid var(--border) !important; + margin: 1rem 0 !important; +} diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 48f98ef039..96dbb2a535 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -4,7 +4,6 @@ import { useRef, useCallback, useState, type KeyboardEvent as ReactKeyboardEvent import { IconArrowUp, IconBrain, - IconCheck, IconListCheck, IconLoader2, IconPlayerStopFilled, @@ -43,8 +42,6 @@ type ChatInputContainerProps = { isStarting: boolean; isSending: boolean; onCancel: () => void; - showApproveButton?: boolean; - onApprove?: () => void; placeholder?: string; pendingClarification?: Message | null; onClarificationResolved?: () => void; @@ -62,8 +59,6 @@ export function ChatInputContainer({ isStarting, isSending, onCancel, - showApproveButton, - onApprove, placeholder, pendingClarification, onClarificationResolved, @@ -235,24 +230,6 @@ export function ChatInputContainer({ - {/* Approve button */} - {showApproveButton && onApprove && ( - - - - - Approve and move to next step - - )} - {/* Submit/Stop button */}
diff --git a/apps/web/components/task/markdown-editor.tsx b/apps/web/components/task/markdown-editor.tsx new file mode 100644 index 0000000000..cd4f45a433 --- /dev/null +++ b/apps/web/components/task/markdown-editor.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useTheme } from 'next-themes'; +import { Editor, rootCtx, defaultValueCtx } from '@milkdown/core'; +import { nord } from '@milkdown/theme-nord'; +import { commonmark } from '@milkdown/preset-commonmark'; +import { gfm } from '@milkdown/preset-gfm'; +import { listener, listenerCtx } from '@milkdown/plugin-listener'; +import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'; + +type MilkdownEditorProps = { + value: string; + onChange: (value: string) => void; + placeholder?: string; +}; + +function MilkdownEditorInner({ value, onChange }: MilkdownEditorProps) { + const { resolvedTheme } = useTheme(); + // Capture initial value at mount - editor manages its own state after that + const initialValueRef = useRef(value); + const onChangeRef = useRef(onChange); + + // Keep onChange ref updated + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const { loading } = useEditor((root) => + Editor.make() + .config((ctx) => { + ctx.set(rootCtx, root); + ctx.set(defaultValueCtx, initialValueRef.current); + ctx.get(listenerCtx).markdownUpdated((_, markdown) => { + onChangeRef.current(markdown); + }); + }) + .config(nord) + .use(commonmark) + .use(gfm) + .use(listener) + ); + + return ( +
+ + {loading && ( +
+ Loading editor... +
+ )} +
+ ); +} + +export function MarkdownEditor(props: MilkdownEditorProps) { + // Generate a unique key on each mount to force fresh Milkdown instance + const [instanceId] = useState(() => Math.random().toString(36).slice(2)); + + return ( + + + + ); +} + diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx index 816dae60cd..f91c24fbd4 100644 --- a/apps/web/components/task/task-center-panel.tsx +++ b/apps/web/components/task/task-center-panel.tsx @@ -6,12 +6,15 @@ import { Textarea } from '@kandev/ui/textarea'; import { SessionPanel } from '@kandev/ui/pannel-session'; import { TaskChatPanel } from './task-chat-panel'; import { TaskChangesPanel } from './task-changes-panel'; +import { TaskPlanPanel } from './task-plan-panel'; import { FileViewerContent } from './file-viewer-content'; import { PassthroughTerminal } from './passthrough-terminal'; import type { OpenFileTab } from '@/lib/types/backend'; import { FILE_EXTENSION_COLORS } from '@/lib/types/backend'; import { useAppStore } from '@/components/state-provider'; import { SessionTabs, type SessionTab } from '@/components/session-tabs'; +import { approveSessionAction } from '@/app/actions/workspaces'; +import { getWebSocketClient } from '@/lib/ws/connection'; import type { SelectedDiff } from './task-layout'; @@ -35,6 +38,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ const activeSession = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null ); + const setTaskSession = useAppStore((state) => state.setTaskSession); + + // Check if agent is currently working + const isAgentWorking = activeSession?.state === 'STARTING' || activeSession?.state === 'RUNNING'; // Check if session is in passthrough mode by looking at the profile snapshot const isPassthroughMode = useMemo(() => { @@ -43,11 +50,77 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ const snapshot = activeSession.agent_profile_snapshot as any; return snapshot?.cli_passthrough === true; }, [activeSession?.agent_profile_snapshot]); + + // Check if we should show the approve button + // Show when session has a review_status that is not approved (meaning it's in a review step) + // Also hide while agent is working to prevent premature approval + const showApproveButton = + activeSession?.review_status != null && activeSession.review_status !== 'approved' && !isAgentWorking; + + // Approve handler - moves session to next workflow step + const handleApprove = useCallback(async () => { + if (!activeSessionId || !activeTaskId) return; + try { + const response = await approveSessionAction(activeSessionId); + + // Update the session in the store so the review panel closes + if (response?.session) { + setTaskSession(response.session); + } + + // Check if the new step has auto_start_agent enabled + if (response?.workflow_step?.auto_start_agent) { + const client = getWebSocketClient(); + if (client) { + client.send({ + type: 'request', + action: 'orchestrator.start', + payload: { + task_id: activeTaskId, + session_id: activeSessionId, + workflow_step_id: response.workflow_step.id, + }, + }); + } + } + } catch (error) { + console.error('Failed to approve session:', error); + } + }, [activeSessionId, activeTaskId, setTaskSession]); + const [leftTab, setLeftTab] = useState('chat'); const [selectedDiff, setSelectedDiff] = useState(null); const [notes, setNotes] = useState(''); const [openFileTabs, setOpenFileTabs] = useState([]); + // Track plan updates for notification dot + const plan = useAppStore((state) => + activeTaskId ? state.taskPlans.byTaskId[activeTaskId] : null + ); + // Track the last plan update timestamp the user has seen (when they viewed the Plan tab) + // Key is taskId to handle task switching + const [lastSeenPlanUpdateByTask, setLastSeenPlanUpdateByTask] = useState>({}); + + // Derive notification state: show dot if plan was updated by agent and we haven't seen it + const hasUnseenPlanUpdate = useMemo(() => { + if (!activeTaskId || !plan || leftTab === 'plan') return false; + if (plan.created_by !== 'agent') return false; + const lastSeen = lastSeenPlanUpdateByTask[activeTaskId]; + return plan.updated_at !== lastSeen; + }, [activeTaskId, plan, leftTab, lastSeenPlanUpdateByTask]); + + // Handle tab change - mark plan as seen when switching to plan tab + const handleTabChange = useCallback((tab: string) => { + // If switching to plan tab, mark current plan as seen + if (tab === 'plan' && activeTaskId && plan?.updated_at) { + setLastSeenPlanUpdateByTask(prev => ({ + ...prev, + [activeTaskId]: plan.updated_at + })); + } + setLeftTab(tab); + }, [activeTaskId, plan]); + // Handle external diff selection useEffect(() => { if (externalSelectedDiff) { @@ -83,12 +156,19 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ setOpenFileTabs((prev) => prev.filter((t) => t.path !== path)); // If closing the active tab, switch to chat if (leftTab === `file:${path}`) { - setLeftTab('chat'); + handleTabChange('chat'); } - }, [leftTab]); + }, [leftTab, handleTabChange]); const tabs: SessionTab[] = useMemo(() => { const staticTabs: SessionTab[] = [ + { + id: 'plan', + label: 'Plan', + icon: hasUnseenPlanUpdate ? ( + + ) : undefined, + }, { id: 'notes', label: 'Notes' }, { id: 'changes', label: 'All changes' }, { id: 'chat', label: 'Chat' }, @@ -111,18 +191,27 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({ }); return [...staticTabs, ...fileTabs]; - }, [openFileTabs, handleCloseFileTab]); + }, [openFileTabs, handleCloseFileTab, hasUnseenPlanUpdate]); return ( 0 ? 2 : undefined} + onTabChange={handleTabChange} + separatorAfterIndex={openFileTabs.length > 0 ? 3 : undefined} className="flex-1 min-h-0 flex flex-col gap-2" > + + + +