ctrlcode 0.1.0__py3-none-any.whl

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.
Files changed (75) hide show
  1. ctrlcode/__init__.py +8 -0
  2. ctrlcode/agents/__init__.py +29 -0
  3. ctrlcode/agents/cleanup.py +388 -0
  4. ctrlcode/agents/communication.py +439 -0
  5. ctrlcode/agents/observability.py +421 -0
  6. ctrlcode/agents/react_loop.py +297 -0
  7. ctrlcode/agents/registry.py +211 -0
  8. ctrlcode/agents/result_parser.py +242 -0
  9. ctrlcode/agents/workflow.py +723 -0
  10. ctrlcode/analysis/__init__.py +28 -0
  11. ctrlcode/analysis/ast_diff.py +163 -0
  12. ctrlcode/analysis/bug_detector.py +149 -0
  13. ctrlcode/analysis/code_graphs.py +329 -0
  14. ctrlcode/analysis/semantic.py +205 -0
  15. ctrlcode/analysis/static.py +183 -0
  16. ctrlcode/analysis/synthesizer.py +281 -0
  17. ctrlcode/analysis/tests.py +189 -0
  18. ctrlcode/cleanup/__init__.py +16 -0
  19. ctrlcode/cleanup/auto_merge.py +350 -0
  20. ctrlcode/cleanup/doc_gardening.py +388 -0
  21. ctrlcode/cleanup/pr_automation.py +330 -0
  22. ctrlcode/cleanup/scheduler.py +356 -0
  23. ctrlcode/config.py +380 -0
  24. ctrlcode/embeddings/__init__.py +6 -0
  25. ctrlcode/embeddings/embedder.py +192 -0
  26. ctrlcode/embeddings/vector_store.py +213 -0
  27. ctrlcode/fuzzing/__init__.py +24 -0
  28. ctrlcode/fuzzing/analyzer.py +280 -0
  29. ctrlcode/fuzzing/budget.py +112 -0
  30. ctrlcode/fuzzing/context.py +665 -0
  31. ctrlcode/fuzzing/context_fuzzer.py +506 -0
  32. ctrlcode/fuzzing/derived_orchestrator.py +732 -0
  33. ctrlcode/fuzzing/oracle_adapter.py +135 -0
  34. ctrlcode/linters/__init__.py +11 -0
  35. ctrlcode/linters/hand_rolled_utils.py +221 -0
  36. ctrlcode/linters/yolo_parsing.py +217 -0
  37. ctrlcode/metrics/__init__.py +6 -0
  38. ctrlcode/metrics/dashboard.py +283 -0
  39. ctrlcode/metrics/tech_debt.py +663 -0
  40. ctrlcode/paths.py +68 -0
  41. ctrlcode/permissions.py +179 -0
  42. ctrlcode/providers/__init__.py +15 -0
  43. ctrlcode/providers/anthropic.py +138 -0
  44. ctrlcode/providers/base.py +77 -0
  45. ctrlcode/providers/openai.py +197 -0
  46. ctrlcode/providers/parallel.py +104 -0
  47. ctrlcode/server.py +871 -0
  48. ctrlcode/session/__init__.py +6 -0
  49. ctrlcode/session/baseline.py +57 -0
  50. ctrlcode/session/manager.py +967 -0
  51. ctrlcode/skills/__init__.py +10 -0
  52. ctrlcode/skills/builtin/commit.toml +29 -0
  53. ctrlcode/skills/builtin/docs.toml +25 -0
  54. ctrlcode/skills/builtin/refactor.toml +33 -0
  55. ctrlcode/skills/builtin/review.toml +28 -0
  56. ctrlcode/skills/builtin/test.toml +28 -0
  57. ctrlcode/skills/loader.py +111 -0
  58. ctrlcode/skills/registry.py +139 -0
  59. ctrlcode/storage/__init__.py +19 -0
  60. ctrlcode/storage/history_db.py +708 -0
  61. ctrlcode/tools/__init__.py +220 -0
  62. ctrlcode/tools/bash.py +112 -0
  63. ctrlcode/tools/browser.py +352 -0
  64. ctrlcode/tools/executor.py +153 -0
  65. ctrlcode/tools/explore.py +486 -0
  66. ctrlcode/tools/mcp.py +108 -0
  67. ctrlcode/tools/observability.py +561 -0
  68. ctrlcode/tools/registry.py +193 -0
  69. ctrlcode/tools/todo.py +291 -0
  70. ctrlcode/tools/update.py +266 -0
  71. ctrlcode/tools/webfetch.py +147 -0
  72. ctrlcode-0.1.0.dist-info/METADATA +93 -0
  73. ctrlcode-0.1.0.dist-info/RECORD +75 -0
  74. ctrlcode-0.1.0.dist-info/WHEEL +4 -0
  75. ctrlcode-0.1.0.dist-info/entry_points.txt +3 -0
