loki-mode 6.62.1 → 6.63.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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.62.1
6
+ # Loki Mode v6.63.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.62.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.63.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.62.1
1
+ 6.63.0
package/autonomy/run.sh CHANGED
@@ -3517,10 +3517,66 @@ track_iteration_start() {
3517
3517
  "provider=${PROVIDER_NAME:-claude}"
3518
3518
  fi
3519
3519
 
3520
+ # Read next pending task for context (enrich iteration with PRD task details)
3521
+ local next_task_context=""
3522
+ if [[ -f ".loki/queue/pending.json" ]]; then
3523
+ next_task_context=$(python3 -c "
3524
+ import json
3525
+ try:
3526
+ with open('.loki/queue/pending.json') as f:
3527
+ tasks = json.load(f)
3528
+ if isinstance(tasks, dict):
3529
+ tasks = tasks.get('tasks', [])
3530
+ pending = [t for t in tasks if isinstance(t, dict) and t.get('status','pending') == 'pending']
3531
+ if pending:
3532
+ t = pending[0]
3533
+ print(json.dumps({
3534
+ 'current_task': t.get('title',''),
3535
+ 'description': t.get('description',''),
3536
+ 'acceptance_criteria': t.get('acceptance_criteria', []),
3537
+ 'user_story': t.get('user_story', ''),
3538
+ 'source': t.get('source', ''),
3539
+ 'project': t.get('project', '')
3540
+ }))
3541
+ except: pass
3542
+ " 2>/dev/null || true)
3543
+ fi
3544
+
3520
3545
  # Create task entry (escape PRD path for safe JSON embedding)
3521
3546
  local prd_escaped
3522
3547
  prd_escaped=$(printf '%s' "${prd:-Codebase Analysis}" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
3523
- local task_json=$(cat <<EOF
3548
+
3549
+ # Build enriched task JSON with pending task context
3550
+ local task_json
3551
+ if [[ -n "$next_task_context" ]]; then
3552
+ task_json=$(python3 -c "
3553
+ import json, sys
3554
+ ctx = json.loads('''$next_task_context''')
3555
+ task = {
3556
+ 'id': 'iteration-$iteration',
3557
+ 'type': 'iteration',
3558
+ 'title': ctx.get('current_task') or 'Iteration $iteration',
3559
+ 'description': ctx.get('description') or 'PRD: ${prd_escaped}',
3560
+ 'status': 'in_progress',
3561
+ 'priority': 'medium',
3562
+ 'startedAt': '$(date -u +%Y-%m-%dT%H:%M:%SZ)',
3563
+ 'provider': '${PROVIDER_NAME:-claude}'
3564
+ }
3565
+ if ctx.get('acceptance_criteria'):
3566
+ task['acceptance_criteria'] = ctx['acceptance_criteria']
3567
+ if ctx.get('user_story'):
3568
+ task['user_story'] = ctx['user_story']
3569
+ if ctx.get('source'):
3570
+ task['source'] = ctx['source']
3571
+ if ctx.get('project'):
3572
+ task['project'] = ctx['project']
3573
+ print(json.dumps(task, indent=2))
3574
+ " 2>/dev/null)
3575
+ fi
3576
+
3577
+ # Fallback to basic task JSON if enrichment failed
3578
+ if [[ -z "$task_json" ]]; then
3579
+ task_json=$(cat <<EOF
3524
3580
  {
3525
3581
  "id": "$task_id",
3526
3582
  "type": "iteration",
@@ -3533,6 +3589,7 @@ track_iteration_start() {
3533
3589
  }
3534
3590
  EOF
3535
3591
  )
3592
+ fi
3536
3593
 
3537
3594
  # Add to in-progress queue
3538
3595
  # BUG-XC-003: Use flock for atomic queue modification
@@ -7906,11 +7963,12 @@ load_state() {
7906
7963
 
7907
7964
  # Load tasks from queue files for prompt injection
7908
7965
  # Supports both array format [...] and object format {"tasks": [...]}
7966
+ # Enhanced in v6.63.0 to include rich task details (description, acceptance criteria, user stories)
7909
7967
  load_queue_tasks() {
7910
7968
  local task_injection=""
7911
7969
 
7912
- # Helper Python script to extract and format tasks
7913
- # Handles both formats, truncates long actions, normalizes newlines
7970
+ # Helper Python script to extract and format tasks with rich details
7971
+ # Handles both formats, includes description, acceptance criteria, and user stories
7914
7972
  local extract_script='
7915
7973
  import json
7916
7974
  import sys
@@ -7928,23 +7986,45 @@ def extract_tasks(filepath, prefix):
7928
7986
  if not isinstance(task, dict):
7929
7987
  continue
7930
7988
  task_id = task.get("id") or "unknown"
7931
- task_type = task.get("type") or "unknown"
7932
- payload = task.get("payload", {})
7933
-
7934
- # Extract action from payload
7935
- if isinstance(payload, dict):
7936
- action = payload.get("action") or payload.get("goal") or ""
7989
+ source = task.get("source", "")
7990
+
7991
+ # Rich PRD-sourced tasks (v6.63.0)
7992
+ if source == "prd" or task_id.startswith("prd-"):
7993
+ title = task.get("title", "Task")
7994
+ lines = [f"{prefix}[{i+1}] {task_id}: {title}"]
7995
+ desc = task.get("description", "")
7996
+ if desc and desc != title:
7997
+ # First 300 chars of description, normalized
7998
+ desc_short = desc.replace("\n", " ").replace("\r", "")[:300]
7999
+ if len(desc) > 300:
8000
+ desc_short += "..."
8001
+ lines.append(f" Description: {desc_short}")
8002
+ criteria = task.get("acceptance_criteria", [])
8003
+ if criteria:
8004
+ criteria_str = "; ".join(str(c) for c in criteria[:5])
8005
+ lines.append(f" Acceptance: {criteria_str}")
8006
+ story = task.get("user_story", "")
8007
+ if story:
8008
+ lines.append(f" User Story: {story}")
8009
+ results.append("\n".join(lines))
7937
8010
  else:
7938
- action = str(payload) if payload else ""
7939
-
7940
- # Normalize: remove newlines, truncate to 500 chars
7941
- action = str(action).replace("\n", " ").replace("\r", "")[:500]
7942
- if len(str(task.get("payload", {}).get("action", ""))) > 500:
7943
- action += "..."
7944
-
7945
- results.append(f"{prefix}[{i+1}] id={task_id} type={task_type}: {action}")
7946
-
7947
- return " ".join(results)
8011
+ # Legacy format: extract action from payload
8012
+ task_type = task.get("type") or "unknown"
8013
+ payload = task.get("payload", {})
8014
+ if isinstance(payload, dict):
8015
+ action = payload.get("action") or payload.get("goal") or ""
8016
+ else:
8017
+ action = str(payload) if payload else ""
8018
+ # Also check top-level title/description for non-payload tasks
8019
+ if not action:
8020
+ action = task.get("title", task.get("description", ""))
8021
+ # Normalize: remove newlines, truncate to 500 chars
8022
+ action = str(action).replace("\n", " ").replace("\r", "")[:500]
8023
+ if len(str(action)) > 500:
8024
+ action += "..."
8025
+ results.append(f"{prefix}[{i+1}] id={task_id} type={task_type}: {action}")
8026
+
8027
+ return "\n".join(results)
7948
8028
  except:
7949
8029
  return ""
7950
8030
 
@@ -7954,11 +8034,11 @@ pending = extract_tasks(".loki/queue/pending.json", "PENDING")
7954
8034
 
7955
8035
  output = []
7956
8036
  if in_progress:
7957
- output.append(f"IN-PROGRESS TASKS (EXECUTE THESE): {in_progress}")
8037
+ output.append(f"IN-PROGRESS TASKS (EXECUTE THESE):\n{in_progress}")
7958
8038
  if pending:
7959
- output.append(f"PENDING: {pending}")
8039
+ output.append(f"PENDING:\n{pending}")
7960
8040
 
7961
- print(" | ".join(output))
8041
+ print("\n---\n".join(output))
7962
8042
  '
7963
8043
 
7964
8044
  # First check in-progress tasks (highest priority)
@@ -8700,6 +8780,212 @@ MIROFISH_QUEUE_EOF
8700
8780
  log_info "MiroFish queue population complete"
8701
8781
  }
8702
8782
 
8783
+ # Populate task queue from plain PRD markdown (if no adapter populated tasks)
8784
+ # Extracts features/requirements from markdown structure into rich task entries
8785
+ populate_prd_queue() {
8786
+ local prd_file="${1:-}"
8787
+ if [[ -z "$prd_file" ]] || [[ ! -f "$prd_file" ]]; then
8788
+ return 0
8789
+ fi
8790
+ # Skip if already populated
8791
+ if [[ -f ".loki/queue/.prd-populated" ]]; then
8792
+ return 0
8793
+ fi
8794
+ # Skip if OpenSpec, BMAD, or MiroFish already populated tasks
8795
+ if [[ -f ".loki/queue/.openspec-populated" ]] || [[ -f ".loki/queue/.bmad-populated" ]] || [[ -f ".loki/queue/.mirofish-populated" ]]; then
8796
+ log_info "Task queue already populated by adapter, skipping PRD parsing"
8797
+ return 0
8798
+ fi
8799
+
8800
+ log_step "Parsing PRD into structured tasks..."
8801
+ mkdir -p ".loki/queue"
8802
+
8803
+ LOKI_PRD_FILE="$prd_file" python3 << 'PRD_PARSE_EOF'
8804
+ import json, re, os, sys
8805
+
8806
+ prd_path = os.environ.get("LOKI_PRD_FILE", "")
8807
+ if not prd_path or not os.path.isfile(prd_path):
8808
+ sys.exit(0)
8809
+
8810
+ with open(prd_path, "r", errors="replace") as f:
8811
+ content = f.read()
8812
+
8813
+ # Parse PRD structure
8814
+ sections = {}
8815
+ current_section = "Overview"
8816
+ current_content = []
8817
+
8818
+ for line in content.split("\n"):
8819
+ heading_match = re.match(r'^#{1,3}\s+(.+)', line)
8820
+ if heading_match:
8821
+ if current_content:
8822
+ sections[current_section] = "\n".join(current_content).strip()
8823
+ current_section = heading_match.group(1).strip()
8824
+ current_content = []
8825
+ else:
8826
+ current_content.append(line)
8827
+ if current_content:
8828
+ sections[current_section] = "\n".join(current_content).strip()
8829
+
8830
+ # Extract project name from first H1
8831
+ project_name = "Project"
8832
+ for line in content.split("\n"):
8833
+ m = re.match(r'^#\s+(.+)', line)
8834
+ if m:
8835
+ project_name = m.group(1).strip()
8836
+ break
8837
+
8838
+ # Find feature/requirement sections
8839
+ feature_keywords = [
8840
+ "features", "requirements", "key features", "core features",
8841
+ "functional requirements", "user stories", "deliverables",
8842
+ "scope", "functionality", "capabilities", "modules"
8843
+ ]
8844
+
8845
+ # Extract features from bullet points in feature sections
8846
+ features = []
8847
+ for section_name, section_content in sections.items():
8848
+ is_feature_section = any(kw in section_name.lower() for kw in feature_keywords)
8849
+ if is_feature_section:
8850
+ # Extract numbered items or bullet points
8851
+ for line in section_content.split("\n"):
8852
+ line = line.strip()
8853
+ # Match: "1. Feature name" or "- Feature name" or "* Feature name"
8854
+ m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+)(.+)', line)
8855
+ if m:
8856
+ feature_text = m.group(1).strip()
8857
+ if len(feature_text) > 10: # Skip very short lines
8858
+ features.append({
8859
+ "title": feature_text,
8860
+ "section": section_name,
8861
+ })
8862
+
8863
+ # If no bullet features found, extract from ## headings that look like features
8864
+ if not features:
8865
+ skip_sections = {"overview", "introduction", "summary", "target audience",
8866
+ "tech stack", "technology", "deployment", "timeline",
8867
+ "out of scope", "non-functional", "appendix", "references",
8868
+ "problem statement", "value proposition", "background"}
8869
+ for section_name, section_content in sections.items():
8870
+ if section_name.lower() not in skip_sections and len(section_content) > 20:
8871
+ features.append({
8872
+ "title": section_name,
8873
+ "section": "Requirements",
8874
+ })
8875
+
8876
+ if not features:
8877
+ print("No features extracted from PRD", file=sys.stderr)
8878
+ sys.exit(0)
8879
+
8880
+ # Build acceptance criteria from section content
8881
+ def extract_acceptance_criteria(section_name, sections):
8882
+ """Extract testable criteria from section content."""
8883
+ criteria = []
8884
+ content = sections.get(section_name, "")
8885
+ for line in content.split("\n"):
8886
+ line = line.strip()
8887
+ if line.startswith(("- ", "* ", " - ", " * ")):
8888
+ text = re.sub(r'^[\-\*]\s+', '', line).strip()
8889
+ if len(text) > 5:
8890
+ criteria.append(text)
8891
+ # Also check for acceptance criteria section
8892
+ for key in ["acceptance criteria", "success criteria", "definition of done"]:
8893
+ for sname, scontent in sections.items():
8894
+ if key in sname.lower():
8895
+ for cline in scontent.split("\n"):
8896
+ cline = cline.strip()
8897
+ m = re.match(r'^(?:\d+[\.\)]\s*|\-\s+|\*\s+|\[.\]\s*)(.+)', cline)
8898
+ if m:
8899
+ criteria.append(m.group(1).strip())
8900
+ return criteria[:10] # Cap at 10
8901
+
8902
+ # Determine priority based on position (earlier = higher)
8903
+ def get_priority(index, total):
8904
+ if total <= 3:
8905
+ return "high"
8906
+ third = total / 3
8907
+ if index < third:
8908
+ return "high"
8909
+ elif index < 2 * third:
8910
+ return "medium"
8911
+ return "low"
8912
+
8913
+ # Build task queue entries
8914
+ pending_path = ".loki/queue/pending.json"
8915
+ existing = []
8916
+ if os.path.exists(pending_path):
8917
+ try:
8918
+ with open(pending_path, "r") as f:
8919
+ data = json.load(f)
8920
+ if isinstance(data, list):
8921
+ existing = data
8922
+ elif isinstance(data, dict) and "tasks" in data:
8923
+ existing = data["tasks"]
8924
+ except (json.JSONDecodeError, FileNotFoundError):
8925
+ existing = []
8926
+
8927
+ existing_ids = {t.get("id") for t in existing if isinstance(t, dict)}
8928
+ added = 0
8929
+
8930
+ for i, feat in enumerate(features):
8931
+ task_id = f"prd-{i+1:03d}"
8932
+ if task_id in existing_ids:
8933
+ continue
8934
+
8935
+ criteria = extract_acceptance_criteria(feat["section"], sections)
8936
+
8937
+ # Build a rich description
8938
+ section_content = sections.get(feat["section"], "")
8939
+ desc_parts = [feat["title"]]
8940
+ if section_content and len(section_content) > len(feat["title"]):
8941
+ # Include relevant context (first 500 chars of section)
8942
+ desc_parts.append(section_content[:500])
8943
+
8944
+ task = {
8945
+ "id": task_id,
8946
+ "title": feat["title"],
8947
+ "description": "\n".join(desc_parts),
8948
+ "priority": get_priority(i, len(features)),
8949
+ "status": "pending",
8950
+ "source": "prd",
8951
+ "project": project_name,
8952
+ }
8953
+
8954
+ if criteria:
8955
+ task["acceptance_criteria"] = criteria
8956
+
8957
+ # Add user story format
8958
+ # Try to extract target audience for user story
8959
+ audience = "a user"
8960
+ for key in ["target audience", "users", "user personas", "audience"]:
8961
+ for sname in sections:
8962
+ if key in sname.lower():
8963
+ # Extract first line
8964
+ first_line = sections[sname].split("\n")[0].strip()
8965
+ if first_line:
8966
+ audience = first_line[:100]
8967
+ break
8968
+
8969
+ task["user_story"] = f"As {audience}, I want to {feat['title'].lower().rstrip('.')}, so that the product delivers its core value."
8970
+
8971
+ existing.append(task)
8972
+ added += 1
8973
+
8974
+ with open(pending_path, "w") as f:
8975
+ json.dump(existing, f, indent=2)
8976
+
8977
+ print(f"Extracted {added} tasks from PRD ({len(features)} features found)", file=sys.stderr)
8978
+ PRD_PARSE_EOF
8979
+
8980
+ if [[ $? -ne 0 ]]; then
8981
+ log_warn "Failed to parse PRD into tasks"
8982
+ return 0
8983
+ fi
8984
+
8985
+ touch ".loki/queue/.prd-populated"
8986
+ log_info "PRD task parsing complete"
8987
+ }
8988
+
8703
8989
  #===============================================================================
8704
8990
  # Main Autonomous Loop
8705
8991
  #===============================================================================
@@ -8812,6 +9098,9 @@ run_autonomous() {
8812
9098
  # Populate task queue from MiroFish advisory (if present, runs once)
8813
9099
  populate_mirofish_queue
8814
9100
 
9101
+ # Populate task queue from PRD (if no adapters already populated, runs once)
9102
+ populate_prd_queue "$prd_path"
9103
+
8815
9104
  # Check max iterations before starting
8816
9105
  if check_max_iterations; then
8817
9106
  log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.62.1"
10
+ __version__ = "6.63.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1085,7 +1085,7 @@ async def list_tasks(
1085
1085
  # Skip if already in all_tasks
1086
1086
  if any(t["id"] == tid for t in all_tasks):
1087
1087
  continue
1088
- all_tasks.append({
1088
+ task_entry = {
1089
1089
  "id": tid,
1090
1090
  "title": item.get("title", item.get("action", "Task")),
1091
1091
  "description": item.get("description", ""),
@@ -1093,7 +1093,16 @@ async def list_tasks(
1093
1093
  "priority": item.get("priority", "medium"),
1094
1094
  "type": item.get("type", "task"),
1095
1095
  "position": i,
1096
- })
1096
+ }
1097
+ if item.get("acceptance_criteria"):
1098
+ task_entry["acceptance_criteria"] = item["acceptance_criteria"]
1099
+ if item.get("user_story"):
1100
+ task_entry["user_story"] = item["user_story"]
1101
+ if item.get("project"):
1102
+ task_entry["project"] = item["project"]
1103
+ if item.get("source"):
1104
+ task_entry["source"] = item["source"]
1105
+ all_tasks.append(task_entry)
1097
1106
  except (json.JSONDecodeError, KeyError):
1098
1107
  pass
1099
1108
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.62.1
5
+ **Version:** v6.63.0
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.62.1'
60
+ __version__ = '6.63.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.62.1",
3
+ "version": "6.63.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
package/web-app/server.py CHANGED
@@ -627,25 +627,32 @@ class DevServerManager:
627
627
  subprocess.run(["docker", "--version"], capture_output=True, timeout=5)
628
628
  except (FileNotFoundError, subprocess.TimeoutExpired):
629
629
  break # Docker not installed -- fall through to other detection
630
- # Parse compose file to detect exposed port
630
+ # Parse compose file to detect exposed port and enumerate services
631
631
  port = 3000 # default
632
+ services_info: list = []
632
633
  try:
633
634
  import yaml
634
635
  with open(root / compose_file) as f:
635
636
  compose = yaml.safe_load(f)
636
637
  if compose and "services" in compose:
637
- for svc in compose["services"].values():
638
- ports = svc.get("ports", [])
639
- for p in ports:
638
+ for svc_name, svc in compose["services"].items():
639
+ svc_ports: list = []
640
+ for p in svc.get("ports", []):
640
641
  p_str = str(p)
641
642
  if ":" in p_str:
642
- # Handle IP:host:container (e.g. "127.0.0.1:8080:80")
643
- # and host:container (e.g. "8080:80")
644
643
  parts = p_str.split(":")
645
- host_port = parts[-2] # second-to-last is always host port
646
- port = int(host_port)
647
- break
648
- if port != 3000:
644
+ host_port = int(parts[-2])
645
+ svc_ports.append(host_port)
646
+ services_info.append({
647
+ "name": svc_name,
648
+ "ports": svc_ports,
649
+ "image": svc.get("image"),
650
+ "has_build": "build" in svc,
651
+ })
652
+ # Use the first exposed host port as the primary port
653
+ for svc_entry in services_info:
654
+ if svc_entry["ports"]:
655
+ port = svc_entry["ports"][0]
649
656
  break
650
657
  except ImportError:
651
658
  # yaml not available -- fall back to regex parsing
@@ -654,11 +661,149 @@ class DevServerManager:
654
661
  port_match = re.search(r'"?(\d+):(\d+)"?', content)
655
662
  if port_match:
656
663
  port = int(port_match.group(1))
664
+ # Extract service names via regex
665
+ for m in re.finditer(r'^ (\w[\w-]*):\s*$', content, re.MULTILINE):
666
+ services_info.append({"name": m.group(1), "ports": []})
657
667
  except Exception:
658
668
  pass
659
669
  except Exception:
660
670
  pass
661
- return {"command": f"docker compose -f {compose_file} up --build", "expected_port": port, "framework": "docker"}
671
+ result_dict: dict = {
672
+ "command": f"docker compose -f {compose_file} up --build",
673
+ "expected_port": port,
674
+ "framework": "docker",
675
+ }
676
+ if services_info:
677
+ result_dict["services"] = services_info
678
+ return result_dict
679
+
680
+ # -- Full-stack project detection (frontend + backend in subdirectories) --
681
+ frontend_dir_names = ["frontend", "client", "web", "app", "ui", "web-app", "webapp"]
682
+ backend_dir_names = ["backend", "server", "api", "service"]
683
+
684
+ frontend_dir: Optional[Path] = None
685
+ backend_dir: Optional[Path] = None
686
+
687
+ for d in frontend_dir_names:
688
+ candidate = root / d
689
+ if candidate.is_dir():
690
+ # Verify it is actually a frontend (has package.json or index.html)
691
+ if (candidate / "package.json").exists() or (candidate / "index.html").exists():
692
+ frontend_dir = candidate
693
+ break
694
+
695
+ for d in backend_dir_names:
696
+ candidate = root / d
697
+ if candidate.is_dir():
698
+ has_py = any(candidate.glob("*.py"))
699
+ has_pkg = (candidate / "package.json").exists()
700
+ has_go = (candidate / "go.mod").exists()
701
+ has_requirements = (candidate / "requirements.txt").exists()
702
+ has_cargo = (candidate / "Cargo.toml").exists()
703
+ if has_py or has_pkg or has_go or has_requirements or has_cargo:
704
+ backend_dir = candidate
705
+ break
706
+
707
+ if frontend_dir and backend_dir:
708
+ # Detect frontend framework and command
709
+ fe_cmd = "npm run dev"
710
+ fe_port = 3000
711
+ fe_framework = "node"
712
+ fe_pkg = frontend_dir / "package.json"
713
+ if fe_pkg.exists():
714
+ try:
715
+ pkg = json.loads(fe_pkg.read_text(errors="replace"))
716
+ fe_scripts = pkg.get("scripts", {})
717
+ fe_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
718
+ if "next" in fe_deps:
719
+ fe_framework = "next"
720
+ fe_port = 3000
721
+ fe_cmd = "npm run dev"
722
+ elif "vite" in fe_deps:
723
+ fe_framework = "vite"
724
+ fe_port = 5173
725
+ fe_cmd = "npm run dev"
726
+ elif "dev" in fe_scripts:
727
+ fe_cmd = "npm run dev"
728
+ elif "start" in fe_scripts:
729
+ fe_cmd = "npm start"
730
+ except Exception:
731
+ pass
732
+ elif (frontend_dir / "index.html").exists():
733
+ fe_framework = "static"
734
+ fe_cmd = "python3 -m http.server 3000"
735
+ fe_port = 3000
736
+
737
+ # Detect backend framework and command
738
+ be_cmd: Optional[str] = None
739
+ be_port = 8000
740
+ be_framework = "unknown"
741
+
742
+ if (backend_dir / "manage.py").exists():
743
+ be_cmd = "python manage.py runserver"
744
+ be_port = 8000
745
+ be_framework = "django"
746
+ else:
747
+ for py_entry in ("app.py", "main.py", "server.py"):
748
+ py_file = backend_dir / py_entry
749
+ if py_file.exists():
750
+ try:
751
+ src = py_file.read_text(errors="replace")[:4096]
752
+ if "fastapi" in src.lower() or "FastAPI" in src:
753
+ module = py_entry[:-3]
754
+ be_cmd = f"uvicorn {module}:app --reload --port 8000"
755
+ be_port = 8000
756
+ be_framework = "fastapi"
757
+ break
758
+ if "flask" in src.lower() or "Flask" in src:
759
+ be_cmd = "flask run --port 5000"
760
+ be_port = 5000
761
+ be_framework = "flask"
762
+ break
763
+ except OSError:
764
+ pass
765
+ if be_cmd is None and (backend_dir / "package.json").exists():
766
+ try:
767
+ be_pkg = json.loads((backend_dir / "package.json").read_text(errors="replace"))
768
+ be_scripts = be_pkg.get("scripts", {})
769
+ be_deps = {**be_pkg.get("dependencies", {}), **be_pkg.get("devDependencies", {})}
770
+ if "express" in be_deps:
771
+ be_framework = "express"
772
+ else:
773
+ be_framework = "node"
774
+ be_port = 3001
775
+ if "dev" in be_scripts:
776
+ be_cmd = "npm run dev"
777
+ elif "start" in be_scripts:
778
+ be_cmd = "npm start"
779
+ except Exception:
780
+ pass
781
+ if be_cmd is None and (backend_dir / "go.mod").exists():
782
+ be_cmd = "go run ."
783
+ be_port = 8080
784
+ be_framework = "go"
785
+ if be_cmd is None and (backend_dir / "requirements.txt").exists():
786
+ # Generic Python backend with requirements.txt
787
+ for py_entry in ("app.py", "main.py", "server.py", "run.py"):
788
+ if (backend_dir / py_entry).exists():
789
+ be_cmd = f"python {py_entry}"
790
+ be_port = 8000
791
+ be_framework = "python"
792
+ break
793
+
794
+ if be_cmd:
795
+ return {
796
+ "framework": "full-stack",
797
+ "command": f"cd {backend_dir.name} && {be_cmd}",
798
+ "frontend_command": f"cd {frontend_dir.name} && {fe_cmd}",
799
+ "expected_port": fe_port,
800
+ "backend_port": be_port,
801
+ "multi_service": True,
802
+ "frontend_dir": str(frontend_dir),
803
+ "backend_dir": str(backend_dir),
804
+ "frontend_framework": fe_framework,
805
+ "backend_framework": be_framework,
806
+ }
662
807
 
663
808
  pkg_json = root / "package.json"
664
809
  if pkg_json.exists():
@@ -767,6 +912,36 @@ class DevServerManager:
767
912
  return port
768
913
  return None
769
914
 
915
+ def _install_pip_deps(self, project_path: Path, build_env: dict) -> None:
916
+ """Install pip dependencies into a project venv (creates one if needed)."""
917
+ if not (project_path / "requirements.txt").exists():
918
+ return
919
+ venv_dir = None
920
+ for venv_name in ("venv", ".venv", "env"):
921
+ candidate = project_path / venv_name
922
+ if candidate.is_dir() and (candidate / "bin" / "pip").exists():
923
+ venv_dir = candidate
924
+ break
925
+ if venv_dir is None:
926
+ try:
927
+ subprocess.run(
928
+ [sys.executable, "-m", "venv", str(project_path / "venv")],
929
+ capture_output=True, timeout=60,
930
+ )
931
+ venv_dir = project_path / "venv"
932
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
933
+ logger.warning("venv creation failed: %s", exc)
934
+ pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
935
+ try:
936
+ subprocess.run(
937
+ [pip_executable, "install", "-r", "requirements.txt"],
938
+ cwd=str(project_path),
939
+ capture_output=True,
940
+ timeout=120,
941
+ )
942
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
943
+ logger.warning("pip install failed: %s", exc)
944
+
770
945
  async def start(self, session_id: str, project_dir: str, command: Optional[str] = None) -> dict:
771
946
  """Start dev server. Auto-detect command if not provided."""
772
947
  if session_id in self.servers:
@@ -792,15 +967,177 @@ class DevServerManager:
792
967
  cmd_str = command or (detected["command"] if detected else "")
793
968
  expected_port = detected["expected_port"] if detected else 3000
794
969
  framework = detected["framework"] if detected else "unknown"
970
+ is_multi_service = detected.get("multi_service", False) if detected else False
971
+
972
+ build_env = {**os.environ}
973
+ build_env.update(_load_secrets())
974
+
975
+ # -- Multi-service full-stack startup --
976
+ if is_multi_service and not command:
977
+ be_dir = detected.get("backend_dir", actual_dir)
978
+ fe_dir = detected.get("frontend_dir", actual_dir)
979
+ be_cmd_str = detected.get("command", "")
980
+ fe_cmd_str = detected.get("frontend_command", "")
981
+ be_port = detected.get("backend_port", 8000)
982
+ fe_port = detected.get("expected_port", 3000)
983
+
984
+ # Install deps in both directories
985
+ for svc_dir_str in (be_dir, fe_dir):
986
+ svc_path = Path(svc_dir_str)
987
+ if (svc_path / "package.json").exists() and not (svc_path / "node_modules").exists():
988
+ try:
989
+ subprocess.run(["npm", "install"], cwd=svc_dir_str, capture_output=True, timeout=120, env=build_env)
990
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
991
+ logger.warning("npm install failed in %s: %s", svc_dir_str, exc)
992
+ if (svc_path / "requirements.txt").exists():
993
+ self._install_pip_deps(svc_path, build_env)
994
+
995
+ popen_kwargs = (
996
+ {"start_new_session": True} if sys.platform != "win32"
997
+ else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
998
+ )
999
+
1000
+ # Start backend process (uses shell=True because command contains 'cd ...')
1001
+ try:
1002
+ be_proc = subprocess.Popen(
1003
+ be_cmd_str, shell=True,
1004
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
1005
+ text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
1006
+ )
1007
+ except Exception as e:
1008
+ return {"status": "error", "message": f"Failed to start backend: {e}"}
1009
+ _track_child_pid(be_proc.pid)
1010
+
1011
+ # Start frontend process
1012
+ try:
1013
+ fe_proc = subprocess.Popen(
1014
+ fe_cmd_str, shell=True,
1015
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
1016
+ text=True, cwd=actual_dir, env=build_env, **popen_kwargs,
1017
+ )
1018
+ except Exception as e:
1019
+ # Kill backend if frontend fails to start
1020
+ try:
1021
+ be_proc.terminate()
1022
+ except (ProcessLookupError, PermissionError, OSError):
1023
+ pass
1024
+ _untrack_child_pid(be_proc.pid)
1025
+ return {"status": "error", "message": f"Failed to start frontend: {e}"}
1026
+ _track_child_pid(fe_proc.pid)
1027
+
1028
+ server_info: dict = {
1029
+ "process": fe_proc, # Primary process is frontend (user-facing)
1030
+ "backend_process": be_proc,
1031
+ "port": None,
1032
+ "expected_port": fe_port,
1033
+ "backend_port": be_port,
1034
+ "command": fe_cmd_str,
1035
+ "original_command": cmd_str,
1036
+ "framework": framework,
1037
+ "status": "starting",
1038
+ "pid": fe_proc.pid,
1039
+ "backend_pid": be_proc.pid,
1040
+ "project_dir": project_dir,
1041
+ "output_lines": [],
1042
+ "backend_output_lines": [],
1043
+ "multi_service": True,
1044
+ "frontend_framework": detected.get("frontend_framework", "unknown"),
1045
+ "backend_framework": detected.get("backend_framework", "unknown"),
1046
+ "frontend_dir": fe_dir,
1047
+ "backend_dir": be_dir,
1048
+ "use_portless": False,
1049
+ "portless_app_name": None,
1050
+ }
1051
+ self.servers[session_id] = server_info
1052
+
1053
+ asyncio.create_task(self._monitor_output(session_id))
1054
+ asyncio.create_task(self._monitor_backend_output(session_id))
1055
+
1056
+ # Wait for either frontend or backend port (up to 30s)
1057
+ for _ in range(60):
1058
+ await asyncio.sleep(0.5)
1059
+ info = self.servers.get(session_id)
1060
+ if not info:
1061
+ return {"status": "error", "message": "Server entry disappeared"}
1062
+ if info["status"] == "error":
1063
+ return {
1064
+ "status": "error",
1065
+ "message": "Dev server crashed",
1066
+ "output": info["output_lines"][-10:] if info["output_lines"] else [],
1067
+ }
1068
+ if info["port"] is not None:
1069
+ health_ok = await self._health_check(info["port"])
1070
+ if health_ok:
1071
+ info["status"] = "running"
1072
+ services = [
1073
+ {
1074
+ "name": "frontend",
1075
+ "framework": info.get("frontend_framework", "unknown"),
1076
+ "port": fe_port,
1077
+ "status": "running",
1078
+ },
1079
+ {
1080
+ "name": "backend",
1081
+ "framework": info.get("backend_framework", "unknown"),
1082
+ "port": be_port,
1083
+ "status": "running" if be_proc.poll() is None else "error",
1084
+ },
1085
+ ]
1086
+ return {
1087
+ "status": "running",
1088
+ "port": info["port"],
1089
+ "command": fe_cmd_str,
1090
+ "pid": fe_proc.pid,
1091
+ "url": f"/proxy/{session_id}/",
1092
+ "multi_service": True,
1093
+ "framework": "full-stack",
1094
+ "services": services,
1095
+ }
1096
+
1097
+ # Timeout -- report whatever state we have
1098
+ if fe_proc.poll() is not None and be_proc.poll() is not None:
1099
+ server_info["status"] = "error"
1100
+ return {
1101
+ "status": "error",
1102
+ "message": "Both frontend and backend exited before port was detected",
1103
+ "output": server_info["output_lines"][-10:],
1104
+ }
1105
+
1106
+ # Fallback to expected port
1107
+ health_ok = await self._health_check(fe_port)
1108
+ if health_ok:
1109
+ server_info["port"] = fe_port
1110
+ server_info["status"] = "running"
1111
+ return {
1112
+ "status": "running",
1113
+ "port": fe_port,
1114
+ "command": fe_cmd_str,
1115
+ "pid": fe_proc.pid,
1116
+ "url": f"/proxy/{session_id}/",
1117
+ "multi_service": True,
1118
+ "framework": "full-stack",
1119
+ }
1120
+
1121
+ server_info["status"] = "starting"
1122
+ server_info["port"] = fe_port
1123
+ return {
1124
+ "status": "starting",
1125
+ "message": "Server started but port not yet confirmed",
1126
+ "port": fe_port,
1127
+ "command": fe_cmd_str,
1128
+ "pid": fe_proc.pid,
1129
+ "url": f"/proxy/{session_id}/",
1130
+ "multi_service": True,
1131
+ "framework": "full-stack",
1132
+ }
1133
+
1134
+ # -- Single-service startup (original path) --
795
1135
 
796
1136
  # Auto-install dependencies before starting the dev server
797
1137
  actual_path = Path(actual_dir)
798
1138
  needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
799
1139
  needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()
800
1140
 
801
- build_env = {**os.environ}
802
- build_env.update(_load_secrets())
803
-
804
1141
  if needs_npm:
805
1142
  try:
806
1143
  subprocess.run(
@@ -814,34 +1151,7 @@ class DevServerManager:
814
1151
  logger.warning("npm install failed: %s", exc)
815
1152
 
816
1153
  if needs_pip:
817
- # Use project venv if available, otherwise create one to avoid
818
- # installing into the server's own Python environment.
819
- venv_dir = None
820
- for venv_name in ("venv", ".venv", "env"):
821
- candidate = actual_path / venv_name
822
- if candidate.is_dir() and (candidate / "bin" / "pip").exists():
823
- venv_dir = candidate
824
- break
825
- if venv_dir is None:
826
- # Create a virtual environment for the project
827
- try:
828
- subprocess.run(
829
- [sys.executable, "-m", "venv", str(actual_path / "venv")],
830
- capture_output=True, timeout=60,
831
- )
832
- venv_dir = actual_path / "venv"
833
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
834
- logger.warning("venv creation failed: %s", exc)
835
- pip_executable = str(venv_dir / "bin" / "pip") if venv_dir else "pip"
836
- try:
837
- subprocess.run(
838
- [pip_executable, "install", "-r", "requirements.txt"],
839
- cwd=actual_dir,
840
- capture_output=True,
841
- timeout=120,
842
- )
843
- except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
844
- logger.warning("pip install failed: %s", exc)
1154
+ self._install_pip_deps(actual_path, build_env)
845
1155
 
846
1156
  # Check if portless is available and proxy is running
847
1157
  use_portless = False
@@ -875,7 +1185,7 @@ class DevServerManager:
875
1185
  _track_child_pid(proc.pid)
876
1186
 
877
1187
  effective_cmd = " ".join(cmd_parts)
878
- server_info: dict = {
1188
+ server_info = {
879
1189
  "process": proc,
880
1190
  "port": None,
881
1191
  "expected_port": expected_port,
@@ -1016,6 +1326,39 @@ class DevServerManager:
1016
1326
  except Exception:
1017
1327
  logger.warning("Failed to schedule auto-fix for session %s", session_id, exc_info=True)
1018
1328
 
1329
+ async def _monitor_backend_output(self, session_id: str) -> None:
1330
+ """Background task: read backend dev server stdout for multi-service setups."""
1331
+ info = self.servers.get(session_id)
1332
+ if not info or not info.get("multi_service"):
1333
+ return
1334
+ be_proc = info.get("backend_process")
1335
+ if not be_proc or not be_proc.stdout:
1336
+ return
1337
+ loop = asyncio.get_running_loop()
1338
+ try:
1339
+ while be_proc.poll() is None:
1340
+ line = await loop.run_in_executor(None, be_proc.stdout.readline)
1341
+ if not line:
1342
+ break
1343
+ text = line.rstrip("\n")
1344
+ be_lines = info.get("backend_output_lines", [])
1345
+ be_lines.append(text)
1346
+ if len(be_lines) > 200:
1347
+ be_lines = be_lines[-200:]
1348
+ info["backend_output_lines"] = be_lines
1349
+ # Also detect backend port from output
1350
+ detected_port = self._parse_port(text)
1351
+ if detected_port:
1352
+ info["backend_port"] = detected_port
1353
+ except Exception:
1354
+ logger.error("Backend monitor failed for session %s", session_id, exc_info=True)
1355
+ finally:
1356
+ if be_proc.poll() is not None and info.get("status") in ("starting", "running"):
1357
+ # Only mark error if frontend is also dead
1358
+ fe_proc = info.get("process")
1359
+ if fe_proc and fe_proc.poll() is not None:
1360
+ info["status"] = "error"
1361
+
1019
1362
  async def _auto_fix(self, session_id: str, error_context: str) -> None:
1020
1363
  """Auto-fix a crashed dev server by invoking loki quick with the error."""
1021
1364
  info = self.servers.get(session_id)
@@ -1169,6 +1512,34 @@ class DevServerManager:
1169
1512
  pass
1170
1513
 
1171
1514
  _untrack_child_pid(proc.pid)
1515
+
1516
+ # For multi-service setups, also kill the backend process
1517
+ be_proc = info.get("backend_process")
1518
+ if be_proc:
1519
+ if be_proc.poll() is None:
1520
+ if sys.platform != "win32":
1521
+ try:
1522
+ pgid = os.getpgid(be_proc.pid)
1523
+ os.killpg(pgid, signal.SIGTERM)
1524
+ except (ProcessLookupError, PermissionError, OSError):
1525
+ try:
1526
+ be_proc.terminate()
1527
+ except (ProcessLookupError, PermissionError, OSError):
1528
+ pass
1529
+ else:
1530
+ try:
1531
+ be_proc.terminate()
1532
+ except (ProcessLookupError, PermissionError, OSError):
1533
+ pass
1534
+ try:
1535
+ be_proc.wait(timeout=5)
1536
+ except subprocess.TimeoutExpired:
1537
+ try:
1538
+ be_proc.kill()
1539
+ except (ProcessLookupError, PermissionError, OSError):
1540
+ pass
1541
+ _untrack_child_pid(be_proc.pid)
1542
+
1172
1543
  return {"stopped": True, "message": "Dev server stopped"}
1173
1544
 
1174
1545
  async def status(self, session_id: str) -> dict:
@@ -1203,6 +1574,29 @@ class DevServerManager:
1203
1574
  "auto_fix_status": info.get("auto_fix_status"),
1204
1575
  "auto_fix_attempts": info.get("auto_fix_attempts", 0),
1205
1576
  }
1577
+
1578
+ # Multi-service status reporting
1579
+ if info.get("multi_service"):
1580
+ be_proc = info.get("backend_process")
1581
+ be_alive = be_proc.poll() is None if be_proc else False
1582
+ result["multi_service"] = True
1583
+ result["framework"] = "full-stack"
1584
+ result["services"] = [
1585
+ {
1586
+ "name": "frontend",
1587
+ "framework": info.get("frontend_framework", "unknown"),
1588
+ "port": info.get("expected_port"),
1589
+ "status": "running" if alive else "error",
1590
+ },
1591
+ {
1592
+ "name": "backend",
1593
+ "framework": info.get("backend_framework", "unknown"),
1594
+ "port": info.get("backend_port"),
1595
+ "status": "running" if be_alive else "error",
1596
+ },
1597
+ ]
1598
+ result["backend_output"] = info.get("backend_output_lines", [])[-20:]
1599
+
1206
1600
  if info.get("use_portless") and info.get("portless_app_name"):
1207
1601
  app_name = info["portless_app_name"]
1208
1602
  result["portless_url"] = f"http://{app_name}.localhost:1355/"