feat: add "Create Spec" button and fix Windows asyncio subprocess

UI Changes:
- Add "Create Spec with AI" button in empty kanban when project has no spec
- Button opens SpecCreationChat to guide users through spec creation
- Shows in Pending column when has_spec=false and no features exist

Windows Fixes:
- Fix asyncio subprocess NotImplementedError on Windows
- Set WindowsProactorEventLoopPolicy in server/__init__.py
- Remove --reload from uvicorn (incompatible with Windows subprocess)
- Add process cleanup on startup in start_ui.bat

Spec Chat Improvements:
- Enable full tool access (remove allowed_tools restriction)
- Add "user" to setting_sources for global skills access
- Use bypassPermissions mode for auto-approval
- Add WebFetch/WebSearch auto-approve hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mmereu
2026-01-19 21:53:09 +01:00
parent 5f786078fa
commit 245cc5b7ad
10 changed files with 116 additions and 19 deletions

View File

@@ -15,7 +15,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher from claude_agent_sdk.types import HookMatcher
from dotenv import load_dotenv from dotenv import load_dotenv
from security import bash_security_hook from security import bash_security_hook, web_tools_auto_approve_hook
# Load environment variables from .env file if present # Load environment variables from .env file if present
load_dotenv() load_dotenv()
@@ -180,7 +180,7 @@ def create_client(
security_settings = { security_settings = {
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True}, "sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
"permissions": { "permissions": {
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories "defaultMode": "bypassPermissions", # Auto-approve all tools
"allow": permissions_list, "allow": permissions_list,
}, },
} }
@@ -272,6 +272,7 @@ def create_client(
hooks={ hooks={
"PreToolUse": [ "PreToolUse": [
HookMatcher(matcher="Bash", hooks=[bash_security_hook]), HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
HookMatcher(matcher="WebFetch|WebSearch", hooks=[web_tools_auto_approve_hook]),
], ],
}, },
max_turns=1000, max_turns=1000,

View File

@@ -309,6 +309,28 @@ def get_command_for_validation(cmd: str, segments: list[str]) -> str:
return "" return ""
async def web_tools_auto_approve_hook(input_data, tool_use_id=None, context=None):
"""
Pre-tool-use hook that auto-approves WebFetch and WebSearch tools.
Workaround for Claude Code bug where these tools are auto-denied in dontAsk mode.
See: https://github.com/anthropics/claude-code/issues/11881
Args:
input_data: Dict containing tool_name and tool_input
tool_use_id: Optional tool use ID
context: Optional context
Returns:
Empty dict to allow (auto-approve)
"""
tool_name = input_data.get("tool_name", "")
if tool_name in ("WebFetch", "WebSearch"):
# Return empty dict = allow/approve the tool
return {}
return {}
async def bash_security_hook(input_data, tool_use_id=None, context=None): async def bash_security_hook(input_data, tool_use_id=None, context=None):
""" """
Pre-tool-use hook that validates bash commands using an allowlist. Pre-tool-use hook that validates bash commands using an allowlist.

View File

@@ -6,3 +6,12 @@ Web UI server for the Autonomous Coding Agent.
Provides REST API and WebSocket endpoints for project management, Provides REST API and WebSocket endpoints for project management,
feature tracking, and agent control. feature tracking, and agent control.
""" """
# Fix Windows asyncio subprocess support - MUST be before any other imports
# that might create an event loop
import sys
if sys.platform == "win32":
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())

View File

@@ -6,11 +6,17 @@ Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving. Provides REST API, WebSocket, and static file serving.
""" """
import asyncio
import os import os
import shutil import shutil
import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
# Fix for Windows subprocess support in asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file if present # Load environment variables from .env file if present

View File

@@ -179,15 +179,10 @@ class SpecChatSession:
model=model, model=model,
cli_path=system_cli, cli_path=system_cli,
# System prompt loaded from CLAUDE.md via setting_sources # System prompt loaded from CLAUDE.md via setting_sources
# This avoids Windows command line length limit (~8191 chars) # Include "user" for global skills and subagents from ~/.claude/
setting_sources=["project"], setting_sources=["project", "user"],
allowed_tools=[ # No allowed_tools restriction - full access to all tools, skills, subagents
"Read", permission_mode="bypassPermissions", # Auto-approve all tools
"Write",
"Edit",
"Glob",
],
permission_mode="acceptEdits", # Auto-approve file writes for spec creation
max_turns=100, max_turns=100,
cwd=str(self.project_dir.resolve()), cwd=str(self.project_dir.resolve()),
settings=str(settings_file.resolve()), settings=str(settings_file.resolve()),

View File