ctrlcode/server.py ADDED
@@ -0,0 +1,871 @@
1
+ """JSON-RPC server for ctrl-code harness."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from aiohttp import web
11
+ from harnessutils.config import HarnessConfig
12
+
13
+ from .config import Config
14
+ from .paths import get_data_dir
15
+ from .providers.anthropic import AnthropicProvider
16
+ from .providers.openai import OpenAIProvider
17
+ from .providers.base import Provider
18
+ from .session.manager import SessionManager
19
+ from .fuzzing.derived_orchestrator import DerivedFuzzingOrchestrator
20
+ from .tools.registry import ToolRegistry
21
+ from .tools.executor import ToolExecutor
22
+ from .tools import (
23
+ setup_explore_tools,
24
+ setup_todo_tools,
25
+ setup_bash_tools,
26
+ setup_webfetch_tools,
27
+ setup_update_tools,
28
+ setup_observability_tools,
29
+ )
30
+ from .skills.loader import SkillLoader
31
+ from .skills.registry import SkillRegistry
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class HarnessServer:
37
+ """JSON-RPC server for coding harness."""
38
+
39
+ def __init__(self, config: Config):
40
+ """
41
+ Initialize harness server.
42
+
43
+ Args:
44
+ config: Server configuration
45
+ """
46
+ self.config = config
47
+ self.api_key = config.server.api_key
48
+ self.app = web.Application()
49
+ self.app.router.add_post("/rpc", self.handle_rpc)
50
+ self.app.router.add_get("/metrics/tech-debt", self.serve_tech_debt_dashboard)
51
+
52
+ # Add startup/shutdown handlers
53
+ self.app.on_startup.append(self._startup)
54
+ self.app.on_shutdown.append(self._shutdown)
55
+
56
+ # Initialize providers
57
+ self.providers: dict[str, Provider] = {}
58
+ if config.anthropic:
59
+ self.providers["anthropic"] = AnthropicProvider( # type: ignore[assignment]
60
+ api_key=config.anthropic.api_key,
61
+ model=config.anthropic.model,
62
+ base_url=config.anthropic.base_url
63
+ )
64
+ if config.openai:
65
+ self.providers["openai"] = OpenAIProvider( # type: ignore[assignment]
66
+ api_key=config.openai.api_key,
67
+ model=config.openai.model,
68
+ base_url=config.openai.base_url
69
+ )
70
+
71
+ if not self.providers:
72
+ raise ValueError("No providers configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY")
73
+
74
+ # Default provider (prefer Anthropic)
75
+ default_provider = self.providers.get("anthropic") or self.providers.get("openai")
76
+ assert default_provider is not None, "At least one provider must be configured"
77
+
78
+ # Initialize derived fuzzing orchestrator (if enabled)
79
+ fuzzing_orchestrator = None
80
+ if config.fuzzing.enabled:
81
+ provider_list = list(self.providers.values())
82
+
83
+ # Prepare embeddings config if available
84
+ embeddings_config = None
85
+ if config.embeddings:
86
+ embeddings_config = {
87
+ "api_key": config.embeddings.api_key,
88
+ "base_url": config.embeddings.base_url,
89
+ "model": config.embeddings.model,
90
+ }
91
+
92
+ fuzzing_orchestrator = DerivedFuzzingOrchestrator(
93
+ providers=provider_list,
94
+ config={
95
+ "budget_tokens": config.fuzzing.budget_tokens,
96
+ "budget_seconds": config.fuzzing.budget_seconds,
97
+ "max_iterations": config.fuzzing.max_iterations,
98
+ "input_fuzz_ratio": config.fuzzing.input_fuzz_ratio,
99
+ "environment_fuzz_ratio": config.fuzzing.environment_fuzz_ratio,
100
+ "combined_fuzz_ratio": config.fuzzing.combined_fuzz_ratio,
101
+ "invariant_fuzz_ratio": config.fuzzing.invariant_fuzz_ratio,
102
+ "oracle_confidence_threshold": config.fuzzing.oracle_confidence_threshold,
103
+ "context_re_derivation_on_mismatch": config.fuzzing.context_re_derivation_on_mismatch,
104
+ },
105
+ embeddings_config=embeddings_config,
106
+ )
107
+ logger.info("Derived context fuzzing enabled")
108
+
109
+ # Initialize tool registry
110
+ self.tool_registry = ToolRegistry()
111
+ self.tool_executor = ToolExecutor(self.tool_registry)
112
+
113
+ # Initialize permission manager
114
+ from .permissions import PermissionManager, set_permission_manager
115
+ self.permission_manager = PermissionManager(approval_callback=self._send_permission_request)
116
+ set_permission_manager(self.permission_manager)
117
+ self._permission_stream: Optional[web.StreamResponse] = None
118
+
119
+ # Register built-in explore tools
120
+ workspace_root = config.workspace_root or Path.cwd()
121
+ setup_explore_tools(self.tool_registry, workspace_root)
122
+ logger.info(f"Registered explore tools with workspace: {workspace_root}")
123
+
124
+ # Register built-in todo tools
125
+ data_dir = get_data_dir()
126
+ setup_todo_tools(self.tool_registry, data_dir)
127
+ logger.info(f"Registered todo tools with data dir: {data_dir}")
128
+
129
+ # Register built-in bash tools
130
+ setup_bash_tools(self.tool_registry, workspace_root)
131
+ logger.info("Registered bash tools")
132
+
133
+ # Register built-in web fetch tools
134
+ setup_webfetch_tools(self.tool_registry)
135
+ logger.info("Registered web fetch tools")
136
+
137
+ # Register built-in update file tools
138
+ setup_update_tools(self.tool_registry, workspace_root)
139
+ logger.info("Registered update file tools")
140
+
141
+ # Register built-in observability tools
142
+ log_dir = get_data_dir() / "logs"
143
+ setup_observability_tools(self.tool_registry, log_dir)
144
+ logger.info(f"Registered observability tools with log dir: {log_dir}")
145
+
146
+ # Initialize skill registry
147
+ self.skill_registry = SkillRegistry()
148
+ self._load_skills(config)
149
+
150
+ # Initialize tech debt metrics
151
+ from .metrics import TechDebtMetrics
152
+ metrics_dir = get_data_dir() / "metrics"
153
+ self.tech_debt_metrics = TechDebtMetrics(metrics_dir, project_path=workspace_root)
154
+ logger.info(f"Initialized tech debt metrics at: {metrics_dir} for project: {workspace_root}")
155
+
156
+ # Initialize session manager
157
+ harness_config = HarnessConfig() # Uses default config
158
+ self.session_manager = SessionManager(
159
+ provider=default_provider,
160
+ storage_path=str(config.storage_path),
161
+ config=harness_config,
162
+ fuzzing_orchestrator=fuzzing_orchestrator,
163
+ fuzzing_enabled=config.fuzzing.enabled,
164
+ tool_executor=self.tool_executor,
165
+ tool_registry=self.tool_registry,
166
+ skill_registry=self.skill_registry,
167
+ context_limit=config.context.default_limit,
168
+ workspace_root=workspace_root,
169
+ )
170
+
171
+ self.fuzzing_orchestrator = fuzzing_orchestrator
172
+
173
+ # Security: rate limiting (requests per session)
174
+ self._rate_limit_requests: dict[str, list[float]] = defaultdict(list)
175
+ self._rate_limit_enabled = config.security.rate_limit_enabled
176
+ self._rate_limit_window = config.security.rate_limit_window_seconds
177
+ self._rate_limit_max_requests = config.security.rate_limit_max_requests
178
+ self._max_input_length = config.security.max_input_length
179
+
180
+ logger.info(f"Initialized with providers: {list(self.providers.keys())}")
181
+
182
+ def _load_skills(self, config: Config) -> None:
183
+ """Load built-in and user skills."""
184
+ from pathlib import Path
185
+
186
+ # Load built-in skills
187
+ builtin_dir = Path(__file__).parent / "skills" / "builtin"
188
+ loader = SkillLoader(builtin_dir)
189
+ builtin_skills = loader.load_all()
190
+ self.skill_registry.register_all(builtin_skills)
191
+ logger.info(f"Loaded {len(builtin_skills)} built-in skills")
192
+
193
+ # Load user skills
194
+ if config.skills_directory and config.skills_directory.exists():
195
+ user_loader = SkillLoader(config.skills_directory)
196
+ user_skills = user_loader.load_all()
197
+ self.skill_registry.register_all(user_skills)
198
+ logger.info(f"Loaded {len(user_skills)} user skills")
199
+
200
+ async def _startup(self, app: web.Application) -> None:
201
+ """Initialize MCP servers on startup."""
202
+ for server_config in self.config.mcp_servers:
203
+ # Security: log MCP server command for audit trail
204
+ logger.info(
205
+ f"Starting MCP server '{server_config.name}' with command: {' '.join(server_config.command)}"
206
+ )
207
+ config_dict = {
208
+ "name": server_config.name,
209
+ "command": server_config.command,
210
+ "env": server_config.env,
211
+ }
212
+ await self.tool_registry.add_server(config_dict)
213
+
214
+ tools = self.tool_registry.list_tools()
215
+ builtin_count = len(self.tool_registry.builtin_tools)
216
+ mcp_count = len(self.tool_registry.mcp_tools)
217
+ logger.info(f"Initialized {len(tools)} total tools ({builtin_count} built-in, {mcp_count} from MCP)")
218
+
219
+ async def _shutdown(self, app: web.Application) -> None:
220
+ """Close MCP servers on shutdown."""
221
+ await self.tool_registry.close_all()
222
+ logger.info("Closed all MCP servers")
223
+
224
+ async def handle_rpc(self, request: web.Request) -> web.StreamResponse:
225
+ """Handle JSON-RPC requests."""
226
+ # Validate API key
227
+ if not self._validate_api_key(request):
228
+ return web.json_response({
229
+ "jsonrpc": "2.0",
230
+ "error": {"code": -32000, "message": "Unauthorized - invalid or missing API key"},
231
+ "id": None
232
+ }, status=401)
233
+
234
+ try:
235
+ data = await request.json()
236
+ except json.JSONDecodeError:
237
+ return web.json_response({
238
+ "jsonrpc": "2.0",
239
+ "error": {"code": -32700, "message": "Parse error"},
240
+ "id": None
241
+ }, status=400)
242
+
243
+ method = data.get("method")
244
+ params = data.get("params", {})
245
+ request_id = data.get("id")
246
+
247
+ logger.info(f"RPC call: {method}")
248
+
249
+ try:
250
+ if method == "session.create":
251
+ result = await self.create_session(params)
252
+ return web.json_response({
253
+ "jsonrpc": "2.0",
254
+ "id": request_id,
255
+ "result": result
256
+ })
257
+
258
+ elif method == "turn.process":
259
+ # Streaming response
260
+ return await self.process_turn(request, params, request_id)
261
+
262
+ elif method == "session.baseline":
263
+ result = await self.get_baseline(params)
264
+ return web.json_response({
265
+ "jsonrpc": "2.0",
266
+ "id": request_id,
267
+ "result": result
268
+ })
269
+
270
+ elif method == "session.stats":
271
+ result = await self.get_stats(params)
272
+ return web.json_response({
273
+ "jsonrpc": "2.0",
274
+ "id": request_id,
275
+ "result": result
276
+ })
277
+
278
+ elif method == "session.compact":
279
+ result = await self.compact_session(params)
280
+ return web.json_response({
281
+ "jsonrpc": "2.0",
282
+ "id": request_id,
283
+ "result": result
284
+ })
285
+
286
+ elif method == "session.clear":
287
+ result = await self.clear_session(params)
288
+ return web.json_response({
289
+ "jsonrpc": "2.0",
290
+ "id": request_id,
291
+ "result": result
292
+ })
293
+
294
+ elif method == "session.get_history_metrics":
295
+ result = await self.get_history_metrics(params)
296
+ return web.json_response({
297
+ "jsonrpc": "2.0",
298
+ "id": request_id,
299
+ "result": result
300
+ })
301
+
302
+ elif method == "tool.call":
303
+ result = await self.call_tool(params)
304
+ return web.json_response({
305
+ "jsonrpc": "2.0",
306
+ "id": request_id,
307
+ "result": result
308
+ })
309
+
310
+ elif method == "workflow.execute":
311
+ # Streaming response for multi-agent workflow
312
+ return await self.execute_workflow(params, request, request_id)
313
+
314
+ elif method == "permission.response":
315
+ result = await self.handle_permission_response(params)
316
+ return web.json_response({
317
+ "jsonrpc": "2.0",
318
+ "id": request_id,
319
+ "result": result
320
+ })
321
+
322
+ elif method == "ping":
323
+ return web.json_response({
324
+ "jsonrpc": "2.0",
325
+ "id": request_id,
326
+ "result": {"status": "alive"}
327
+ })
328
+
329
+ else:
330
+ return web.json_response({
331
+ "jsonrpc": "2.0",
332
+ "error": {"code": -32601, "message": "Method not found"},
333
+ "id": request_id
334
+ }, status=404)
335
+
336
+ except Exception as e:
337
+ logger.error(f"Error handling {method}: {e}", exc_info=True)
338
+ return web.json_response({
339
+ "jsonrpc": "2.0",
340
+ "error": {"code": -32603, "message": str(e)},
341
+ "id": request_id
342
+ }, status=500)
343
+
344
+ async def create_session(self, params: dict[str, Any]) -> dict[str, Any]:
345
+ """Create a new session."""
346
+ provider_name = params.get("provider")
347
+ provider = None
348
+
349
+ if provider_name and provider_name in self.providers:
350
+ provider = self.providers[provider_name]
351
+
352
+ session = self.session_manager.create_session(provider=provider)
353
+
354
+ # Get context limits from config
355
+ context_limit = self.config.context.default_limit if self.config.context else 200000
356
+
357
+ # Get sessions directory path for TUI logging
358
+ sessions_dir = get_data_dir() / "sessions"
359
+ sessions_dir.mkdir(parents=True, exist_ok=True)
360
+
361
+ return {
362
+ "session_id": session.id,
363
+ "provider": session.provider.__class__.__name__,
364
+ "context_limit": context_limit,
365
+ "max_input_length": self._max_input_length,
366
+ "sessions_dir": str(sessions_dir)
367
+ }
368
+
369
+ async def _send_permission_request(self, request_id: str, permission_request) -> None:
370
+ """Send permission request to TUI via stream.
371
+
372
+ Args:
373
+ request_id: Unique request ID
374
+ permission_request: PermissionRequest object
375
+ """
376
+ if not self._permission_stream:
377
+ logger.error("No active permission stream")
378
+ return
379
+
380
+ import json
381
+ event = {
382
+ "type": "permission_request",
383
+ "request_id": request_id,
384
+ "operation": permission_request.operation,
385
+ "path": permission_request.path,
386
+ "reason": permission_request.reason,
387
+ "details": permission_request.details or {}
388
+ }
389
+
390
+ await self._permission_stream.write(
391
+ (json.dumps(event) + "\n").encode("utf-8")
392
+ )
393
+
394
+ def _validate_api_key(self, request: web.Request) -> bool:
395
+ """
396
+ Validate API key from request Authorization header.
397
+
398
+ Args:
399
+ request: aiohttp request object
400
+
401
+ Returns:
402
+ True if API key is valid, False otherwise
403
+ """
404
+ # Get Authorization header
405
+ auth_header = request.headers.get("Authorization", "")
406
+
407
+ # Check Bearer token format
408
+ if not auth_header.startswith("Bearer "):
409
+ return False
410
+
411
+ # Extract token
412
+ provided_key = auth_header[7:] # Remove "Bearer " prefix
413
+
414
+ # If no API key configured, allow all requests (backward compatibility)
415
+ if not self.api_key:
416
+ return True
417
+
418
+ # Constant-time comparison to prevent timing attacks
419
+ import hmac
420
+ return hmac.compare_digest(provided_key, self.api_key)
421
+
422
+ def _sanitize_user_input(self, user_input: str) -> tuple[str, str | None]:
423
+ """
424
+ Sanitize and validate user input.
425
+
426
+ Args:
427
+ user_input: Raw user input string
428
+
429
+ Returns:
430
+ Tuple of (sanitized_input, error_message)
431
+ If error_message is not None, input is invalid
432
+ """
433
+ # Length validation (from config, -1 = unlimited)
434
+ if self._max_input_length > 0 and len(user_input) > self._max_input_length:
435
+ return "", f"Input exceeds maximum length of {self._max_input_length} characters"
436
+
437
+ # Remove null bytes (security risk)
438
+ if "\x00" in user_input:
439
+ return "", "Input contains null bytes"
440
+
441
+ # Remove other control characters except newlines/tabs
442
+ sanitized = "".join(
443
+ char for char in user_input
444
+ if char == "\n" or char == "\t" or (ord(char) >= 32 and ord(char) != 127)
445
+ )
446
+
447
+ # Detect potential prompt injection patterns
448
+ suspicious_patterns = [
449
+ "ignore previous instructions",
450
+ "disregard all",
451
+ "new instructions:",
452
+ "system:",
453
+ "<|endoftext|>",
454
+ "<|im_start|>",
455
+ "<|im_end|>",
456
+ ]
457
+
458
+ lower_input = sanitized.lower()
459
+ for pattern in suspicious_patterns:
460
+ if pattern in lower_input:
461
+ logger.warning(f"Suspicious input pattern detected: {pattern}")
462
+ # Don't block, just log - could be legitimate discussion
463
+
464
+ return sanitized, None
465
+
466
+ def _check_rate_limit(self, session_id: str) -> bool:
467
+ """
468
+ Check if request is within rate limit for session.
469
+
470
+ Args:
471
+ session_id: Session identifier
472
+
473
+ Returns:
474
+ True if within rate limit, False if exceeded
475
+ """
476
+ # Skip if rate limiting disabled
477
+ if not self._rate_limit_enabled:
478
+ return True
479
+
480
+ now = time.time()
481
+
482
+ # Get request history for this session
483
+ requests = self._rate_limit_requests[session_id]
484
+
485
+ # Remove requests outside the time window
486
+ requests[:] = [req_time for req_time in requests if now - req_time < self._rate_limit_window]
487
+
488
+ # Check if limit exceeded
489
+ if len(requests) >= self._rate_limit_max_requests:
490
+ logger.warning(
491
+ f"Rate limit exceeded for session {session_id}: "
492
+ f"{len(requests)} requests in {self._rate_limit_window}s"
493
+ )
494
+ return False
495
+
496
+ # Add current request
497
+ requests.append(now)
498
+ return True
499
+
500
+ async def process_turn(
501
+ self,
502
+ request: web.Request,
503
+ params: dict[str, Any],
504
+ request_id: Any
505
+ ) -> web.StreamResponse:
506
+ """Process conversation turn with streaming."""
507
+ session_id = params.get("session_id")
508
+ user_input = params.get("input")
509
+ tools = params.get("tools")
510
+
511
+ if not session_id or not user_input:
512
+ return web.json_response({
513
+ "jsonrpc": "2.0",
514
+ "error": {"code": -32602, "message": "Invalid params"},
515
+ "id": request_id
516
+ }, status=400)
517
+
518
+ # Security: check rate limit
519
+ if not self._check_rate_limit(session_id):
520
+ return web.json_response({
521
+ "jsonrpc": "2.0",
522
+ "error": {"code": -32602, "message": "Rate limit exceeded. Please wait before sending more requests."},
523
+ "id": request_id
524
+ }, status=429)
525
+
526
+ # Security: sanitize user input
527
+ sanitized_input, error = self._sanitize_user_input(user_input)
528
+ if error:
529
+ logger.warning(f"Input validation failed: {error}")
530
+ return web.json_response({
531
+ "jsonrpc": "2.0",
532
+ "error": {"code": -32602, "message": f"Invalid input: {error}"},
533
+ "id": request_id
534
+ }, status=400)
535
+
536
+ # Use sanitized input instead of raw input
537
+ user_input = sanitized_input
538
+
539
+ # Stream response as newline-delimited JSON
540
+ response = web.StreamResponse()
541
+ response.headers['Content-Type'] = 'application/x-ndjson'
542
+ await response.prepare(request)
543
+
544
+ # Set permission stream for this request
545
+ self._permission_stream = response
546
+
547
+ try:
548
+ async for event in self.session_manager.process_turn(
549
+ session_id=session_id,
550
+ user_input=user_input,
551
+ tools=tools
552
+ ):
553
+ # Send event as NDJSON
554
+ line = json.dumps(event.to_dict()) + "\n"
555
+ try:
556
+ await response.write(line.encode())
557
+ # aiohttp auto-flushes after each write
558
+ except (ConnectionResetError, ConnectionError) as e:
559
+ logger.info(f"Client disconnected during streaming: {e}")
560
+ break # Stop streaming, client is gone
561
+
562
+ except Exception as e:
563
+ logger.error(f"Error processing turn: {e}", exc_info=True)
564
+ error_event = {"type": "error", "data": {"message": str(e)}}
565
+ try:
566
+ await response.write((json.dumps(error_event) + "\n").encode())
567
+ except (ConnectionResetError, ConnectionError):
568
+ logger.info("Client disconnected, cannot send error event")
569
+
570
+ finally:
571
+ # Clear permission stream
572
+ self._permission_stream = None
573
+
574
+ await response.write_eof()
575
+ return response
576
+
577
+ async def get_baseline(self, params: dict[str, Any]) -> dict[str, Any]:
578
+ """Get baseline for session."""
579
+ session_id = params.get("session_id")
580
+
581
+ if not session_id:
582
+ raise ValueError("session_id required")
583
+
584
+ baseline = self.session_manager.get_baseline(session_id)
585
+
586
+ if baseline:
587
+ return {
588
+ "code": baseline.code,
589
+ "file_path": baseline.file_path,
590
+ "language": baseline.language
591
+ }
592
+
593
+ return {"code": None}
594
+
595
+ async def get_stats(self, params: dict[str, Any]) -> dict[str, Any]:
596
+ """Get conversation statistics."""
597
+ session_id = params.get("session_id")
598
+
599
+ if not session_id:
600
+ raise ValueError("session_id required")
601
+
602
+ return self.session_manager.get_context_stats(session_id)
603
+
604
+ async def get_history_metrics(self, params: dict[str, Any]) -> dict[str, Any]:
605
+ """Get historical learning metrics from fuzzing orchestrator."""
606
+ if not self.fuzzing_orchestrator or not self.fuzzing_orchestrator.history_db:
607
+ return {}
608
+
609
+ # Get stats from history DB
610
+ stats = self.fuzzing_orchestrator.get_history_stats()
611
+
612
+ # Get cache stats if available
613
+ cache_stats = {}
614
+ if hasattr(self.fuzzing_orchestrator, 'context_engine') and \
615
+ hasattr(self.fuzzing_orchestrator.context_engine, 'oracle_cache'):
616
+ cache = self.fuzzing_orchestrator.context_engine.oracle_cache
617
+ cache_stats = {
618
+ "size": cache.size,
619
+ "hits": cache.hits,
620
+ "misses": cache.misses,
621
+ "hit_rate": cache.hit_rate,
622
+ }
623
+
624
+ # Get vector store size if available
625
+ vector_store_size = 0
626
+ if hasattr(self.fuzzing_orchestrator, 'context_engine') and \
627
+ hasattr(self.fuzzing_orchestrator.context_engine, 'vector_store'):
628
+ vector_store_size = self.fuzzing_orchestrator.context_engine.vector_store.size
629
+
630
+ return {
631
+ **stats,
632
+ "cache_stats": cache_stats,
633
+ "vector_store_size": vector_store_size,
634
+ }
635
+
636
+ async def compact_session(self, params: dict[str, Any]) -> dict[str, Any]:
637
+ """Compact conversation history."""
638
+ session_id = params.get("session_id")
639
+
640
+ if not session_id:
641
+ raise ValueError("session_id required")
642
+
643
+ self.session_manager.compact_conversation(session_id)
644
+
645
+ return {"success": True}
646
+
647
+ async def clear_session(self, params: dict[str, Any]) -> dict[str, Any]:
648
+ """Clear conversation history."""
649
+ session_id = params.get("session_id")
650
+
651
+ if not session_id:
652
+ raise ValueError("session_id required")
653
+
654
+ self.session_manager.clear_conversation(session_id)
655
+
656
+ return {"success": True}
657
+
658
+ async def get_history_metrics(self, params: dict[str, Any]) -> dict[str, Any]:
659
+ """Get historical learning metrics from HistoryDB.
660
+
661
+ Returns:
662
+ Dictionary containing historical fuzzing metrics
663
+ """
664
+ # Check if fuzzing orchestrator and history DB are available
665
+ if not self.fuzzing_orchestrator:
666
+ return {
667
+ "error": "Fuzzing not enabled",
668
+ "total_sessions": 0
669
+ }
670
+
671
+ if not self.fuzzing_orchestrator.history_db:
672
+ return {
673
+ "error": "History database not available",
674
+ "total_sessions": 0
675
+ }
676
+
677
+ # Get stats from history database
678
+ try:
679
+ stats = self.fuzzing_orchestrator.history_db.get_stats()
680
+ return stats
681
+ except Exception as e:
682
+ logger.error(f"Failed to get history metrics: {e}")
683
+ return {
684
+ "error": f"Failed to get metrics: {str(e)}",
685
+ "total_sessions": 0
686
+ }
687
+
688
+ async def handle_permission_response(self, params: dict[str, Any]) -> dict[str, Any]:
689
+ """Handle permission response from TUI.
690
+
691
+ Args:
692
+ params: {request_id: str, approved: bool}
693
+
694
+ Returns:
695
+ Success status
696
+ """
697
+ request_id = params.get("request_id")
698
+ approved = params.get("approved", False)
699
+
700
+ if not request_id:
701
+ raise ValueError("request_id required")
702
+
703
+ # Forward to permission manager
704
+ from .permissions import get_permission_manager
705
+
706
+ manager = get_permission_manager()
707
+ if manager:
708
+ manager.handle_permission_response(request_id, approved)
709
+ else:
710
+ logger.warning(f"No permission manager to handle response for {request_id}")
711
+
712
+ return {"success": True}
713
+
714
+ async def call_tool(self, params: dict[str, Any]) -> dict[str, Any]:
715
+ """Call a tool directly."""
716
+ session_id = params.get("session_id")
717
+ tool_name = params.get("tool_name")
718
+ tool_input = params.get("tool_input", {})
719
+
720
+ if not session_id:
721
+ raise ValueError("session_id required")
722
+ if not tool_name:
723
+ raise ValueError("tool_name required")
724
+
725
+ # Execute tool via tool executor
726
+ import uuid
727
+ call_id = str(uuid.uuid4())
728
+ result = await self.tool_executor.execute(tool_name, tool_input, call_id)
729
+
730
+ # Return the result from the tool call
731
+ if result.success:
732
+ return result.result
733
+ else:
734
+ raise ValueError(result.error or "Tool execution failed")
735
+
736
+ async def execute_workflow(
737
+ self,
738
+ params: dict[str, Any],
739
+ request: web.Request,
740
+ request_id: str | None
741
+ ) -> web.StreamResponse:
742
+ """
743
+ Execute multi-agent workflow with streaming events.
744
+
745
+ Args:
746
+ params: Request parameters containing 'intent'
747
+ request: HTTP request object
748
+ request_id: JSON-RPC request ID
749
+
750
+ Returns:
751
+ Streaming NDJSON response
752
+ """
753
+ user_intent = params.get("intent")
754
+ if not user_intent:
755
+ return web.json_response({
756
+ "jsonrpc": "2.0",
757
+ "error": {"code": -32602, "message": "intent parameter required"},
758
+ "id": request_id
759
+ }, status=400)
760
+
761
+ # Security: sanitize user intent
762
+ sanitized_intent, error = self._sanitize_user_input(user_intent)
763
+ if error:
764
+ logger.warning(f"Workflow intent validation failed: {error}")
765
+ return web.json_response({
766
+ "jsonrpc": "2.0",
767
+ "error": {"code": -32602, "message": f"Invalid intent: {error}"},
768
+ "id": request_id
769
+ }, status=400)
770
+
771
+ # Use sanitized intent
772
+ user_intent = sanitized_intent
773
+
774
+ # Create streaming response
775
+ response = web.StreamResponse()
776
+ response.headers['Content-Type'] = 'application/x-ndjson'
777
+ await response.prepare(request)
778
+
779
+ try:
780
+ # Import workflow components
781
+ from .agents.workflow import WorkflowOrchestrator
782
+ from .agents.registry import AgentRegistry
783
+
784
+ # Create agent registry
785
+ workspace_root = self.config.workspace_root or Path.cwd()
786
+ agent_registry = AgentRegistry(
787
+ workspace_root=workspace_root,
788
+ tool_registry=self.tool_registry
789
+ )
790
+
791
+ # Create workflow event callback
792
+ async def workflow_event_callback(event_type: str, data: dict):
793
+ event = {"type": event_type, "data": data}
794
+ line = json.dumps(event) + "\n"
795
+ await response.write(line.encode())
796
+
797
+ # Get provider
798
+ provider = self.providers.get("anthropic") or self.providers.get("openai")
799
+ if not provider:
800
+ raise ValueError("No provider available")
801
+
802
+ # Create orchestrator
803
+ orchestrator = WorkflowOrchestrator(
804
+ agent_registry=agent_registry,
805
+ storage_path=get_data_dir() / "agents",
806
+ provider=provider,
807
+ tool_registry=self.tool_registry,
808
+ event_callback=workflow_event_callback
809
+ )
810
+
811
+ # Execute workflow
812
+ result = await orchestrator.handle_user_request(user_intent)
813
+
814
+ # Send final result event
815
+ final_event = {"type": "workflow_complete", "data": result}
816
+ await response.write((json.dumps(final_event) + "\n").encode())
817
+
818
+ except Exception as e:
819
+ logger.error(f"Workflow execution failed: {e}", exc_info=True)
820
+ error_event = {"type": "workflow_error", "data": {"error": str(e)}}
821
+ await response.write((json.dumps(error_event) + "\n").encode())
822
+
823
+ await response.write_eof()
824
+ return response
825
+
826
+ async def serve_tech_debt_dashboard(self, request: web.Request) -> web.Response:
827
+ """
828
+ Serve tech debt dashboard HTML.
829
+
830
+ Args:
831
+ request: HTTP request
832
+
833
+ Returns:
834
+ HTML response with dashboard
835
+ """
836
+ try:
837
+ html = self.tech_debt_metrics.generate_dashboard_html()
838
+ return web.Response(text=html, content_type="text/html")
839
+ except Exception as e:
840
+ logger.error(f"Failed to generate dashboard: {e}", exc_info=True)
841
+ return web.Response(
842
+ text=f"<html><body><h1>Error generating dashboard</h1><p>{e}</p></body></html>",
843
+ content_type="text/html",
844
+ status=500
845
+ )
846
+
847
+ def run(self):
848
+ """Run the server."""
849
+ logger.info(f"Starting server on {self.config.server.host}:{self.config.server.port}")
850
+ web.run_app(
851
+ self.app,
852
+ host=self.config.server.host,
853
+ port=self.config.server.port,
854
+ print=None # Suppress default startup message
855
+ )
856
+
857
+
858
+ def main():
859
+ """Entry point for server."""
860
+ logging.basicConfig(
861
+ level=logging.INFO,
862
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
863
+ )
864
+
865
+ config = Config.load()
866
+ server = HarnessServer(config)
867
+ server.run()
868
+
869
+
870
+ if __name__ == "__main__":
871
+ main()