autoforge-ai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spec Creation Chat Session
|
|
3
|
+
==========================
|
|
4
|
+
|
|
5
|
+
Manages interactive spec creation conversation with Claude.
|
|
6
|
+
Uses the create-spec.md skill to guide users through app spec creation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import threading
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, AsyncGenerator, Optional
|
|
17
|
+
|
|
18
|
+
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
19
|
+
from dotenv import load_dotenv
|
|
20
|
+
|
|
21
|
+
from ..schemas import ImageAttachment
|
|
22
|
+
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
|
|
23
|
+
|
|
24
|
+
# Load environment variables from .env file if present
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SpecChatSession:
|
|
31
|
+
"""
|
|
32
|
+
Manages a spec creation conversation for one project.
|
|
33
|
+
|
|
34
|
+
Uses the create-spec skill to guide users through:
|
|
35
|
+
- Phase 1: Project Overview (name, description, audience)
|
|
36
|
+
- Phase 2: Involvement Level (Quick vs Detailed mode)
|
|
37
|
+
- Phase 3: Technology Preferences
|
|
38
|
+
- Phase 4: Features (main exploration phase)
|
|
39
|
+
- Phase 5: Technical Details (derived or discussed)
|
|
40
|
+
- Phase 6-7: Success Criteria & Approval
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, project_name: str, project_dir: Path):
|
|
44
|
+
"""
|
|
45
|
+
Initialize the session.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
project_name: Name of the project being created
|
|
49
|
+
project_dir: Absolute path to the project directory
|
|
50
|
+
"""
|
|
51
|
+
self.project_name = project_name
|
|
52
|
+
self.project_dir = project_dir
|
|
53
|
+
self.client: Optional[ClaudeSDKClient] = None
|
|
54
|
+
self.messages: list[dict] = []
|
|
55
|
+
self.complete: bool = False
|
|
56
|
+
self.created_at = datetime.now()
|
|
57
|
+
self._conversation_id: Optional[str] = None
|
|
58
|
+
self._client_entered: bool = False # Track if context manager is active
|
|
59
|
+
|
|
60
|
+
async def close(self) -> None:
|
|
61
|
+
"""Clean up resources and close the Claude client."""
|
|
62
|
+
if self.client and self._client_entered:
|
|
63
|
+
try:
|
|
64
|
+
await self.client.__aexit__(None, None, None)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.warning(f"Error closing Claude client: {e}")
|
|
67
|
+
finally:
|
|
68
|
+
self._client_entered = False
|
|
69
|
+
self.client = None
|
|
70
|
+
|
|
71
|
+
async def start(self) -> AsyncGenerator[dict, None]:
|
|
72
|
+
"""
|
|
73
|
+
Initialize session and get initial greeting from Claude.
|
|
74
|
+
|
|
75
|
+
Yields message chunks as they stream in.
|
|
76
|
+
"""
|
|
77
|
+
# Load the create-spec skill
|
|
78
|
+
skill_path = ROOT_DIR / ".claude" / "commands" / "create-spec.md"
|
|
79
|
+
|
|
80
|
+
if not skill_path.exists():
|
|
81
|
+
yield {
|
|
82
|
+
"type": "error",
|
|
83
|
+
"content": f"Spec creation skill not found at {skill_path}"
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
skill_content = skill_path.read_text(encoding="utf-8")
|
|
89
|
+
except UnicodeDecodeError:
|
|
90
|
+
skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
|
|
92
|
+
# Ensure project directory exists (like CLI does in start.py)
|
|
93
|
+
self.project_dir.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
# Delete app_spec.txt so Claude can create it fresh
|
|
96
|
+
# The SDK requires reading existing files before writing, but app_spec.txt is created new
|
|
97
|
+
# Note: We keep initializer_prompt.md so Claude can read and update the template
|
|
98
|
+
from autoforge_paths import get_prompts_dir
|
|
99
|
+
prompts_dir = get_prompts_dir(self.project_dir)
|
|
100
|
+
app_spec_path = prompts_dir / "app_spec.txt"
|
|
101
|
+
if app_spec_path.exists():
|
|
102
|
+
app_spec_path.unlink()
|
|
103
|
+
logger.info("Deleted scaffolded app_spec.txt for fresh spec creation")
|
|
104
|
+
|
|
105
|
+
# Create security settings file (like client.py does)
|
|
106
|
+
# This grants permissions for file operations in the project directory
|
|
107
|
+
security_settings = {
|
|
108
|
+
"sandbox": {"enabled": False}, # Disable sandbox for spec creation
|
|
109
|
+
"permissions": {
|
|
110
|
+
"defaultMode": "acceptEdits",
|
|
111
|
+
"allow": [
|
|
112
|
+
"Read(./**)",
|
|
113
|
+
"Write(./**)",
|
|
114
|
+
"Edit(./**)",
|
|
115
|
+
"Glob(./**)",
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
from autoforge_paths import get_claude_settings_path
|
|
120
|
+
settings_file = get_claude_settings_path(self.project_dir)
|
|
121
|
+
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
with open(settings_file, "w") as f:
|
|
123
|
+
json.dump(security_settings, f, indent=2)
|
|
124
|
+
|
|
125
|
+
# Replace $ARGUMENTS with absolute project path (like CLI does in start.py:184)
|
|
126
|
+
# Using absolute path avoids confusion when project folder name differs from app name
|
|
127
|
+
project_path = str(self.project_dir.resolve())
|
|
128
|
+
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
|
|
129
|
+
|
|
130
|
+
# Write system prompt to CLAUDE.md file to avoid Windows command line length limit
|
|
131
|
+
# The SDK will read this via setting_sources=["project"]
|
|
132
|
+
claude_md_path = self.project_dir / "CLAUDE.md"
|
|
133
|
+
with open(claude_md_path, "w", encoding="utf-8") as f:
|
|
134
|
+
f.write(system_prompt)
|
|
135
|
+
logger.info(f"Wrote system prompt to {claude_md_path}")
|
|
136
|
+
|
|
137
|
+
# Create Claude SDK client with limited tools for spec creation
|
|
138
|
+
# Use Opus for best quality spec generation
|
|
139
|
+
# Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows
|
|
140
|
+
system_cli = shutil.which("claude")
|
|
141
|
+
|
|
142
|
+
# Build environment overrides for API configuration
|
|
143
|
+
# Filter to only include vars that are actually set (non-None)
|
|
144
|
+
sdk_env: dict[str, str] = {}
|
|
145
|
+
for var in API_ENV_VARS:
|
|
146
|
+
value = os.getenv(var)
|
|
147
|
+
if value:
|
|
148
|
+
sdk_env[var] = value
|
|
149
|
+
|
|
150
|
+
# Determine model from environment or use default
|
|
151
|
+
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
|
|
152
|
+
model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
self.client = ClaudeSDKClient(
|
|
156
|
+
options=ClaudeAgentOptions(
|
|
157
|
+
model=model,
|
|
158
|
+
cli_path=system_cli,
|
|
159
|
+
# System prompt loaded from CLAUDE.md via setting_sources
|
|
160
|
+
# Include "user" for global skills and subagents from ~/.claude/
|
|
161
|
+
setting_sources=["project", "user"],
|
|
162
|
+
allowed_tools=[
|
|
163
|
+
"Read",
|
|
164
|
+
"Write",
|
|
165
|
+
"Edit",
|
|
166
|
+
"Glob",
|
|
167
|
+
"WebFetch",
|
|
168
|
+
"WebSearch",
|
|
169
|
+
],
|
|
170
|
+
permission_mode="acceptEdits", # Auto-approve file writes for spec creation
|
|
171
|
+
max_turns=100,
|
|
172
|
+
cwd=str(self.project_dir.resolve()),
|
|
173
|
+
settings=str(settings_file.resolve()),
|
|
174
|
+
env=sdk_env,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
# Enter the async context and track it
|
|
178
|
+
await self.client.__aenter__()
|
|
179
|
+
self._client_entered = True
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.exception("Failed to create Claude client")
|
|
182
|
+
yield {
|
|
183
|
+
"type": "error",
|
|
184
|
+
"content": f"Failed to initialize Claude: {str(e)}"
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
# Start the conversation - Claude will send the Phase 1 greeting
|
|
189
|
+
try:
|
|
190
|
+
async for chunk in self._query_claude("Begin the spec creation process."):
|
|
191
|
+
yield chunk
|
|
192
|
+
# Signal that the response is complete (for UI to hide loading indicator)
|
|
193
|
+
yield {"type": "response_done"}
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.exception("Failed to start spec chat")
|
|
196
|
+
yield {
|
|
197
|
+
"type": "error",
|
|
198
|
+
"content": f"Failed to start conversation: {str(e)}"
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async def send_message(
|
|
202
|
+
self,
|
|
203
|
+
user_message: str,
|
|
204
|
+
attachments: list[ImageAttachment] | None = None
|
|
205
|
+
) -> AsyncGenerator[dict, None]:
|
|
206
|
+
"""
|
|
207
|
+
Send user message and stream Claude's response.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
user_message: The user's response
|
|
211
|
+
attachments: Optional list of image attachments
|
|
212
|
+
|
|
213
|
+
Yields:
|
|
214
|
+
Message chunks of various types:
|
|
215
|
+
- {"type": "text", "content": str}
|
|
216
|
+
- {"type": "question", "questions": list}
|
|
217
|
+
- {"type": "spec_complete", "path": str}
|
|
218
|
+
- {"type": "error", "content": str}
|
|
219
|
+
"""
|
|
220
|
+
if not self.client:
|
|
221
|
+
yield {
|
|
222
|
+
"type": "error",
|
|
223
|
+
"content": "Session not initialized. Call start() first."
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# Store the user message
|
|
228
|
+
self.messages.append({
|
|
229
|
+
"role": "user",
|
|
230
|
+
"content": user_message,
|
|
231
|
+
"has_attachments": bool(attachments),
|
|
232
|
+
"timestamp": datetime.now().isoformat()
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
async for chunk in self._query_claude(user_message, attachments):
|
|
237
|
+
yield chunk
|
|
238
|
+
# Signal that the response is complete (for UI to hide loading indicator)
|
|
239
|
+
yield {"type": "response_done"}
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.exception("Error during Claude query")
|
|
242
|
+
yield {
|
|
243
|
+
"type": "error",
|
|
244
|
+
"content": f"Error: {str(e)}"
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async def _query_claude(
|
|
248
|
+
self,
|
|
249
|
+
message: str,
|
|
250
|
+
attachments: list[ImageAttachment] | None = None
|
|
251
|
+
) -> AsyncGenerator[dict, None]:
|
|
252
|
+
"""
|
|
253
|
+
Internal method to query Claude and stream responses.
|
|
254
|
+
|
|
255
|
+
Handles tool calls (Write) and text responses.
|
|
256
|
+
Supports multimodal content with image attachments.
|
|
257
|
+
|
|
258
|
+
IMPORTANT: Spec creation requires BOTH files to be written:
|
|
259
|
+
1. app_spec.txt - the main specification
|
|
260
|
+
2. initializer_prompt.md - tells the agent how many features to create
|
|
261
|
+
|
|
262
|
+
We only signal spec_complete when BOTH files are verified on disk.
|
|
263
|
+
"""
|
|
264
|
+
if not self.client:
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Build the message content
|
|
268
|
+
if attachments and len(attachments) > 0:
|
|
269
|
+
# Multimodal message: build content blocks array
|
|
270
|
+
content_blocks: list[dict[str, Any]] = []
|
|
271
|
+
|
|
272
|
+
# Add text block if there's text
|
|
273
|
+
if message:
|
|
274
|
+
content_blocks.append({"type": "text", "text": message})
|
|
275
|
+
|
|
276
|
+
# Add image blocks
|
|
277
|
+
for att in attachments:
|
|
278
|
+
content_blocks.append({
|
|
279
|
+
"type": "image",
|
|
280
|
+
"source": {
|
|
281
|
+
"type": "base64",
|
|
282
|
+
"media_type": att.mimeType,
|
|
283
|
+
"data": att.base64Data,
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
# Send multimodal content to Claude using async generator format
|
|
288
|
+
# The SDK's query() accepts AsyncIterable[dict] for custom message formats
|
|
289
|
+
await self.client.query(make_multimodal_message(content_blocks))
|
|
290
|
+
logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
|
|
291
|
+
else:
|
|
292
|
+
# Text-only message: use string format
|
|
293
|
+
await self.client.query(message)
|
|
294
|
+
|
|
295
|
+
current_text = ""
|
|
296
|
+
|
|
297
|
+
# Track pending writes for BOTH required files
|
|
298
|
+
pending_writes: dict[str, dict[str, Any] | None] = {
|
|
299
|
+
"app_spec": None, # {"tool_id": ..., "path": ...}
|
|
300
|
+
"initializer": None, # {"tool_id": ..., "path": ...}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Track which files have been successfully written
|
|
304
|
+
files_written = {
|
|
305
|
+
"app_spec": False,
|
|
306
|
+
"initializer": False,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
# Store paths for the completion message
|
|
310
|
+
spec_path = None
|
|
311
|
+
|
|
312
|
+
# Stream the response using receive_response
|
|
313
|
+
async for msg in self.client.receive_response():
|
|
314
|
+
msg_type = type(msg).__name__
|
|
315
|
+
|
|
316
|
+
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
|
317
|
+
# Process content blocks in the assistant message
|
|
318
|
+
for block in msg.content:
|
|
319
|
+
block_type = type(block).__name__
|
|
320
|
+
|
|
321
|
+
if block_type == "TextBlock" and hasattr(block, "text"):
|
|
322
|
+
# Accumulate text and yield it
|
|
323
|
+
text = block.text
|
|
324
|
+
if text:
|
|
325
|
+
current_text += text
|
|
326
|
+
yield {"type": "text", "content": text}
|
|
327
|
+
|
|
328
|
+
# Store in message history
|
|
329
|
+
self.messages.append({
|
|
330
|
+
"role": "assistant",
|
|
331
|
+
"content": text,
|
|
332
|
+
"timestamp": datetime.now().isoformat()
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
|
336
|
+
tool_name = block.name
|
|
337
|
+
tool_input = getattr(block, "input", {})
|
|
338
|
+
tool_id = getattr(block, "id", "")
|
|
339
|
+
|
|
340
|
+
if tool_name in ("Write", "Edit"):
|
|
341
|
+
# File being written or edited - track for verification
|
|
342
|
+
file_path = tool_input.get("file_path", "")
|
|
343
|
+
|
|
344
|
+
# Track app_spec.txt
|
|
345
|
+
if "app_spec.txt" in str(file_path):
|
|
346
|
+
pending_writes["app_spec"] = {
|
|
347
|
+
"tool_id": tool_id,
|
|
348
|
+
"path": file_path
|
|
349
|
+
}
|
|
350
|
+
logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
|
|
351
|
+
|
|
352
|
+
# Track initializer_prompt.md
|
|
353
|
+
elif "initializer_prompt.md" in str(file_path):
|
|
354
|
+
pending_writes["initializer"] = {
|
|
355
|
+
"tool_id": tool_id,
|
|
356
|
+
"path": file_path
|
|
357
|
+
}
|
|
358
|
+
logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
|
|
359
|
+
|
|
360
|
+
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
|
361
|
+
# Tool results - check for write confirmations and errors
|
|
362
|
+
for block in msg.content:
|
|
363
|
+
block_type = type(block).__name__
|
|
364
|
+
if block_type == "ToolResultBlock":
|
|
365
|
+
is_error = getattr(block, "is_error", False)
|
|
366
|
+
tool_use_id = getattr(block, "tool_use_id", "")
|
|
367
|
+
|
|
368
|
+
if is_error:
|
|
369
|
+
content = getattr(block, "content", "Unknown error")
|
|
370
|
+
logger.warning(f"Tool error: {content}")
|
|
371
|
+
# Clear any pending writes that failed
|
|
372
|
+
for key in pending_writes:
|
|
373
|
+
pending_write = pending_writes[key]
|
|
374
|
+
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
|
|
375
|
+
logger.error(f"{key} write failed: {content}")
|
|
376
|
+
pending_writes[key] = None
|
|
377
|
+
else:
|
|
378
|
+
# Tool succeeded - check which file was written
|
|
379
|
+
|
|
380
|
+
# Check app_spec.txt
|
|
381
|
+
if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
|
|
382
|
+
file_path = pending_writes["app_spec"]["path"]
|
|
383
|
+
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
384
|
+
if full_path.exists():
|
|
385
|
+
logger.info(f"app_spec.txt verified at: {full_path}")
|
|
386
|
+
files_written["app_spec"] = True
|
|
387
|
+
spec_path = file_path
|
|
388
|
+
|
|
389
|
+
# Notify about file write (but NOT completion yet)
|
|
390
|
+
yield {
|
|
391
|
+
"type": "file_written",
|
|
392
|
+
"path": str(file_path)
|
|
393
|
+
}
|
|
394
|
+
else:
|
|
395
|
+
logger.error(f"app_spec.txt not found after write: {full_path}")
|
|
396
|
+
pending_writes["app_spec"] = None
|
|
397
|
+
|
|
398
|
+
# Check initializer_prompt.md
|
|
399
|
+
if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
|
|
400
|
+
file_path = pending_writes["initializer"]["path"]
|
|
401
|
+
full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
|
|
402
|
+
if full_path.exists():
|
|
403
|
+
logger.info(f"initializer_prompt.md verified at: {full_path}")
|
|
404
|
+
files_written["initializer"] = True
|
|
405
|
+
|
|
406
|
+
# Notify about file write
|
|
407
|
+
yield {
|
|
408
|
+
"type": "file_written",
|
|
409
|
+
"path": str(file_path)
|
|
410
|
+
}
|
|
411
|
+
else:
|
|
412
|
+
logger.error(f"initializer_prompt.md not found after write: {full_path}")
|
|
413
|
+
pending_writes["initializer"] = None
|
|
414
|
+
|
|
415
|
+
# Check if BOTH files are now written - only then signal completion
|
|
416
|
+
if files_written["app_spec"] and files_written["initializer"]:
|
|
417
|
+
logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
|
|
418
|
+
self.complete = True
|
|
419
|
+
yield {
|
|
420
|
+
"type": "spec_complete",
|
|
421
|
+
"path": str(spec_path)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
def is_complete(self) -> bool:
|
|
425
|
+
"""Check if spec creation is complete."""
|
|
426
|
+
return self.complete
|
|
427
|
+
|
|
428
|
+
def get_messages(self) -> list[dict]:
|
|
429
|
+
"""Get all messages in the conversation."""
|
|
430
|
+
return self.messages.copy()
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# Session registry with thread safety
|
|
434
|
+
_sessions: dict[str, SpecChatSession] = {}
|
|
435
|
+
_sessions_lock = threading.Lock()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def get_session(project_name: str) -> Optional[SpecChatSession]:
|
|
439
|
+
"""Get an existing session for a project."""
|
|
440
|
+
with _sessions_lock:
|
|
441
|
+
return _sessions.get(project_name)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
async def create_session(project_name: str, project_dir: Path) -> SpecChatSession:
|
|
445
|
+
"""Create a new session for a project, closing any existing one.
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
project_name: Name of the project
|
|
449
|
+
project_dir: Absolute path to the project directory
|
|
450
|
+
"""
|
|
451
|
+
old_session: Optional[SpecChatSession] = None
|
|
452
|
+
|
|
453
|
+
with _sessions_lock:
|
|
454
|
+
# Get existing session to close later (outside the lock)
|
|
455
|
+
old_session = _sessions.pop(project_name, None)
|
|
456
|
+
session = SpecChatSession(project_name, project_dir)
|
|
457
|
+
_sessions[project_name] = session
|
|
458
|
+
|
|
459
|
+
# Close old session outside the lock to avoid blocking
|
|
460
|
+
if old_session:
|
|
461
|
+
try:
|
|
462
|
+
await old_session.close()
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.warning(f"Error closing old session for {project_name}: {e}")
|
|
465
|
+
|
|
466
|
+
return session
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def remove_session(project_name: str) -> None:
|
|
470
|
+
"""Remove and close a session."""
|
|
471
|
+
session: Optional[SpecChatSession] = None
|
|
472
|
+
|
|
473
|
+
with _sessions_lock:
|
|
474
|
+
session = _sessions.pop(project_name, None)
|
|
475
|
+
|
|
476
|
+
# Close session outside the lock
|
|
477
|
+
if session:
|
|
478
|
+
try:
|
|
479
|
+
await session.close()
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.warning(f"Error closing session for {project_name}: {e}")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def list_sessions() -> list[str]:
|
|
485
|
+
"""List all active session project names."""
|
|
486
|
+
with _sessions_lock:
|
|
487
|
+
return list(_sessions.keys())
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def cleanup_all_sessions() -> None:
|
|
491
|
+
"""Close all active sessions. Called on server shutdown."""
|
|
492
|
+
sessions_to_close: list[SpecChatSession] = []
|
|
493
|
+
|
|
494
|
+
with _sessions_lock:
|
|
495
|
+
sessions_to_close = list(_sessions.values())
|
|
496
|
+
_sessions.clear()
|
|
497
|
+
|
|
498
|
+
for session in sessions_to_close:
|
|
499
|
+
try:
|
|
500
|
+
await session.close()
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.warning(f"Error closing session {session.project_name}: {e}")
|