@@ -9,6 +9,12 @@ echo AutoCoder UI
echo ==================================== echo ====================================
echo. echo.
REM Kill any existing processes on port 8888
echo Cleaning up old processes...
for /f "tokens=5" %%a in ('netstat -aon ^| findstr ":8888" ^| findstr "LISTENING"') do (
taskkill /F /PID %%a >nul 2>&1
)
REM Check if Python is available REM Check if Python is available
where python >nul 2>&1 where python >nul 2>&1
if %ERRORLEVEL% neq 0 ( if %ERRORLEVEL% neq 0 (

View File

@@ -19,6 +19,7 @@ Options:
--dev Run in development mode with Vite hot reload --dev Run in development mode with Vite hot reload
""" """
import asyncio
import os import os
import shutil import shutil
import socket import socket
@@ -28,6 +29,10 @@ import time
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
# Fix Windows asyncio subprocess support BEFORE anything else runs
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
ROOT = Path(__file__).parent.absolute() ROOT = Path(__file__).parent.absolute()
VENV_DIR = ROOT / "venv" VENV_DIR = ROOT / "venv"
UI_DIR = ROOT / "ui" UI_DIR = ROOT / "ui"
@@ -182,17 +187,24 @@ def start_dev_server(port: int) -> tuple:
def start_production_server(port: int): def start_production_server(port: int):
"""Start FastAPI server in production mode.""" """Start FastAPI server in production mode with hot reload."""
venv_python = get_venv_python() venv_python = get_venv_python()
print(f"\n Starting server at http://127.0.0.1:{port}") print(f"\n Starting server at http://127.0.0.1:{port} (with hot reload)")
# Set PYTHONASYNCIODEBUG to help with Windows subprocess issues
env = os.environ.copy()
# NOTE: --reload is NOT used because on Windows it breaks asyncio subprocess
# support (uvicorn's reload worker doesn't inherit the ProactorEventLoop policy).
# This affects Claude SDK which uses asyncio.create_subprocess_exec.
# For development with hot reload, use: python start_ui.py --dev
return subprocess.Popen([ return subprocess.Popen([
str(venv_python), "-m", "uvicorn", str(venv_python), "-m", "uvicorn",
"server.main:app", "server.main:app",
"--host", "127.0.0.1", "--host", "127.0.0.1",
"--port", str(port) "--port", str(port),
], cwd=str(ROOT)) ], cwd=str(ROOT), env=env)
def main() -> None: def main() -> None:

View File

@@ -18,6 +18,7 @@ import { CelebrationOverlay } from './components/CelebrationOverlay'
import { AssistantFAB } from './components/AssistantFAB' import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel' import { AssistantPanel } from './components/AssistantPanel'
import { ExpandProjectModal } from './components/ExpandProjectModal' import { ExpandProjectModal } from './components/ExpandProjectModal'
import { SpecCreationChat } from './components/SpecCreationChat'
import { SettingsModal } from './components/SettingsModal' import { SettingsModal } from './components/SettingsModal'
import { DevServerControl } from './components/DevServerControl' import { DevServerControl } from './components/DevServerControl'
import { ViewToggle, type ViewMode } from './components/ViewToggle' import { ViewToggle, type ViewMode } from './components/ViewToggle'
@@ -51,6 +52,7 @@ function App() {
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false) const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false) const [isSpecCreating, setIsSpecCreating] = useState(false)
const [showSpecChat, setShowSpecChat] = useState(false) // For "Create Spec" button in empty kanban
const [darkMode, setDarkMode] = useState(() => { const [darkMode, setDarkMode] = useState(() => {
try { try {
return localStorage.getItem(DARK_MODE_KEY) === 'true' return localStorage.getItem(DARK_MODE_KEY) === 'true'
@@ -74,6 +76,10 @@ function App() {
useAgentStatus(selectedProject) // Keep polling for status updates useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject) const wsState = useProjectWebSocket(selectedProject)
// Get has_spec from the selected project
const selectedProjectData = projects?.find(p => p.name === selectedProject)
const hasSpec = selectedProjectData?.has_spec ?? true
// Fetch graph data when in graph view // Fetch graph data when in graph view
const { data: graphData } = useQuery({ const { data: graphData } = useQuery({
queryKey: ['dependencyGraph', selectedProject], queryKey: ['dependencyGraph', selectedProject],
@@ -391,6 +397,8 @@ function App() {
onAddFeature={() => setShowAddFeature(true)} onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)} onExpandProject={() => setShowExpandProject(true)}
activeAgents={wsState.activeAgents} activeAgents={wsState.activeAgents}
onCreateSpec={() => setShowSpecChat(true)}
hasSpec={hasSpec}
/> />
) : ( ) : (
<div className="neo-card overflow-hidden" style={{ height: '600px' }}> <div className="neo-card overflow-hidden" style={{ height: '600px' }}>
@@ -441,6 +449,23 @@ function App() {
/> />
)} )}
{/* Spec Creation Chat - for creating spec from empty kanban */}
{showSpecChat && selectedProject && (
<div className="fixed inset-0 z-50 bg-[var(--color-neo-bg)]">
<SpecCreationChat
projectName={selectedProject}
onComplete={() => {
setShowSpecChat(false)
// Refresh projects to update has_spec
queryClient.invalidateQueries({ queryKey: ['projects'] })
queryClient.invalidateQueries({ queryKey: ['features', selectedProject] })
}}
onCancel={() => setShowSpecChat(false)}
onExitToProject={() => setShowSpecChat(false)}
/>
</div>
)}
{/* Debug Log Viewer - fixed to bottom */} {/* Debug Log Viewer - fixed to bottom */}
{selectedProject && ( {selectedProject && (
<DebugLogViewer <DebugLogViewer
@@ -458,7 +483,7 @@ function App() {
)} )}
{/* Assistant FAB and Panel - hide when expand modal or spec creation is open */} {/* Assistant FAB and Panel - hide when expand modal or spec creation is open */}
{selectedProject && !showExpandProject && !isSpecCreating && ( {selectedProject && !showExpandProject && !isSpecCreating && !showSpecChat && (
<> <>
<AssistantFAB <AssistantFAB
onClick={() => setAssistantOpen(!assistantOpen)} onClick={() => setAssistantOpen(!assistantOpen)}

View File

@@ -7,9 +7,11 @@ interface KanbanBoardProps {
onAddFeature?: () => void onAddFeature?: () => void
onExpandProject?: () => void onExpandProject?: () => void
activeAgents?: ActiveAgent[] activeAgents?: ActiveAgent[]
onCreateSpec?: () => void // Callback to start spec creation
hasSpec?: boolean // Whether the project has a spec
} }
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [] }: KanbanBoardProps) { export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [], onCreateSpec, hasSpec = true }: KanbanBoardProps) {
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0 const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
// Combine all features for dependency status calculation // Combine all features for dependency status calculation
@@ -47,6 +49,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
onAddFeature={onAddFeature} onAddFeature={onAddFeature}
onExpandProject={onExpandProject} onExpandProject={onExpandProject}
showExpandButton={hasFeatures} showExpandButton={hasFeatures}
onCreateSpec={onCreateSpec}
showCreateSpec={!hasSpec && !hasFeatures}
/> />
<KanbanColumn <KanbanColumn
title="In Progress" title="In Progress"

View File

@@ -1,5 +1,5 @@
import { FeatureCard } from './FeatureCard' import { FeatureCard } from './FeatureCard'
import { Plus, Sparkles } from 'lucide-react' import { Plus, Sparkles, Wand2 } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types' import type { Feature, ActiveAgent } from '../lib/types'
interface KanbanColumnProps { interface KanbanColumnProps {
@@ -13,6 +13,8 @@ interface KanbanColumnProps {
onAddFeature?: () => void onAddFeature?: () => void
onExpandProject?: () => void onExpandProject?: () => void
showExpandButton?: boolean showExpandButton?: boolean
onCreateSpec?: () => void // Callback to start spec creation
showCreateSpec?: boolean // Show "Create Spec" button when project has no spec
} }
const colorMap = { const colorMap = {
@@ -32,6 +34,8 @@ export function KanbanColumn({
onAddFeature, onAddFeature,
onExpandProject, onExpandProject,
showExpandButton, showExpandButton,
onCreateSpec,
showCreateSpec,
}: KanbanColumnProps) { }: KanbanColumnProps) {
// Create a map of feature ID to active agent for quick lookup // Create a map of feature ID to active agent for quick lookup
const agentByFeatureId = new Map( const agentByFeatureId = new Map(
@@ -81,7 +85,20 @@ export function KanbanColumn({
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]"> <div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]">
{features.length === 0 ? ( {features.length === 0 ? (
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]"> <div className="text-center py-8 text-[var(--color-neo-text-secondary)]">
No features {showCreateSpec && onCreateSpec ? (
<div className="space-y-4">
<p>No spec created yet</p>
<button
onClick={onCreateSpec}
className="neo-btn neo-btn-primary inline-flex items-center gap-2"
>
<Wand2 size={18} />
Create Spec with AI
</button>
</div>
) : (
'No features'
)}
</div> </div>
) : ( ) : (
features.map((feature, index) => ( features.map((feature, index) => (