loki-mode 6.37.2 → 6.37.4

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.37.2
6
+ # Loki Mode v6.37.4
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.37.2 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.37.4 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.37.2
1
+ 6.37.4
package/autonomy/loki CHANGED
@@ -7788,6 +7788,36 @@ print(json.dumps({'id': manifest.id, 'dir': str(pipeline.migration_dir)}))
7788
7788
  phases_to_run=("understand" "guardrail" "migrate" "verify")
7789
7789
  fi
7790
7790
 
7791
+ # Validate prerequisite artifacts exist before running phases (#81)
7792
+ for p in "${phases_to_run[@]}"; do
7793
+ case "$p" in
7794
+ guardrail)
7795
+ if [ ! -f "${migration_dir}/docs/analysis.md" ] || [ ! -f "${migration_dir}/seams.json" ]; then
7796
+ echo -e "${RED}Error: Phase 'guardrail' requires artifacts from 'understand' phase${NC}"
7797
+ echo -e "${DIM} Missing: analysis.md or seams.json in ${migration_dir}${NC}"
7798
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase understand"
7799
+ return 1
7800
+ fi
7801
+ ;;
7802
+ migrate)
7803
+ if [ ! -f "${migration_dir}/features.json" ]; then
7804
+ echo -e "${RED}Error: Phase 'migrate' requires artifacts from 'guardrail' phase${NC}"
7805
+ echo -e "${DIM} Missing: features.json in ${migration_dir}${NC}"
7806
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase guardrail"
7807
+ return 1
7808
+ fi
7809
+ ;;
7810
+ verify)
7811
+ if [ ! -f "${migration_dir}/migration-plan.json" ]; then
7812
+ echo -e "${RED}Error: Phase 'verify' requires migration-plan.json from 'migrate' phase${NC}"
7813
+ echo -e "${DIM} Missing: migration-plan.json in ${migration_dir}${NC}"
7814
+ echo "Run: loki migrate ${codebase_path} --target ${target} --phase migrate"
7815
+ return 1
7816
+ fi
7817
+ ;;
7818
+ esac
7819
+ done
7820
+
7791
7821
  # Execute phases
7792
7822
  for p in "${phases_to_run[@]}"; do
7793
7823
  echo -e "${CYAN}[Phase: ${p}]${NC} Starting..."
@@ -12595,24 +12625,32 @@ cmd_council() {
12595
12625
 
12596
12626
  if [ -f "$council_dir/state.json" ]; then
12597
12627
  LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
12598
- import json, os
12599
- with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12600
- state = json.load(f)
12601
- print(f\"Enabled: {state.get('initialized', False)}\")
12602
- print(f\"Total votes: {state.get('total_votes', 0)}\")
12603
- print(f\"Approve votes: {state.get('approve_votes', 0)}\")
12604
- print(f\"Reject votes: {state.get('reject_votes', 0)}\")
12605
- print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
12606
- print(f\"Done signals: {state.get('done_signals', 0)}\")
12607
- print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
12608
- verdicts = state.get('verdicts', [])
12609
- if verdicts:
12610
- print(f\"\nRecent verdicts:\")
12611
- for v in verdicts[-5:]:
12612
- print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
12613
- else:
12614
- print(f\"\nNo verdicts yet\")
12615
- " 2>/dev/null
12628
+ import json, os, sys
12629
+ try:
12630
+ with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12631
+ state = json.load(f)
12632
+ print(f\"Enabled: {state.get('initialized', False)}\")
12633
+ print(f\"Total votes: {state.get('total_votes', 0)}\")
12634
+ print(f\"Approve votes: {state.get('approve_votes', 0)}\")
12635
+ print(f\"Reject votes: {state.get('reject_votes', 0)}\")
12636
+ print(f\"Stagnation streak: {state.get('consecutive_no_change', 0)}\")
12637
+ print(f\"Done signals: {state.get('done_signals', 0)}\")
12638
+ print(f\"Last check: iteration {state.get('last_check_iteration', 'none')}\")
12639
+ verdicts = state.get('verdicts', [])
12640
+ if verdicts:
12641
+ print(f\"\nRecent verdicts:\")
12642
+ for v in verdicts[-5:]:
12643
+ print(f\" Iteration {v['iteration']}: {v['result']} ({v['approve']} approve / {v['reject']} reject)\")
12644
+ else:
12645
+ print(f\"\nNo verdicts yet\")
12646
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
12647
+ print(f'Error: Council state file is corrupted or invalid: {e}', file=sys.stderr)
12648
+ print('Delete .loki/council/state.json and restart the session to reset.')
12649
+ sys.exit(1)
12650
+ except Exception as e:
12651
+ print(f'Error reading council state: {e}', file=sys.stderr)
12652
+ sys.exit(1)
12653
+ " 2>&1
12616
12654
  else
12617
12655
  echo "Council state file not found"
12618
12656
  fi
@@ -12623,9 +12661,14 @@ else:
12623
12661
 
12624
12662
  if [ -f "$council_dir/state.json" ]; then
12625
12663
  LOKI_COUNCIL_STATE="$council_dir/state.json" python3 -c "
12626
- import json, os
12627
- with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12628
- state = json.load(f)
12664
+ import json, os, sys
12665
+ try:
12666
+ with open(os.environ['LOKI_COUNCIL_STATE']) as f:
12667
+ state = json.load(f)
12668
+ except (json.JSONDecodeError, KeyError, TypeError) as e:
12669
+ print(f'Error: Council state file is corrupted: {e}', file=sys.stderr)
12670
+ print('Delete .loki/council/state.json and restart the session to reset.')
12671
+ sys.exit(1)
12629
12672
  verdicts = state.get('verdicts', [])
12630
12673
  if not verdicts:
12631
12674
  print('No decisions recorded yet')
package/autonomy/run.sh CHANGED
@@ -5502,10 +5502,11 @@ run_code_review() {
5502
5502
  log_info "Selecting 3 specialist reviewers from pool..."
5503
5503
 
5504
5504
  # Write diff/files to temp files for python to read (avoid env var size limits)
5505
+ # Use printf to prevent shell variable expansion in diff content (#78)
5505
5506
  local diff_file="$review_dir/$review_id/diff.txt"
5506
5507
  local files_file="$review_dir/$review_id/files.txt"
5507
- echo "$diff_content" > "$diff_file"
5508
- echo "$changed_files" > "$files_file"
5508
+ printf '%s\n' "$diff_content" > "$diff_file"
5509
+ printf '%s\n' "$changed_files" > "$files_file"
5509
5510
 
5510
5511
  # Select specialists via keyword scoring (python3 reads files, not env vars)
5511
5512
  # Loads from agents/types.json when available, falls back to hardcoded pool (v6.7.0)
@@ -5881,11 +5882,11 @@ run_adversarial_testing() {
5881
5882
  local changed_files
5882
5883
  changed_files=$(git -C "${TARGET_DIR:-.}" diff --name-only HEAD~1 2>/dev/null || git -C "${TARGET_DIR:-.}" diff --name-only --cached 2>/dev/null || echo "")
5883
5884
 
5884
- # Write analysis files
5885
+ # Write analysis files -- use printf to prevent shell variable expansion (#78)
5885
5886
  local diff_file="$adversarial_dir/$test_id/diff.txt"
5886
5887
  local files_file="$adversarial_dir/$test_id/files.txt"
5887
- echo "$diff_content" > "$diff_file"
5888
- echo "$changed_files" > "$files_file"
5888
+ printf '%s\n' "$diff_content" > "$diff_file"
5889
+ printf '%s\n' "$changed_files" > "$files_file"
5889
5890
 
5890
5891
  # Build adversarial prompt -- use heredoc with quoted delimiter to prevent
5891
5892
  # shell variable expansion in diff content (fixes #78)
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.37.2"
10
+ __version__ = "6.37.4"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
package/dashboard/auth.py CHANGED
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import base64
16
16
  import hashlib
17
+ import hmac
17
18
  import json
18
19
  import os
19
20
  import secrets
@@ -58,17 +59,29 @@ _SCOPE_HIERARCHY = {
58
59
  if OIDC_ENABLED:
59
60
  import logging as _logging
60
61
  _logger = _logging.getLogger("loki.auth")
62
+ _pyjwt_available = False
63
+ try:
64
+ import jwt as _pyjwt_check # noqa: F401
65
+ from jwt import PyJWKClient as _PyJWKClient_check # noqa: F401
66
+ _pyjwt_available = True
67
+ except ImportError:
68
+ _pyjwt_available = False
69
+
61
70
  if OIDC_SKIP_SIGNATURE_VERIFY:
62
71
  _logger.critical(
63
72
  "OIDC/SSO signature verification DISABLED (LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). "
64
73
  "This is INSECURE and allows forged JWTs. Only use for local testing. "
65
74
  "For production, install PyJWT + cryptography and remove this env var."
66
75
  )
76
+ elif _pyjwt_available:
77
+ _logger.info(
78
+ "OIDC/SSO enabled with PyJWT cryptographic signature verification (RS256/RS384/RS512)."
79
+ )
67
80
  else:
68
- _logger.warning(
69
- "OIDC/SSO enabled (EXPERIMENTAL). Claims-based validation only -- "
70
- "JWT signatures are NOT cryptographically verified. Install PyJWT + "
71
- "cryptography for production signature verification."
81
+ _logger.critical(
82
+ "OIDC/SSO enabled but PyJWT is NOT installed. Tokens will be REJECTED "
83
+ "unless LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true is set. "
84
+ "Install PyJWT + cryptography: pip install PyJWT cryptography"
72
85
  )
73
86
 
74
87
  # OIDC JWKS cache (issuer URL -> (keys_dict, fetch_timestamp))
@@ -103,6 +116,9 @@ def _save_tokens(tokens: dict) -> None:
103
116
  TOKEN_FILE.touch(mode=0o600, exist_ok=True)
104
117
  with open(TOKEN_FILE, "w") as f:
105
118
  json.dump(tokens, f, indent=2, default=str)
119
+ # Enforce 0600 on every write, not just creation -- touch(mode=) only
120
+ # applies when the file is new, so an external chmod would persist.
121
+ os.chmod(TOKEN_FILE, 0o600)
106
122
 
107
123
 
108
124
  def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
@@ -123,7 +139,7 @@ def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
123
139
 
124
140
  def _constant_time_compare(a: str, b: str) -> bool:
125
141
  """Constant-time string comparison to prevent timing attacks."""
126
- return secrets.compare_digest(a.encode(), b.encode())
142
+ return hmac.compare_digest(a.encode(), b.encode())
127
143
 
128
144
 
129
145
  def resolve_scopes(role_or_scopes) -> list[str]:
@@ -342,30 +358,35 @@ def validate_token(raw_token: str) -> Optional[dict]:
342
358
 
343
359
  tokens = _load_tokens()
344
360
 
345
- # Find matching token (using constant-time comparison to prevent timing attacks)
361
+ # Iterate ALL tokens to prevent timing side-channel that leaks token count.
362
+ # Do not short-circuit on match -- always hash and compare every entry.
363
+ matched_token: Optional[dict] = None
346
364
  for token in tokens["tokens"].values():
347
365
  stored_salt = token.get("salt", "")
348
366
  token_hash, _ = _hash_token(raw_token, salt=stored_salt)
349
367
  if _constant_time_compare(token["hash"], token_hash):
350
- # Check if revoked
351
- if token.get("revoked"):
352
- return None
368
+ matched_token = token
369
+
370
+ if matched_token is not None:
371
+ # Check if revoked
372
+ if matched_token.get("revoked"):
373
+ return None
353
374
 
354
- # Check expiration
355
- if token.get("expires_at"):
356
- expires = datetime.fromisoformat(token["expires_at"])
357
- if datetime.now(timezone.utc) > expires:
358
- return None
375
+ # Check expiration
376
+ if matched_token.get("expires_at"):
377
+ expires = datetime.fromisoformat(matched_token["expires_at"])
378
+ if datetime.now(timezone.utc) > expires:
379
+ return None
359
380
 
360
- # Update last used
361
- token["last_used"] = datetime.now(timezone.utc).isoformat()
362
- _save_tokens(tokens)
381
+ # Update last used
382
+ matched_token["last_used"] = datetime.now(timezone.utc).isoformat()
383
+ _save_tokens(tokens)
363
384
 
364
- return {
365
- "id": token["id"],
366
- "name": token["name"],
367
- "scopes": token["scopes"],
368
- }
385
+ return {
386
+ "id": matched_token["id"],
387
+ "name": matched_token["name"],
388
+ "scopes": matched_token["scopes"],
389
+ }
369
390
 
370
391
  return None
371
392
 
@@ -517,14 +538,18 @@ def validate_oidc_token(token_str: str) -> Optional[dict]:
517
538
  "issuer": decoded.get("iss"),
518
539
  }
519
540
  except ImportError:
520
- # PyJWT not installed -- fall through to claims-only path with loud warning
521
- _warning_msg = (
522
- "WARNING: OIDC JWT signatures are NOT cryptographically verified. "
523
- "Install PyJWT with 'pip install PyJWT cryptography' for production use."
541
+ # PyJWT not installed -- only allow claims-only path if explicitly opted in
542
+ if not OIDC_SKIP_SIGNATURE_VERIFY:
543
+ _auth_logger.error(
544
+ "OIDC token rejected: PyJWT not installed and "
545
+ "LOKI_OIDC_SKIP_SIGNATURE_VERIFY is not set. "
546
+ "Install PyJWT: pip install PyJWT cryptography"
547
+ )
548
+ return None
549
+ _auth_logger.warning(
550
+ "PyJWT not installed -- using claims-only validation "
551
+ "(LOKI_OIDC_SKIP_SIGNATURE_VERIFY=true). This is INSECURE."
524
552
  )
525
- _auth_logger.warning(_warning_msg)
526
- # Also print to stderr so operators notice even without log config
527
- print(_warning_msg, file=sys.stderr)
528
553
  except Exception as exc:
529
554
  _auth_logger.error("PyJWT signature verification failed: %s", exc)
530
555
  return None
@@ -30,7 +30,7 @@ from fastapi import (
30
30
  )
31
31
  from fastapi.middleware.cors import CORSMiddleware
32
32
  from fastapi.responses import JSONResponse, PlainTextResponse
33
- from pydantic import BaseModel, Field
33
+ from pydantic import BaseModel, Field, field_validator
34
34
  from sqlalchemy import select, update, delete
35
35
  from sqlalchemy.ext.asyncio import AsyncSession
36
36
  from sqlalchemy.orm import selectinload
@@ -75,6 +75,27 @@ def _safe_int_env(name: str, default: int) -> int:
75
75
  return default
76
76
 
77
77
 
78
+ def _safe_json_read(path: _Path, default: Any = None) -> Any:
79
+ """Read a JSON file with retry on partial/corrupt data from concurrent writes."""
80
+ for attempt in range(2):
81
+ try:
82
+ text = path.read_text(encoding="utf-8", errors="replace")
83
+ return json.loads(text)
84
+ except json.JSONDecodeError:
85
+ if attempt == 0:
86
+ time.sleep(0.1)
87
+ continue
88
+ return default
89
+ except (OSError, IOError):
90
+ return default
91
+ return default
92
+
93
+
94
+ def _safe_read_text(path: _Path) -> str:
95
+ """Read a text file with UTF-8 encoding, replacing non-UTF-8 bytes."""
96
+ return open(path, encoding="utf-8", errors="replace").read()
97
+
98
+
78
99
  # ---------------------------------------------------------------------------
79
100
  # Simple in-memory rate limiter for control endpoints
80
101
  # ---------------------------------------------------------------------------
@@ -98,12 +119,12 @@ class _RateLimiter:
98
119
  for k in empty_keys:
99
120
  del self._calls[k]
100
121
 
101
- # Evict oldest keys if max_keys exceeded
122
+ # Evict least-recently-accessed keys if max_keys exceeded
102
123
  if len(self._calls) > self._max_keys:
103
- # Sort by oldest timestamp, remove oldest keys
124
+ # Sort by last-access time (most recent timestamp), evict least recent
104
125
  sorted_keys = sorted(
105
126
  self._calls.items(),
106
- key=lambda x: min(x[1]) if x[1] else 0
127
+ key=lambda x: max(x[1]) if x[1] else 0
107
128
  )
108
129
  keys_to_remove = len(self._calls) - self._max_keys
109
130
  for k, _ in sorted_keys[:keys_to_remove]:
@@ -123,12 +144,30 @@ logger = logging.getLogger(__name__)
123
144
 
124
145
 
125
146
  # Pydantic schemas for API
147
+ def _sanitize_text_field(value: str) -> str:
148
+ """Strip/reject control characters from text fields."""
149
+ import unicodedata
150
+ # Remove control characters (except common whitespace like space)
151
+ cleaned = "".join(
152
+ ch for ch in value if unicodedata.category(ch)[0] != "C" or ch in (" ",)
153
+ )
154
+ cleaned = cleaned.strip()
155
+ if not cleaned:
156
+ raise ValueError("Field must not be empty after removing control characters")
157
+ return cleaned
158
+
159
+
126
160
  class ProjectCreate(BaseModel):
127
161
  """Schema for creating a project."""
128
162
  name: str = Field(..., min_length=1, max_length=255)
129
163
  description: Optional[str] = None
130
164
  prd_path: Optional[str] = None
131
165
 
166
+ @field_validator("name")
167
+ @classmethod
168
+ def validate_name(cls, v: str) -> str:
169
+ return _sanitize_text_field(v)
170
+
132
171
 
133
172
  class ProjectUpdate(BaseModel):
134
173
  """Schema for updating a project."""
@@ -165,6 +204,11 @@ class TaskCreate(BaseModel):
165
204
  parent_task_id: Optional[int] = None
166
205
  estimated_duration: Optional[int] = None
167
206
 
207
+ @field_validator("title")
208
+ @classmethod
209
+ def validate_title(cls, v: str) -> str:
210
+ return _sanitize_text_field(v)
211
+
168
212
 
169
213
  class TaskUpdate(BaseModel):
170
214
  """Schema for updating a task."""
@@ -315,7 +359,7 @@ async def _push_loki_state_loop() -> None:
315
359
  if mtime != last_mtime:
316
360
  last_mtime = mtime
317
361
  try:
318
- raw = json.loads(state_file.read_text())
362
+ raw = _safe_json_read(state_file, {})
319
363
  # Transform to StatusResponse-compatible format
320
364
  agents_list = raw.get("agents", [])
321
365
  running_agents = len(agents_list) if isinstance(agents_list, list) else 0
@@ -515,10 +559,10 @@ async def get_status() -> StatusResponse:
515
559
  pending_tasks = 0
516
560
  running_agents = 0
517
561
 
518
- # Read dashboard state
562
+ # Read dashboard state (with retry for concurrent writes)
519
563
  if state_file.exists():
520
564
  try:
521
- state = json.loads(state_file.read_text())
565
+ state = _safe_json_read(state_file, {})
522
566
  phase = state.get("phase", "")
523
567
  iteration = state.get("iteration", 0)
524
568
  complexity = state.get("complexity", "standard")
@@ -561,7 +605,7 @@ async def get_status() -> StatusResponse:
561
605
  # Also check session.json for skill-invoked sessions
562
606
  if not running and session_file.exists():
563
607
  try:
564
- sd = json.loads(session_file.read_text())
608
+ sd = _safe_json_read(session_file, {})
565
609
  if sd.get("status") == "running":
566
610
  running = True
567
611
  except (json.JSONDecodeError, KeyError):
@@ -673,21 +717,41 @@ async def get_status() -> StatusResponse:
673
717
  @app.get("/api/projects", response_model=list[ProjectResponse])
674
718
  async def list_projects(
675
719
  status: Optional[str] = Query(None),
720
+ limit: int = Query(default=50, ge=1, le=500),
721
+ offset: int = Query(default=0, ge=0),
676
722
  db: AsyncSession = Depends(get_db),
677
723
  ) -> list[ProjectResponse]:
678
- """List all projects."""
679
- query = select(Project).options(selectinload(Project.tasks))
724
+ """List projects with pagination. Does not eager-load tasks for efficiency."""
725
+ from sqlalchemy import func as sa_func
726
+
727
+ query = select(Project)
680
728
  if status:
681
729
  query = query.where(Project.status == status)
682
- query = query.order_by(Project.created_at.desc())
730
+ query = query.order_by(Project.created_at.desc()).offset(offset).limit(limit)
683
731
 
684
732
  result = await db.execute(query)
685
733
  projects = result.scalars().all()
686
734
 
735
+ # Batch-fetch task counts instead of N+1 eager loading
736
+ project_ids = [p.id for p in projects]
687
737
  response = []
738
+ if project_ids:
739
+ count_query = (
740
+ select(
741
+ Task.project_id,
742
+ sa_func.count().label("total"),
743
+ sa_func.count().filter(Task.status == TaskStatus.DONE).label("done"),
744
+ )
745
+ .where(Task.project_id.in_(project_ids))
746
+ .group_by(Task.project_id)
747
+ )
748
+ count_result = await db.execute(count_query)
749
+ counts = {row.project_id: (row.total, row.done) for row in count_result}
750
+ else:
751
+ counts = {}
752
+
688
753
  for project in projects:
689
- task_count = len(project.tasks)
690
- completed_count = len([t for t in project.tasks if t.status == TaskStatus.DONE])
754
+ total, done = counts.get(project.id, (0, 0))
691
755
  response.append(
692
756
  ProjectResponse(
693
757
  id=project.id,
@@ -697,8 +761,8 @@ async def list_projects(
697
761
  status=project.status,
698
762
  created_at=project.created_at,
699
763
  updated_at=project.updated_at,
700
- task_count=task_count,
701
- completed_task_count=completed_count,
764
+ task_count=total,
765
+ completed_task_count=done,
702
766
  )
703
767
  )
704
768
  return response
@@ -1066,12 +1130,29 @@ async def create_task(
1066
1130
  Task.project_id == task.project_id
1067
1131
  )
1068
1132
  )
1069
- if not result.scalar_one_or_none():
1133
+ parent = result.scalar_one_or_none()
1134
+ if not parent:
1070
1135
  raise HTTPException(
1071
1136
  status_code=400,
1072
1137
  detail="Parent task not found or belongs to different project"
1073
1138
  )
1074
1139
 
1140
+ # Detect circular reference: walk parent chain
1141
+ visited = set()
1142
+ current_parent_id = task.parent_task_id
1143
+ while current_parent_id is not None:
1144
+ if current_parent_id in visited:
1145
+ raise HTTPException(
1146
+ status_code=422,
1147
+ detail="Circular reference detected in parent task chain"
1148
+ )
1149
+ visited.add(current_parent_id)
1150
+ parent_result = await db.execute(
1151
+ select(Task.parent_task_id).where(Task.id == current_parent_id)
1152
+ )
1153
+ row = parent_result.scalar_one_or_none()
1154
+ current_parent_id = row if row else None
1155
+
1075
1156
  db_task = Task(
1076
1157
  project_id=task.project_id,
1077
1158
  title=task.title,
@@ -1192,6 +1273,15 @@ async def delete_task(
1192
1273
  })
1193
1274
 
1194
1275
 
1276
+ # Valid status transitions for task state machine
1277
+ _TASK_STATE_MACHINE: dict[TaskStatus, set[TaskStatus]] = {
1278
+ TaskStatus.BACKLOG: {TaskStatus.PENDING},
1279
+ TaskStatus.PENDING: {TaskStatus.IN_PROGRESS},
1280
+ TaskStatus.IN_PROGRESS: {TaskStatus.REVIEW, TaskStatus.DONE},
1281
+ TaskStatus.REVIEW: {TaskStatus.DONE, TaskStatus.IN_PROGRESS},
1282
+ }
1283
+
1284
+
1195
1285
  @app.post("/api/tasks/{task_id}/move", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("control"))])
1196
1286
  async def move_task(
1197
1287
  task_id: int,
@@ -1208,6 +1298,18 @@ async def move_task(
1208
1298
  raise HTTPException(status_code=404, detail="Task not found")
1209
1299
 
1210
1300
  old_status = task.status
1301
+
1302
+ # Validate status transition
1303
+ if move.status != old_status:
1304
+ allowed = _TASK_STATE_MACHINE.get(old_status, set())
1305
+ if move.status not in allowed:
1306
+ raise HTTPException(
1307
+ status_code=422,
1308
+ detail=f"Invalid status transition: {old_status.value} -> {move.status.value}. "
1309
+ f"Allowed transitions from {old_status.value}: "
1310
+ f"{', '.join(s.value for s in allowed) if allowed else 'none'}",
1311
+ )
1312
+
1211
1313
  task.status = move.status
1212
1314
  task.position = move.position
1213
1315
 
@@ -1252,8 +1354,9 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1252
1354
  # proxy access logs -- configure log sanitization for /ws in production.
1253
1355
  # FastAPI Depends() is not supported on @app.websocket() routes.
1254
1356
 
1255
- # Rate limit WebSocket connections by IP
1256
- client_ip = websocket.client.host if websocket.client else "unknown"
1357
+ # Rate limit WebSocket connections by IP (use unique key when client info unavailable)
1358
+ import uuid as _uuid
1359
+ client_ip = websocket.client.host if websocket.client else f"ws-{_uuid.uuid4().hex}"
1257
1360
  if not _read_limiter.check(f"ws_{client_ip}"):
1258
1361
  await websocket.close(code=1008) # Policy Violation
1259
1362
  return
@@ -1284,20 +1387,23 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1284
1387
  "data": {"message": "Connected to Loki Dashboard"},
1285
1388
  })
1286
1389
 
1287
- # Keep connection alive and handle incoming messages
1390
+ # Keep connection alive and handle incoming messages.
1391
+ # Close idle connections after ~60s of no response to pings.
1392
+ missed_pongs = 0
1288
1393
  while True:
1289
1394
  try:
1290
1395
  data = await asyncio.wait_for(
1291
1396
  websocket.receive_text(),
1292
- timeout=30.0 # Ping every 30 seconds
1397
+ timeout=30.0 # Ping every 30 seconds of silence
1293
1398
  )
1294
- # Handle incoming messages (e.g., subscriptions)
1399
+ missed_pongs = 0 # any message resets idle counter
1295
1400
  try:
1296
1401
  message = json.loads(data)
1297
1402
  if message.get("type") == "ping":
1298
1403
  await manager.send_personal(websocket, {"type": "pong"})
1404
+ elif message.get("type") == "pong":
1405
+ pass # client responded to our ping
1299
1406
  elif message.get("type") == "subscribe":
1300
- # Could implement channel subscriptions here
1301
1407
  await manager.send_personal(websocket, {
1302
1408
  "type": "subscribed",
1303
1409
  "data": message.get("data", {}),
@@ -1305,8 +1411,15 @@ async def websocket_endpoint(websocket: WebSocket) -> None:
1305
1411
  except json.JSONDecodeError as e:
1306
1412
  logger.debug(f"WebSocket received invalid JSON: {e}")
1307
1413
  except asyncio.TimeoutError:
1308
- # Send keepalive ping
1309
- await manager.send_personal(websocket, {"type": "ping"})
1414
+ missed_pongs += 1
1415
+ if missed_pongs >= 2:
1416
+ # Two consecutive pings with no reply -- close idle connection
1417
+ logger.info("Closing idle WebSocket (no pong response)")
1418
+ break
1419
+ try:
1420
+ await manager.send_personal(websocket, {"type": "ping"})
1421
+ except Exception:
1422
+ break
1310
1423
 
1311
1424
  except WebSocketDisconnect:
1312
1425
  manager.disconnect(websocket)
@@ -1437,7 +1550,13 @@ async def sync_registry():
1437
1550
  if not _read_limiter.check("registry_sync"):
1438
1551
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
1439
1552
 
1440
- result = registry.sync_registry_with_discovery()
1553
+ try:
1554
+ result = await asyncio.wait_for(
1555
+ asyncio.get_event_loop().run_in_executor(None, registry.sync_registry_with_discovery),
1556
+ timeout=30.0,
1557
+ )
1558
+ except asyncio.TimeoutError:
1559
+ raise HTTPException(status_code=504, detail="Registry sync timed out after 30 seconds")
1441
1560
  return {
1442
1561
  "added": result["added"],
1443
1562
  "updated": result["updated"],
@@ -1608,7 +1727,7 @@ class AuditQueryParams(BaseModel):
1608
1727
  offset: int = 0
1609
1728
 
1610
1729
 
1611
- @app.get("/api/enterprise/audit")
1730
+ @app.get("/api/enterprise/audit", dependencies=[Depends(auth.require_scope("audit"))])
1612
1731
  async def query_audit_logs(
1613
1732
  start_date: Optional[str] = None,
1614
1733
  end_date: Optional[str] = None,
@@ -1640,7 +1759,7 @@ async def query_audit_logs(
1640
1759
  )
1641
1760
 
1642
1761
 
1643
- @app.get("/api/enterprise/audit/summary")
1762
+ @app.get("/api/enterprise/audit/summary", dependencies=[Depends(auth.require_scope("audit"))])
1644
1763
  async def get_audit_summary(days: int = 7):
1645
1764
  """Get audit activity summary."""
1646
1765
  if not audit.is_audit_enabled():
@@ -1805,11 +1924,14 @@ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
1805
1924
  except Exception:
1806
1925
  pass
1807
1926
 
1808
- # Fallback to JSON files
1927
+ # Fallback to JSON files -- use heapq to avoid sorting all files
1928
+ import heapq
1809
1929
  ep_dir = _get_loki_dir() / "memory" / "episodic"
1810
1930
  episodes = []
1811
1931
  if ep_dir.exists():
1812
- files = sorted(ep_dir.glob("*.json"), reverse=True)[:limit]
1932
+ all_files = ep_dir.glob("*.json")
1933
+ # nlargest by filename (timestamps sort lexicographically) avoids full sort
1934
+ files = heapq.nlargest(limit, all_files, key=lambda f: f.name)
1813
1935
  for f in files:
1814
1936
  try:
1815
1937
  episodes.append(json.loads(f.read_text()))
@@ -3515,7 +3637,7 @@ async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_cu
3515
3637
  file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc).strftime(
3516
3638
  "%Y-%m-%dT%H:%M:%S"
3517
3639
  )
3518
- content = log_file.read_text()
3640
+ content = _safe_read_text(log_file)
3519
3641
  for raw_line in content.strip().split("\n")[-lines:]:
3520
3642
  timestamp = ""
3521
3643
  level = "info"
@@ -4300,7 +4422,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
4300
4422
  if not log_file.exists():
4301
4423
  return {"lines": []}
4302
4424
  try:
4303
- all_lines = log_file.read_text().splitlines()
4425
+ all_lines = _safe_read_text(log_file).splitlines()
4304
4426
  return {"lines": all_lines[-lines:]}
4305
4427
  except OSError:
4306
4428
  return {"lines": []}
@@ -4563,25 +4685,16 @@ async def serve_index():
4563
4685
  if os.path.isfile(index_path):
4564
4686
  return FileResponse(index_path, media_type="text/html")
4565
4687
 
4566
- # Return helpful error message
4567
- return HTMLResponse(
4568
- content="""
4569
- <html>
4570
- <head><title>Loki Dashboard</title></head>
4571
- <body style="font-family: system-ui; padding: 40px; max-width: 600px; margin: 0 auto;">
4572
- <h1>Dashboard Frontend Not Found</h1>
4573
- <p>The dashboard API is running, but the frontend files were not found.</p>
4574
- <p>To fix this, run:</p>
4575
- <pre style="background: #f5f5f5; padding: 15px; border-radius: 5px;">cd dashboard-ui && npm run build</pre>
4576
- <p><strong>API Endpoints:</strong></p>
4577
- <ul>
4578
- <li><a href="/health">/health</a> - Health check</li>
4579
- <li><a href="/docs">/docs</a> - API documentation</li>
4580
- </ul>
4581
- </body>
4582
- </html>
4583
- """,
4584
- status_code=200
4688
+ # Return 503 when frontend files are not found
4689
+ return JSONResponse(
4690
+ content={
4691
+ "error": "dashboard_frontend_not_found",
4692
+ "detail": "The dashboard API is running, but the frontend files were not found. "
4693
+ "Run: cd dashboard-ui && npm run build",
4694
+ "api_docs": "/docs",
4695
+ "health": "/health",
4696
+ },
4697
+ status_code=503,
4585
4698
  )
4586
4699
 
4587
4700
 
@@ -1237,7 +1237,7 @@ var LokiDashboard=(()=>{var pt=Object.defineProperty;var Pt=Object.getOwnPropert
1237
1237
  }
1238
1238
 
1239
1239
  ${B}
1240
- `}getAriaPattern(t){return gt[t]||{}}applyAriaPattern(t,e){let i=this.getAriaPattern(e);for(let[a,s]of Object.entries(i))if(a==="role")t.setAttribute("role",s);else{let r=a.replace(/([A-Z])/g,"-$1").toLowerCase();t.setAttribute(r,s)}}render(){}};var L={realtime:1e3,normal:2e3,background:5e3,offline:1e4},_t={vscode:L.normal,browser:L.realtime,cli:L.background},yt={baseUrl:typeof window<"u"?window.location.origin:"http://localhost:57374",wsUrl:typeof window<"u"?`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/ws`:"ws://localhost:57374/ws",pollInterval:2e3,timeout:1e4,retryAttempts:3,retryDelay:1e3},u={CONNECTED:"api:connected",DISCONNECTED:"api:disconnected",ERROR:"api:error",STATUS_UPDATE:"api:status-update",TASK_CREATED:"api:task-created",TASK_UPDATED:"api:task-updated",TASK_DELETED:"api:task-deleted",PROJECT_CREATED:"api:project-created",PROJECT_UPDATED:"api:project-updated",AGENT_UPDATE:"api:agent-update",LOG_MESSAGE:"api:log-message",MEMORY_UPDATE:"api:memory-update",CHECKLIST_UPDATE:"api:checklist-update"},C=class C extends EventTarget{static getInstance(t={}){let e=t.baseUrl||yt.baseUrl;return C._instances.has(e)||C._instances.set(e,new C(t)),C._instances.get(e)}static clearInstances(){C._instances.forEach(t=>t.disconnect()),C._instances.clear()}constructor(t={}){super(),this.config={...yt,...t},this._ws=null,this._connected=!1,this._pollInterval=null,this._reconnectTimeout=null,this._reconnectAttempts=0,this._maxReconnectAttempts=20,this._cache=new Map,this._cacheTimeout=5e3,this._vscodeApi=null,this._context=this._detectContext(),this._currentPollInterval=_t[this._context]||L.normal,this._visibilityChangeHandler=null,this._messageHandler=null,this._setupAdaptivePolling(),this._setupVSCodeBridge()}_detectContext(){return typeof acquireVsCodeApi<"u"?"vscode":typeof window<"u"&&window.location?"browser":"cli"}get context(){return this._context}static get POLL_INTERVALS(){return L}_setupAdaptivePolling(){typeof document>"u"||(this._visibilityChangeHandler=()=>{document.hidden?this._setPollInterval(L.background):this._setPollInterval(_t[this._context]||L.normal)},document.addEventListener("visibilitychange",this._visibilityChangeHandler))}_setPollInterval(t){this._currentPollInterval=t,this._pollInterval&&(this.stopPolling(),this.startPolling(null,t))}setPollMode(t){let e=L[t];e&&this._setPollInterval(e)}_setupVSCodeBridge(){if(!(typeof acquireVsCodeApi>"u")){try{this._vscodeApi=acquireVsCodeApi()}catch{console.warn("VS Code API already acquired or unavailable");return}this._messageHandler=t=>{let e=t.data;if(!(!e||!e.type))switch(e.type){case"updateStatus":this._emit(u.STATUS_UPDATE,e.data);break;case"updateTasks":this._emit(u.TASK_UPDATED,e.data);break;case"taskCreated":this._emit(u.TASK_CREATED,e.data);break;case"taskDeleted":this._emit(u.TASK_DELETED,e.data);break;case"projectCreated":this._emit(u.PROJECT_CREATED,e.data);break;case"projectUpdated":this._emit(u.PROJECT_UPDATED,e.data);break;case"agentUpdate":this._emit(u.AGENT_UPDATE,e.data);break;case"logMessage":this._emit(u.LOG_MESSAGE,e.data);break;case"memoryUpdate":this._emit(u.MEMORY_UPDATE,e.data);break;case"connected":this._connected=!0,this._emit(u.CONNECTED,e.data);break;case"disconnected":this._connected=!1,this._emit(u.DISCONNECTED,e.data);break;case"error":this._emit(u.ERROR,e.data);break;case"setPollMode":this.setPollMode(e.data.mode);break;default:this._emit(`api:${e.type}`,e.data)}},window.addEventListener("message",this._messageHandler)}}get isVSCode(){return this._context==="vscode"}postToVSCode(t,e={}){this._vscodeApi&&this._vscodeApi.postMessage({type:t,data:e})}requestRefresh(){this.postToVSCode("requestRefresh")}notifyVSCode(t,e={}){this.postToVSCode("userAction",{action:t,...e})}get baseUrl(){return this.config.baseUrl}set baseUrl(t){this.config.baseUrl=t,this.config.wsUrl=t.replace(/^http/,"ws")+"/ws"}get isConnected(){return this._connected}async connect(){if(!(this._ws&&this._ws.readyState===WebSocket.OPEN))return new Promise((t,e)=>{try{this._ws=new WebSocket(this.config.wsUrl),this._ws.onopen=()=>{this._connected=!0,this._reconnectAttempts=0,this._emit(u.CONNECTED),t()},this._ws.onclose=()=>{this._connected=!1,this._emit(u.DISCONNECTED),this._scheduleReconnect()},this._ws.onerror=i=>{this._emit(u.ERROR,{error:i}),e(i)},this._ws.onmessage=i=>{try{let a=JSON.parse(i.data);this._handleMessage(a)}catch(a){console.error("Failed to parse WebSocket message:",a)}}}catch(i){e(i)}})}disconnect(){this._ws&&(this._ws.close(),this._ws=null),this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._reconnectTimeout&&(clearTimeout(this._reconnectTimeout),this._reconnectTimeout=null),this._connected=!1,this._cleanupGlobalListeners()}_cleanupGlobalListeners(){this._visibilityChangeHandler&&typeof document<"u"&&(document.removeEventListener("visibilitychange",this._visibilityChangeHandler),this._visibilityChangeHandler=null),this._messageHandler&&typeof window<"u"&&(window.removeEventListener("message",this._messageHandler),this._messageHandler=null)}destroy(){this.disconnect()}_scheduleReconnect(){if(this._reconnectTimeout)return;if(this._reconnectAttempts>=this._maxReconnectAttempts){console.warn("WebSocket max reconnect attempts reached, giving up"),this._emit(u.ERROR,{error:"Max reconnect attempts reached"});return}let t=Math.min(this.config.retryDelay*Math.pow(2,this._reconnectAttempts),3e4);this._reconnectAttempts++,this._reconnectTimeout=setTimeout(()=>{this._reconnectTimeout=null,this.connect().catch(()=>{})},t)}_handleMessage(t){let i={connected:u.CONNECTED,status_update:u.STATUS_UPDATE,task_created:u.TASK_CREATED,task_updated:u.TASK_UPDATED,task_deleted:u.TASK_DELETED,task_moved:u.TASK_UPDATED,project_created:u.PROJECT_CREATED,project_updated:u.PROJECT_UPDATED,agent_update:u.AGENT_UPDATE,log:u.LOG_MESSAGE}[t.type]||`api:${t.type}`;this._emit(i,t.data)}_emit(t,e={}){this.dispatchEvent(new CustomEvent(t,{detail:e}))}async _request(t,e={}){let i=`${this.config.baseUrl}${t}`,a=new AbortController,s=setTimeout(()=>a.abort(),this.config.timeout);try{let r=await fetch(i,{...e,signal:a.signal,headers:{"Content-Type":"application/json",...e.headers}});if(clearTimeout(s),!r.ok){let o=await r.text().catch(()=>""),n=r.statusText||`HTTP ${r.status}`;if(o)try{let l=JSON.parse(o);n=l.detail||l.error||l.message||n}catch{n=o.length>200?o.slice(0,200)+"...":o}throw new Error(n)}return r.status===204?null:await r.json()}catch(r){throw clearTimeout(s),r.name==="AbortError"?new Error("Request timeout"):r}}async _get(t,e=!1){if(e&&this._cache.has(t)){let a=this._cache.get(t);if(Date.now()-a.timestamp<this._cacheTimeout)return a.data}let i=await this._request(t);return e&&this._cache.set(t,{data:i,timestamp:Date.now()}),i}async _post(t,e){return this._request(t,{method:"POST",body:JSON.stringify(e)})}async _put(t,e){return this._request(t,{method:"PUT",body:JSON.stringify(e)})}async _delete(t){return this._request(t,{method:"DELETE"})}async getStatus(){return this._get("/api/status")}async healthCheck(){return this._get("/health")}async listProjects(t=null){let e=t?`?status=${t}`:"";return this._get(`/api/projects${e}`)}async getProject(t){return this._get(`/api/projects/${t}`)}async createProject(t){return this._post("/api/projects",t)}async updateProject(t,e){return this._put(`/api/projects/${t}`,e)}async deleteProject(t){return this._delete(`/api/projects/${t}`)}async listTasks(t={}){let e=new URLSearchParams;t.projectId&&e.append("project_id",t.projectId),t.status&&e.append("status",t.status),t.priority&&e.append("priority",t.priority);let i=e.toString()?`?${e}`:"";return this._get(`/api/tasks${i}`)}async getTask(t){return this._get(`/api/tasks/${t}`)}async createTask(t){return this._post("/api/tasks",t)}async updateTask(t,e){return this._put(`/api/tasks/${t}`,e)}async moveTask(t,e,i){return this._post(`/api/tasks/${t}/move`,{status:e,position:i})}async deleteTask(t){return this._delete(`/api/tasks/${t}`)}async getMemorySummary(){return this._get("/api/memory/summary",!0)}async getMemoryIndex(){return this._get("/api/memory/index",!0)}async getMemoryTimeline(){return this._get("/api/memory/timeline")}async listEpisodes(t={}){let e=new URLSearchParams(t).toString();return this._get(`/api/memory/episodes${e?"?"+e:""}`)}async getEpisode(t){return this._get(`/api/memory/episodes/${t}`)}async listPatterns(t={}){let e=new URLSearchParams(t).toString();return this._get(`/api/memory/patterns${e?"?"+e:""}`)}async getPattern(t){return this._get(`/api/memory/patterns/${t}`)}async listSkills(){return this._get("/api/memory/skills")}async getSkill(t){return this._get(`/api/memory/skills/${t}`)}async retrieveMemories(t,e=null,i=5){return this._post("/api/memory/retrieve",{query:t,taskType:e,topK:i})}async consolidateMemory(t=24){return this._post("/api/memory/consolidate",{sinceHours:t})}async getTokenEconomics(){return this._get("/api/memory/economics")}async searchMemory(t,e="all",i=20){let a=new URLSearchParams({q:t,collection:e,limit:String(i)});return this._get(`/api/memory/search?${a}`)}async getMemoryStats(){return this._get("/api/memory/stats",!0)}async listRegisteredProjects(t=!1){return this._get(`/api/registry/projects?include_inactive=${t}`)}async registerProject(t,e=null,i=null){return this._post("/api/registry/projects",{path:t,name:e,alias:i})}async discoverProjects(t=3){return this._get(`/api/registry/discover?max_depth=${t}`)}async syncRegistry(){return this._post("/api/registry/sync",{})}async getCrossProjectTasks(t=null){let e=t?`?project_ids=${t.join(",")}`:"";return this._get(`/api/registry/tasks${e}`)}async getLearningMetrics(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source);let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/metrics${i}`)}async getLearningTrends(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source);let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/trends${i}`)}async getLearningSignals(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source),t.limit&&e.append("limit",String(t.limit)),t.offset&&e.append("offset",String(t.offset));let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/signals${i}`)}async getLatestAggregation(){return this._get("/api/learning/aggregation")}async triggerAggregation(t={}){return this._post("/api/learning/aggregate",t)}async getAggregatedPreferences(t=20){return this._get(`/api/learning/preferences?limit=${t}`)}async getAggregatedErrors(t=20){return this._get(`/api/learning/errors?limit=${t}`)}async getAggregatedSuccessPatterns(t=20){return this._get(`/api/learning/success?limit=${t}`)}async getToolEfficiency(t=20){return this._get(`/api/learning/tools?limit=${t}`)}async getCost(){return this._get("/api/cost")}async getPricing(){return this._get("/api/pricing")}async getCouncilState(){return this._get("/api/council/state")}async getCouncilVerdicts(t=20){return this._get(`/api/council/verdicts?limit=${t}`)}async getCouncilConvergence(){return this._get("/api/council/convergence")}async getCouncilReport(){return this._get("/api/council/report")}async forceCouncilReview(){return this._post("/api/council/force-review",{})}async getContext(){return this._get("/api/context")}async getNotifications(t,e){let i=new URLSearchParams;t&&i.set("severity",t),e&&i.set("unread_only","true");let a=i.toString();return this._get("/api/notifications"+(a?"?"+a:""))}async getNotificationTriggers(){return this._get("/api/notifications/triggers")}async updateNotificationTriggers(t){return this._put("/api/notifications/triggers",{triggers:t})}async acknowledgeNotification(t){return this._post("/api/notifications/"+encodeURIComponent(t)+"/acknowledge",{})}async pauseSession(){return this._post("/api/control/pause",{})}async resumeSession(){return this._post("/api/control/resume",{})}async stopSession(){return this._post("/api/control/stop",{})}async getLogs(t=100){return this._get(`/api/logs?lines=${t}`)}async getChecklist(){return this._get("/api/checklist")}async getChecklistSummary(){return this._get("/api/checklist/summary")}async getPrdObservations(){let t=await fetch(`${this.baseUrl}/api/prd-observations`);if(!t.ok)throw new Error(`HTTP ${t.status}`);return t.text()}async getChecklistWaivers(){return this._get("/api/checklist/waivers")}async addChecklistWaiver(t,e,i="dashboard"){return this._post("/api/checklist/waivers",{item_id:t,reason:e,waived_by:i})}async removeChecklistWaiver(t){return this._delete(`/api/checklist/waivers/${encodeURIComponent(t)}`)}async getCouncilGate(){return this._get("/api/council/gate")}async getAppRunnerStatus(){return this._get("/api/app-runner/status")}async getAppRunnerLogs(t=100){return this._get(`/api/app-runner/logs?lines=${t}`)}async restartApp(){return this._post("/api/control/app-restart",{})}async stopApp(){return this._post("/api/control/app-stop",{})}async getPlaywrightResults(){return this._get("/api/playwright/results")}async getPlaywrightScreenshot(){return this._get("/api/playwright/screenshot")}startPolling(t,e=null){if(this._pollInterval)return;this._pollCallback=t;let i=async()=>{try{let s=await this.getStatus();this._connected=!0,this._pollCallback&&this._pollCallback(s),this._emit(u.STATUS_UPDATE,s),this._vscodeApi&&this.postToVSCode("pollSuccess",{timestamp:Date.now()})}catch(s){this._connected=!1,this._emit(u.ERROR,{error:s}),this._vscodeApi&&this.postToVSCode("pollError",{error:s.message})}};i();let a=e||this._currentPollInterval||this.config.pollInterval;this._pollInterval=setInterval(i,a)}stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}};w(C,"_instances",new Map);var R=C;function wt(d={}){return new R(d)}function g(d={}){return R.getInstance(d)}var bt="loki-state-change",mt={ui:{theme:"light",sidebarCollapsed:!1,activeSection:"kanban",terminalAutoScroll:!0},session:{connected:!1,lastSync:null,mode:"offline",phase:null,iteration:null},localTasks:[],cache:{projects:[],tasks:[],agents:[],memory:null,lastFetch:null},preferences:{pollInterval:2e3,notifications:!0,soundEnabled:!1}},$=class $ extends EventTarget{static getInstance(){return $._instance||($._instance=new $),$._instance}constructor(){super(),this._state=this._loadState(),this._subscribers=new Map,this._batchUpdates=[],this._batchTimeout=null}_loadState(){try{let t=localStorage.getItem($.STORAGE_KEY);if(t){let e=JSON.parse(t);return this._mergeState(mt,e)}}catch(t){console.warn("Failed to load state from localStorage:",t)}return{...mt}}_mergeState(t,e){let i={...t};for(let a of Object.keys(e))a in t&&typeof t[a]=="object"&&!Array.isArray(t[a])?i[a]=this._mergeState(t[a],e[a]):i[a]=e[a];return i}_saveState(){try{let t={ui:this._state.ui,localTasks:this._state.localTasks,preferences:this._state.preferences};localStorage.setItem($.STORAGE_KEY,JSON.stringify(t))}catch(t){console.warn("Failed to save state to localStorage:",t)}}get(t=null){if(!t)return{...this._state};let e=t.split("."),i=this._state;for(let a of e){if(i==null)return;i=i[a]}return i}set(t,e,i=!0){let a=t.split("."),s=a.pop(),r=this._state;for(let n of a)n in r||(r[n]={}),r=r[n];let o=r[s];r[s]=e,i&&this._saveState(),this._notifyChange(t,e,o)}update(t,e=!0){let i=[];for(let[a,s]of Object.entries(t)){let r=this.get(a);this.set(a,s,!1),i.push({path:a,value:s,oldValue:r})}e&&this._saveState();for(let a of i)this._notifyChange(a.path,a.value,a.oldValue)}_notifyChange(t,e,i){this.dispatchEvent(new CustomEvent(bt,{detail:{path:t,value:e,oldValue:i}}));let a=this._subscribers.get(t)||[];for(let r of a)try{r(e,i,t)}catch(o){console.error("State subscriber error:",o)}let s=t.split(".");for(;s.length>1;){s.pop();let r=s.join("."),o=this._subscribers.get(r)||[];for(let n of o)try{n(this.get(r),null,r)}catch(l){console.error("State subscriber error:",l)}}}subscribe(t,e){return this._subscribers.has(t)||this._subscribers.set(t,[]),this._subscribers.get(t).push(e),()=>{let i=this._subscribers.get(t),a=i.indexOf(e);a>-1&&i.splice(a,1)}}reset(t=null){if(t){let e=t.split("."),i=mt;for(let a of e)i=i?.[a];this.set(t,i)}else this._state={...mt},this._saveState(),this.dispatchEvent(new CustomEvent(bt,{detail:{path:null,value:this._state,oldValue:null}}))}addLocalTask(t){let e=this.get("localTasks")||[],i={id:`local-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,createdAt:new Date().toISOString(),status:"pending",...t};return this.set("localTasks",[...e,i]),i}updateLocalTask(t,e){let i=this.get("localTasks")||[],a=i.findIndex(r=>r.id===t);if(a===-1)return null;let s={...i[a],...e,updatedAt:new Date().toISOString()};return i[a]=s,this.set("localTasks",[...i]),s}deleteLocalTask(t){let e=this.get("localTasks")||[];this.set("localTasks",e.filter(i=>i.id!==t))}moveLocalTask(t,e,i=null){let s=(this.get("localTasks")||[]).find(r=>r.id===t);return s?this.updateLocalTask(t,{status:e,position:i??s.position}):null}updateSession(t){this.update(Object.fromEntries(Object.entries(t).map(([e,i])=>[`session.${e}`,i])),!1)}updateCache(t){this.update({"cache.projects":t.projects??this.get("cache.projects"),"cache.tasks":t.tasks??this.get("cache.tasks"),"cache.agents":t.agents??this.get("cache.agents"),"cache.memory":t.memory??this.get("cache.memory"),"cache.lastFetch":new Date().toISOString()},!1)}getMergedTasks(){let t=this.get("cache.tasks")||[],i=(this.get("localTasks")||[]).map(a=>({...a,isLocal:!0}));return[...t,...i]}getTasksByStatus(t){return this.getMergedTasks().filter(e=>e.status===t)}};w($,"STORAGE_KEY","loki-dashboard-state"),w($,"_instance",null);var F=$;function z(){return F.getInstance()}function $t(d){let t=z();return{get:()=>t.get(d),set:e=>t.set(d,e),subscribe:e=>t.subscribe(d,e)}}var j=class extends h{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._data={status:"offline",phase:null,iteration:null,provider:null,running_agents:0,pending_tasks:null,uptime_seconds:0,complexity:null,connected:!1},this._api=null,this._pollInterval=null,this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null,this._checklistSummary=null,this._appRunnerStatus=null,this._playwrightResults=null,this._gateStatus=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._startPolling(),this._api.connect().catch(()=>{})}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._loadAbortController&&(this._loadAbortController.abort(),this._loadAbortController=null),this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(u.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(u.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadStatus()),t==="theme"&&this._applyTheme())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._statusUpdateHandler=e=>this._updateFromStatus(e.detail),this._connectedHandler=()=>{this._data.connected=!0,this.render()},this._disconnectedHandler=()=>{this._data.connected=!1,this._data.status="offline",this.render()},this._api.addEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(u.CONNECTED,this._connectedHandler),this._api.addEventListener(u.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){this._loadAbortController&&this._loadAbortController.abort(),this._loadAbortController=new AbortController;let{signal:t}=this._loadAbortController;try{let[e,i,a,s,r]=await Promise.allSettled([this._api.getStatus(),this._api.getChecklistSummary(),this._api.getAppRunnerStatus(),this._api.getPlaywrightResults(),this._api.getCouncilGate()]);if(t.aborted)return;e.status==="fulfilled"?this._updateFromStatus(e.value):(this._data.connected=!1,this._data.status="offline"),i.status==="fulfilled"&&(this._checklistSummary=i.value?.summary||null),a.status==="fulfilled"&&(this._appRunnerStatus=a.value),s.status==="fulfilled"&&(this._playwrightResults=s.value),r.status==="fulfilled"&&(this._gateStatus=r.value),this.render()}catch{if(t.aborted)return;this._data.connected=!1,this._data.status="offline",this.render()}}_updateFromStatus(t){t&&(this._data={...this._data,connected:!0,status:t.status||"offline",phase:t.phase||null,iteration:t.iteration!=null?t.iteration:null,provider:t.provider||null,running_agents:t.running_agents||0,pending_tasks:t.pending_tasks!=null?t.pending_tasks:null,uptime_seconds:t.uptime_seconds||0,complexity:t.complexity||null})}_startPolling(){this._pollInterval=setInterval(async()=>{try{await this._loadStatus()}catch{this._data.connected=!1,this._data.status="offline",this.render()}},5e3)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}_formatUptime(t){if(!t||t<0)return"--";let e=Math.floor(t/3600),i=Math.floor(t%3600/60),a=Math.floor(t%60);return e>0?`${e}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_getStatusDotClass(){switch(this._data.status){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_escapeHtml(t){return t?String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}_renderAppRunnerCard(){let t=this._appRunnerStatus;if(!t||t.status==="not_initialized")return`
1240
+ `}getAriaPattern(t){return gt[t]||{}}applyAriaPattern(t,e){let i=this.getAriaPattern(e);for(let[a,s]of Object.entries(i))if(a==="role")t.setAttribute("role",s);else{let r=a.replace(/([A-Z])/g,"-$1").toLowerCase();t.setAttribute(r,s)}}render(){}};var L={realtime:1e3,normal:2e3,background:5e3,offline:1e4},_t={vscode:L.normal,browser:L.realtime,cli:L.background},yt={baseUrl:typeof window<"u"?window.location.origin:"http://localhost:57374",wsUrl:typeof window<"u"?`${window.location.protocol==="https:"?"wss:":"ws:"}//${window.location.host}/ws`:"ws://localhost:57374/ws",pollInterval:2e3,timeout:1e4,retryAttempts:3,retryDelay:1e3},u={CONNECTED:"api:connected",DISCONNECTED:"api:disconnected",ERROR:"api:error",STATUS_UPDATE:"api:status-update",TASK_CREATED:"api:task-created",TASK_UPDATED:"api:task-updated",TASK_DELETED:"api:task-deleted",PROJECT_CREATED:"api:project-created",PROJECT_UPDATED:"api:project-updated",AGENT_UPDATE:"api:agent-update",LOG_MESSAGE:"api:log-message",MEMORY_UPDATE:"api:memory-update",CHECKLIST_UPDATE:"api:checklist-update"},C=class C extends EventTarget{static getInstance(t={}){let e=t.baseUrl||yt.baseUrl;return C._instances.has(e)||C._instances.set(e,new C(t)),C._instances.get(e)}static clearInstances(){C._instances.forEach(t=>t.disconnect()),C._instances.clear()}constructor(t={}){super(),this.config={...yt,...t},this._ws=null,this._connected=!1,this._pollInterval=null,this._reconnectTimeout=null,this._reconnectAttempts=0,this._maxReconnectAttempts=20,this._cache=new Map,this._cacheTimeout=5e3,this._vscodeApi=null,this._context=this._detectContext(),this._currentPollInterval=_t[this._context]||L.normal,this._visibilityChangeHandler=null,this._messageHandler=null,this._setupAdaptivePolling(),this._setupVSCodeBridge()}_detectContext(){return typeof acquireVsCodeApi<"u"?"vscode":typeof window<"u"&&window.location?"browser":"cli"}get context(){return this._context}static get POLL_INTERVALS(){return L}_setupAdaptivePolling(){typeof document>"u"||(this._visibilityChangeHandler=()=>{document.hidden?this._setPollInterval(L.background):this._setPollInterval(_t[this._context]||L.normal)},document.addEventListener("visibilitychange",this._visibilityChangeHandler))}_setPollInterval(t){this._currentPollInterval=t,this._pollInterval&&(this.stopPolling(),this.startPolling(null,t))}setPollMode(t){let e=L[t];e&&this._setPollInterval(e)}_setupVSCodeBridge(){if(!(typeof acquireVsCodeApi>"u")){try{this._vscodeApi=acquireVsCodeApi()}catch{console.warn("VS Code API already acquired or unavailable");return}this._messageHandler=t=>{let e=t.data;if(!(!e||!e.type))switch(e.type){case"updateStatus":this._emit(u.STATUS_UPDATE,e.data);break;case"updateTasks":this._emit(u.TASK_UPDATED,e.data);break;case"taskCreated":this._emit(u.TASK_CREATED,e.data);break;case"taskDeleted":this._emit(u.TASK_DELETED,e.data);break;case"projectCreated":this._emit(u.PROJECT_CREATED,e.data);break;case"projectUpdated":this._emit(u.PROJECT_UPDATED,e.data);break;case"agentUpdate":this._emit(u.AGENT_UPDATE,e.data);break;case"logMessage":this._emit(u.LOG_MESSAGE,e.data);break;case"memoryUpdate":this._emit(u.MEMORY_UPDATE,e.data);break;case"connected":this._connected=!0,this._emit(u.CONNECTED,e.data);break;case"disconnected":this._connected=!1,this._emit(u.DISCONNECTED,e.data);break;case"error":this._emit(u.ERROR,e.data);break;case"setPollMode":this.setPollMode(e.data.mode);break;default:this._emit(`api:${e.type}`,e.data)}},window.addEventListener("message",this._messageHandler)}}get isVSCode(){return this._context==="vscode"}postToVSCode(t,e={}){this._vscodeApi&&this._vscodeApi.postMessage({type:t,data:e})}requestRefresh(){this.postToVSCode("requestRefresh")}notifyVSCode(t,e={}){this.postToVSCode("userAction",{action:t,...e})}get baseUrl(){return this.config.baseUrl}set baseUrl(t){this.config.baseUrl=t,this.config.wsUrl=t.replace(/^http/,"ws")+"/ws"}get isConnected(){return this._connected}async connect(){if(!(this._ws&&this._ws.readyState===WebSocket.OPEN))return new Promise((t,e)=>{try{this._ws=new WebSocket(this.config.wsUrl),this._ws.onopen=()=>{this._connected=!0,this._reconnectAttempts=0,this._emit(u.CONNECTED),t()},this._ws.onclose=()=>{this._connected=!1,this._emit(u.DISCONNECTED),this._scheduleReconnect()},this._ws.onerror=i=>{this._emit(u.ERROR,{error:i}),e(i)},this._ws.onmessage=i=>{try{let a=JSON.parse(i.data);this._handleMessage(a)}catch(a){console.error("Failed to parse WebSocket message:",a)}}}catch(i){e(i)}})}disconnect(){this._ws&&(this._ws.close(),this._ws=null),this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._reconnectTimeout&&(clearTimeout(this._reconnectTimeout),this._reconnectTimeout=null),this._connected=!1,this._cleanupGlobalListeners()}_cleanupGlobalListeners(){this._visibilityChangeHandler&&typeof document<"u"&&(document.removeEventListener("visibilitychange",this._visibilityChangeHandler),this._visibilityChangeHandler=null),this._messageHandler&&typeof window<"u"&&(window.removeEventListener("message",this._messageHandler),this._messageHandler=null)}destroy(){this.disconnect()}_scheduleReconnect(){if(this._reconnectTimeout)return;if(this._reconnectAttempts>=this._maxReconnectAttempts){console.warn("WebSocket max reconnect attempts reached, giving up"),this._emit(u.ERROR,{error:"Max reconnect attempts reached"});return}let t=Math.min(this.config.retryDelay*Math.pow(2,this._reconnectAttempts),3e4);this._reconnectAttempts++,this._reconnectTimeout=setTimeout(()=>{this._reconnectTimeout=null,this.connect().catch(()=>{})},t)}_handleMessage(t){if(t.type==="ping"){this._ws&&this._ws.readyState===WebSocket.OPEN&&this._ws.send(JSON.stringify({type:"pong"}));return}let i={connected:u.CONNECTED,status_update:u.STATUS_UPDATE,task_created:u.TASK_CREATED,task_updated:u.TASK_UPDATED,task_deleted:u.TASK_DELETED,task_moved:u.TASK_UPDATED,project_created:u.PROJECT_CREATED,project_updated:u.PROJECT_UPDATED,agent_update:u.AGENT_UPDATE,log:u.LOG_MESSAGE}[t.type]||`api:${t.type}`;this._emit(i,t.data)}_emit(t,e={}){this.dispatchEvent(new CustomEvent(t,{detail:e}))}async _request(t,e={}){let i=`${this.config.baseUrl}${t}`,a=new AbortController,s=setTimeout(()=>a.abort(),this.config.timeout);try{let r=await fetch(i,{...e,signal:a.signal,credentials:"include",headers:{"Content-Type":"application/json",...e.headers}});if(clearTimeout(s),!r.ok){let o=await r.text().catch(()=>""),n=r.statusText||`HTTP ${r.status}`;if(o)try{let l=JSON.parse(o);n=l.detail||l.error||l.message||n}catch{n=o.length>200?o.slice(0,200)+"...":o}throw new Error(n)}return r.status===204?null:await r.json()}catch(r){throw clearTimeout(s),r.name==="AbortError"?new Error("Request timeout"):r}}async _get(t,e=!1){if(e&&this._cache.has(t)){let a=this._cache.get(t);if(Date.now()-a.timestamp<this._cacheTimeout)return a.data}let i=await this._request(t);return e&&this._cache.set(t,{data:i,timestamp:Date.now()}),i}async _post(t,e){return this._request(t,{method:"POST",body:JSON.stringify(e)})}async _put(t,e){return this._request(t,{method:"PUT",body:JSON.stringify(e)})}async _delete(t){return this._request(t,{method:"DELETE"})}async getStatus(){return this._get("/api/status")}async healthCheck(){return this._get("/health")}async listProjects(t=null){let e=t?`?status=${t}`:"";return this._get(`/api/projects${e}`)}async getProject(t){return this._get(`/api/projects/${t}`)}async createProject(t){return this._post("/api/projects",t)}async updateProject(t,e){return this._put(`/api/projects/${t}`,e)}async deleteProject(t){return this._delete(`/api/projects/${t}`)}async listTasks(t={}){let e=new URLSearchParams;t.projectId&&e.append("project_id",t.projectId),t.status&&e.append("status",t.status),t.priority&&e.append("priority",t.priority);let i=e.toString()?`?${e}`:"";return this._get(`/api/tasks${i}`)}async getTask(t){return this._get(`/api/tasks/${t}`)}async createTask(t){return this._post("/api/tasks",t)}async updateTask(t,e){return this._put(`/api/tasks/${t}`,e)}async moveTask(t,e,i){return this._post(`/api/tasks/${t}/move`,{status:e,position:i})}async deleteTask(t){return this._delete(`/api/tasks/${t}`)}async getMemorySummary(){return this._get("/api/memory/summary",!0)}async getMemoryIndex(){return this._get("/api/memory/index",!0)}async getMemoryTimeline(){return this._get("/api/memory/timeline")}async listEpisodes(t={}){let e=new URLSearchParams(t).toString();return this._get(`/api/memory/episodes${e?"?"+e:""}`)}async getEpisode(t){return this._get(`/api/memory/episodes/${t}`)}async listPatterns(t={}){let e=new URLSearchParams(t).toString();return this._get(`/api/memory/patterns${e?"?"+e:""}`)}async getPattern(t){return this._get(`/api/memory/patterns/${t}`)}async listSkills(){return this._get("/api/memory/skills")}async getSkill(t){return this._get(`/api/memory/skills/${t}`)}async retrieveMemories(t,e=null,i=5){return this._post("/api/memory/retrieve",{query:t,taskType:e,topK:i})}async consolidateMemory(t=24){return this._post("/api/memory/consolidate",{sinceHours:t})}async getTokenEconomics(){return this._get("/api/memory/economics")}async searchMemory(t,e="all",i=20){let a=new URLSearchParams({q:t,collection:e,limit:String(i)});return this._get(`/api/memory/search?${a}`)}async getMemoryStats(){return this._get("/api/memory/stats",!0)}async listRegisteredProjects(t=!1){return this._get(`/api/registry/projects?include_inactive=${t}`)}async registerProject(t,e=null,i=null){return this._post("/api/registry/projects",{path:t,name:e,alias:i})}async discoverProjects(t=3){return this._get(`/api/registry/discover?max_depth=${t}`)}async syncRegistry(){return this._post("/api/registry/sync",{})}async getCrossProjectTasks(t=null){let e=t?`?project_ids=${t.join(",")}`:"";return this._get(`/api/registry/tasks${e}`)}async getLearningMetrics(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source);let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/metrics${i}`)}async getLearningTrends(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source);let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/trends${i}`)}async getLearningSignals(t={}){let e=new URLSearchParams;t.timeRange&&e.append("timeRange",t.timeRange),t.signalType&&e.append("signalType",t.signalType),t.source&&e.append("source",t.source),t.limit&&e.append("limit",String(t.limit)),t.offset&&e.append("offset",String(t.offset));let i=e.toString()?`?${e}`:"";return this._get(`/api/learning/signals${i}`)}async getLatestAggregation(){return this._get("/api/learning/aggregation")}async triggerAggregation(t={}){return this._post("/api/learning/aggregate",t)}async getAggregatedPreferences(t=20){return this._get(`/api/learning/preferences?limit=${t}`)}async getAggregatedErrors(t=20){return this._get(`/api/learning/errors?limit=${t}`)}async getAggregatedSuccessPatterns(t=20){return this._get(`/api/learning/success?limit=${t}`)}async getToolEfficiency(t=20){return this._get(`/api/learning/tools?limit=${t}`)}async getCost(){return this._get("/api/cost")}async getPricing(){return this._get("/api/pricing")}async getCouncilState(){return this._get("/api/council/state")}async getCouncilVerdicts(t=20){return this._get(`/api/council/verdicts?limit=${t}`)}async getCouncilConvergence(){return this._get("/api/council/convergence")}async getCouncilReport(){return this._get("/api/council/report")}async forceCouncilReview(){return this._post("/api/council/force-review",{})}async getContext(){return this._get("/api/context")}async getNotifications(t,e){let i=new URLSearchParams;t&&i.set("severity",t),e&&i.set("unread_only","true");let a=i.toString();return this._get("/api/notifications"+(a?"?"+a:""))}async getNotificationTriggers(){return this._get("/api/notifications/triggers")}async updateNotificationTriggers(t){return this._put("/api/notifications/triggers",{triggers:t})}async acknowledgeNotification(t){return this._post("/api/notifications/"+encodeURIComponent(t)+"/acknowledge",{})}async pauseSession(){return this._post("/api/control/pause",{})}async resumeSession(){return this._post("/api/control/resume",{})}async stopSession(){return this._post("/api/control/stop",{})}async getLogs(t=100){return this._get(`/api/logs?lines=${t}`)}async getChecklist(){return this._get("/api/checklist")}async getChecklistSummary(){return this._get("/api/checklist/summary")}async getPrdObservations(){let t=await fetch(`${this.baseUrl}/api/prd-observations`,{credentials:"include"});if(!t.ok)throw new Error(`HTTP ${t.status}`);return t.text()}async getChecklistWaivers(){return this._get("/api/checklist/waivers")}async addChecklistWaiver(t,e,i="dashboard"){return this._post("/api/checklist/waivers",{item_id:t,reason:e,waived_by:i})}async removeChecklistWaiver(t){return this._delete(`/api/checklist/waivers/${encodeURIComponent(t)}`)}async getCouncilGate(){return this._get("/api/council/gate")}async getAppRunnerStatus(){return this._get("/api/app-runner/status")}async getAppRunnerLogs(t=100){return this._get(`/api/app-runner/logs?lines=${t}`)}async restartApp(){return this._post("/api/control/app-restart",{})}async stopApp(){return this._post("/api/control/app-stop",{})}async getPlaywrightResults(){return this._get("/api/playwright/results")}async getPlaywrightScreenshot(){return this._get("/api/playwright/screenshot")}startPolling(t,e=null){if(this._pollInterval)return;this._pollCallback=t;let i=async()=>{try{let s=await this.getStatus();this._connected=!0,this._pollCallback&&this._pollCallback(s),this._emit(u.STATUS_UPDATE,s),this._vscodeApi&&this.postToVSCode("pollSuccess",{timestamp:Date.now()})}catch(s){this._connected=!1,this._emit(u.ERROR,{error:s}),this._vscodeApi&&this.postToVSCode("pollError",{error:s.message})}};i();let a=e||this._currentPollInterval||this.config.pollInterval;this._pollInterval=setInterval(i,a)}stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}};w(C,"_instances",new Map);var R=C;function wt(d={}){return new R(d)}function g(d={}){return R.getInstance(d)}var bt="loki-state-change",mt={ui:{theme:"light",sidebarCollapsed:!1,activeSection:"kanban",terminalAutoScroll:!0},session:{connected:!1,lastSync:null,mode:"offline",phase:null,iteration:null},localTasks:[],cache:{projects:[],tasks:[],agents:[],memory:null,lastFetch:null},preferences:{pollInterval:2e3,notifications:!0,soundEnabled:!1}},$=class $ extends EventTarget{static getInstance(){return $._instance||($._instance=new $),$._instance}constructor(){super(),this._state=this._loadState(),this._subscribers=new Map,this._batchUpdates=[],this._batchTimeout=null}_loadState(){try{let t=localStorage.getItem($.STORAGE_KEY);if(t){let e=JSON.parse(t);return this._mergeState(mt,e)}}catch(t){console.warn("Failed to load state from localStorage:",t)}return{...mt}}_mergeState(t,e){let i={...t};for(let a of Object.keys(e))a in t&&typeof t[a]=="object"&&!Array.isArray(t[a])?i[a]=this._mergeState(t[a],e[a]):i[a]=e[a];return i}_saveState(){try{let t={ui:this._state.ui,localTasks:this._state.localTasks,preferences:this._state.preferences};localStorage.setItem($.STORAGE_KEY,JSON.stringify(t))}catch(t){console.warn("Failed to save state to localStorage:",t)}}get(t=null){if(!t)return{...this._state};let e=t.split("."),i=this._state;for(let a of e){if(i==null)return;i=i[a]}return i}set(t,e,i=!0){let a=t.split("."),s=a.pop(),r=this._state;for(let n of a)n in r||(r[n]={}),r=r[n];let o=r[s];r[s]=e,i&&this._saveState(),this._notifyChange(t,e,o)}update(t,e=!0){let i=[];for(let[a,s]of Object.entries(t)){let r=this.get(a);this.set(a,s,!1),i.push({path:a,value:s,oldValue:r})}e&&this._saveState();for(let a of i)this._notifyChange(a.path,a.value,a.oldValue)}_notifyChange(t,e,i){this.dispatchEvent(new CustomEvent(bt,{detail:{path:t,value:e,oldValue:i}}));let a=this._subscribers.get(t)||[];for(let r of a)try{r(e,i,t)}catch(o){console.error("State subscriber error:",o)}let s=t.split(".");for(;s.length>1;){s.pop();let r=s.join("."),o=this._subscribers.get(r)||[];for(let n of o)try{n(this.get(r),null,r)}catch(l){console.error("State subscriber error:",l)}}}subscribe(t,e){return this._subscribers.has(t)||this._subscribers.set(t,[]),this._subscribers.get(t).push(e),()=>{let i=this._subscribers.get(t),a=i.indexOf(e);a>-1&&i.splice(a,1)}}reset(t=null){if(t){let e=t.split("."),i=mt;for(let a of e)i=i?.[a];this.set(t,i)}else this._state={...mt},this._saveState(),this.dispatchEvent(new CustomEvent(bt,{detail:{path:null,value:this._state,oldValue:null}}))}addLocalTask(t){let e=this.get("localTasks")||[],i={id:`local-${Date.now()}-${Math.random().toString(36).substr(2,9)}`,createdAt:new Date().toISOString(),status:"pending",...t};return this.set("localTasks",[...e,i]),i}updateLocalTask(t,e){let i=this.get("localTasks")||[],a=i.findIndex(r=>r.id===t);if(a===-1)return null;let s={...i[a],...e,updatedAt:new Date().toISOString()};return i[a]=s,this.set("localTasks",[...i]),s}deleteLocalTask(t){let e=this.get("localTasks")||[];this.set("localTasks",e.filter(i=>i.id!==t))}moveLocalTask(t,e,i=null){let s=(this.get("localTasks")||[]).find(r=>r.id===t);return s?this.updateLocalTask(t,{status:e,position:i??s.position}):null}updateSession(t){this.update(Object.fromEntries(Object.entries(t).map(([e,i])=>[`session.${e}`,i])),!1)}updateCache(t){this.update({"cache.projects":t.projects??this.get("cache.projects"),"cache.tasks":t.tasks??this.get("cache.tasks"),"cache.agents":t.agents??this.get("cache.agents"),"cache.memory":t.memory??this.get("cache.memory"),"cache.lastFetch":new Date().toISOString()},!1)}getMergedTasks(){let t=this.get("cache.tasks")||[],i=(this.get("localTasks")||[]).map(a=>({...a,isLocal:!0}));return[...t,...i]}getTasksByStatus(t){return this.getMergedTasks().filter(e=>e.status===t)}};w($,"STORAGE_KEY","loki-dashboard-state"),w($,"_instance",null);var F=$;function z(){return F.getInstance()}function $t(d){let t=z();return{get:()=>t.get(d),set:e=>t.set(d,e),subscribe:e=>t.subscribe(d,e)}}var j=class extends h{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._data={status:"offline",phase:null,iteration:null,provider:null,running_agents:0,pending_tasks:null,uptime_seconds:0,complexity:null,connected:!1},this._api=null,this._pollInterval=null,this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null,this._checklistSummary=null,this._appRunnerStatus=null,this._playwrightResults=null,this._gateStatus=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._startPolling(),this._api.connect().catch(()=>{})}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._loadAbortController&&(this._loadAbortController.abort(),this._loadAbortController=null),this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(u.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(u.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadStatus()),t==="theme"&&this._applyTheme())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._statusUpdateHandler=e=>this._updateFromStatus(e.detail),this._connectedHandler=()=>{this._data.connected=!0,this.render()},this._disconnectedHandler=()=>{this._data.connected=!1,this._data.status="offline",this.render()},this._api.addEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(u.CONNECTED,this._connectedHandler),this._api.addEventListener(u.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){this._loadAbortController&&this._loadAbortController.abort(),this._loadAbortController=new AbortController;let{signal:t}=this._loadAbortController;try{let[e,i,a,s,r]=await Promise.allSettled([this._api.getStatus(),this._api.getChecklistSummary(),this._api.getAppRunnerStatus(),this._api.getPlaywrightResults(),this._api.getCouncilGate()]);if(t.aborted)return;e.status==="fulfilled"?this._updateFromStatus(e.value):(this._data.connected=!1,this._data.status="offline"),i.status==="fulfilled"&&(this._checklistSummary=i.value?.summary||null),a.status==="fulfilled"&&(this._appRunnerStatus=a.value),s.status==="fulfilled"&&(this._playwrightResults=s.value),r.status==="fulfilled"&&(this._gateStatus=r.value),this.render()}catch{if(t.aborted)return;this._data.connected=!1,this._data.status="offline",this.render()}}_updateFromStatus(t){t&&(this._data={...this._data,connected:!0,status:t.status||"offline",phase:t.phase||null,iteration:t.iteration!=null?t.iteration:null,provider:t.provider||null,running_agents:t.running_agents||0,pending_tasks:t.pending_tasks!=null?t.pending_tasks:null,uptime_seconds:t.uptime_seconds||0,complexity:t.complexity||null})}_startPolling(){this._pollInterval=setInterval(async()=>{try{await this._loadStatus()}catch{this._data.connected=!1,this._data.status="offline",this.render()}},5e3)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null)}_formatUptime(t){if(!t||t<0)return"--";let e=Math.floor(t/3600),i=Math.floor(t%3600/60),a=Math.floor(t%60);return e>0?`${e}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_getStatusDotClass(){switch(this._data.status){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_escapeHtml(t){return t?String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"):""}_renderAppRunnerCard(){let t=this._appRunnerStatus;if(!t||t.status==="not_initialized")return`
1241
1241
  <div class="overview-card">
1242
1242
  <div class="card-label">App Runner</div>
1243
1243
  <div class="card-value small-text">${this._data.status==="running"||this._data.status==="autonomous"?"Waiting...":"Not started"}</div>
@@ -2028,7 +2028,7 @@ var LokiDashboard=(()=>{var pt=Object.defineProperty;var Pt=Object.getOwnPropert
2028
2028
  ${i}
2029
2029
  </div>
2030
2030
  ${this._selectedTask?this._renderTaskDetailModal(this._selectedTask):""}
2031
- `,this._attachEventListeners()}_attachEventListeners(){let t=this.shadowRoot.getElementById("refresh-btn");t&&t.addEventListener("click",()=>this._loadTasks()),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(a=>{a.addEventListener("click",()=>{this._openAddTaskModal(a.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(a=>{let s=a.dataset.taskId,r=this._tasks.find(o=>o.id.toString()===s);r&&(a.addEventListener("click",()=>this._openTaskDetail(r)),a.addEventListener("keydown",o=>{o.key==="Enter"||o.key===" "?(o.preventDefault(),this._openTaskDetail(r)):(o.key==="ArrowDown"||o.key==="ArrowUp")&&(o.preventDefault(),this._navigateTaskCards(a,o.key==="ArrowDown"?"next":"prev"))}),a.classList.contains("draggable")&&(a.addEventListener("dragstart",o=>this._handleDragStart(o,r)),a.addEventListener("dragend",o=>this._handleDragEnd(o))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(a=>{a.addEventListener("dragover",s=>this._handleDragOver(s)),a.addEventListener("dragenter",s=>this._handleDragEnter(s)),a.addEventListener("dragleave",s=>this._handleDragLeave(s)),a.addEventListener("drop",s=>this._handleDrop(s,a.dataset.status))});let e=this.shadowRoot.getElementById("modal-close-btn");e&&e.addEventListener("click",()=>this._closeTaskDetail());let i=this.shadowRoot.getElementById("task-detail-overlay");i&&i.addEventListener("click",a=>{a.target===i&&this._closeTaskDetail()})}_escapeHtml(t){let e=document.createElement("div");return e.textContent=t,e.innerHTML}_navigateTaskCards(t,e){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(t);if(a===-1)return;let s=e==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",U);var O=class extends h{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._api=null,this._state=z(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(u.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(u.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadStatus()),t==="theme"&&this._applyTheme(),t==="compact"&&this.render())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._statusUpdateHandler=e=>this._updateFromStatus(e.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(u.CONNECTED,this._connectedHandler),this._api.addEventListener(u.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){try{let t=await this._api.getStatus();this._updateFromStatus(t)}catch{this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(t){t&&(this._status={...this._status,connected:!0,mode:t.status||"running",version:t.version,uptime:t.uptime_seconds||0,activeAgents:t.running_agents||0,pendingTasks:t.pending_tasks||0,phase:t.phase,iteration:t.iteration,complexity:t.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._ownPollInterval=setInterval(async()=>{try{let t=await this._api.getStatus();this._updateFromStatus(t)}catch{this._status.connected=!1,this._status.mode="offline",this.render()}},3e3)}_stopPolling(){this._ownPollInterval&&(clearInterval(this._ownPollInterval),this._ownPollInterval=null)}_formatUptime(t){if(!t||t<0)return"--";let e=Math.floor(t/3600),i=Math.floor(t%3600/60),a=Math.floor(t%60);return e>0?`${e}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(t){let e=document.createElement("div");return e.textContent=String(t??""),e.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}_triggerStart(){this.dispatchEvent(new CustomEvent("session-start",{detail:this._status}))}async _triggerPause(){try{await this._api.pauseSession(),this._status.mode="paused",this.render()}catch(t){console.error("Failed to pause session:",t)}this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}async _triggerResume(){try{await this._api.resumeSession(),this._status.mode="running",this.render()}catch(t){console.error("Failed to resume session:",t)}this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}async _triggerStop(){try{await this._api.stopSession(),this._status.mode="stopped",this.render()}catch(t){console.error("Failed to stop session:",t)}this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}render(){let t=this.hasAttribute("compact"),e=this._getStatusClass(),i=this._getStatusLabel(),a=["running","autonomous"].includes(this._status.mode),s=this._status.mode==="paused",r=`
2031
+ `,this._attachEventListeners()}_attachEventListeners(){let t=this.shadowRoot.getElementById("refresh-btn");t&&t.addEventListener("click",()=>this._loadTasks()),this.shadowRoot.querySelectorAll(".add-task-btn").forEach(a=>{a.addEventListener("click",()=>{this._openAddTaskModal(a.dataset.status)})}),this.shadowRoot.querySelectorAll(".task-card").forEach(a=>{let s=a.dataset.taskId,r=this._tasks.find(o=>o.id.toString()===s);r&&(a.addEventListener("click",()=>this._openTaskDetail(r)),a.addEventListener("keydown",o=>{o.key==="Enter"||o.key===" "?(o.preventDefault(),this._openTaskDetail(r)):(o.key==="ArrowDown"||o.key==="ArrowUp")&&(o.preventDefault(),this._navigateTaskCards(a,o.key==="ArrowDown"?"next":"prev"))}),a.classList.contains("draggable")&&(a.addEventListener("dragstart",o=>this._handleDragStart(o,r)),a.addEventListener("dragend",o=>this._handleDragEnd(o))))}),this.shadowRoot.querySelectorAll(".kanban-tasks").forEach(a=>{a.addEventListener("dragover",s=>this._handleDragOver(s)),a.addEventListener("dragenter",s=>this._handleDragEnter(s)),a.addEventListener("dragleave",s=>this._handleDragLeave(s)),a.addEventListener("drop",s=>this._handleDrop(s,a.dataset.status))});let e=this.shadowRoot.getElementById("modal-close-btn");e&&e.addEventListener("click",()=>this._closeTaskDetail());let i=this.shadowRoot.getElementById("task-detail-overlay");i&&i.addEventListener("click",a=>{a.target===i&&this._closeTaskDetail()})}_escapeHtml(t){let e=document.createElement("div");return e.textContent=t,e.innerHTML}_navigateTaskCards(t,e){let i=Array.from(this.shadowRoot.querySelectorAll(".task-card")),a=i.indexOf(t);if(a===-1)return;let s=e==="next"?a+1:a-1;s>=0&&s<i.length&&i[s].focus()}};customElements.get("loki-task-board")||customElements.define("loki-task-board",U);var O=class extends h{static get observedAttributes(){return["api-url","theme","compact"]}constructor(){super(),this._status={mode:"offline",phase:null,iteration:null,complexity:null,connected:!1,version:null,uptime:0,activeAgents:0,pendingTasks:0},this._api=null,this._state=z(),this._statusUpdateHandler=null,this._connectedHandler=null,this._disconnectedHandler=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadStatus(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling(),this._api&&(this._statusUpdateHandler&&this._api.removeEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._connectedHandler&&this._api.removeEventListener(u.CONNECTED,this._connectedHandler),this._disconnectedHandler&&this._api.removeEventListener(u.DISCONNECTED,this._disconnectedHandler))}attributeChangedCallback(t,e,i){e!==i&&(t==="api-url"&&this._api&&(this._api.baseUrl=i,this._loadStatus()),t==="theme"&&this._applyTheme(),t==="compact"&&this.render())}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._statusUpdateHandler=e=>this._updateFromStatus(e.detail),this._connectedHandler=()=>{this._status.connected=!0,this.render()},this._disconnectedHandler=()=>{this._status.connected=!1,this._status.mode="offline",this.render()},this._api.addEventListener(u.STATUS_UPDATE,this._statusUpdateHandler),this._api.addEventListener(u.CONNECTED,this._connectedHandler),this._api.addEventListener(u.DISCONNECTED,this._disconnectedHandler)}async _loadStatus(){try{let t=await this._api.getStatus();this._updateFromStatus(t)}catch{this._status.connected=!1,this._status.mode="offline",this.render()}}_updateFromStatus(t){t&&(this._status={...this._status,connected:!0,mode:t.status||"running",version:t.version,uptime:t.uptime_seconds||0,activeAgents:t.running_agents||0,pendingTasks:t.pending_tasks||0,phase:t.phase,iteration:t.iteration,complexity:t.complexity},this._state.updateSession({connected:!0,mode:this._status.mode,lastSync:new Date().toISOString()}),this.render())}_startPolling(){this._ownPollInterval=setInterval(async()=>{try{let t=await this._api.getStatus();this._updateFromStatus(t)}catch{this._status.connected=!1,this._status.mode="offline",this.render()}},3e3)}_stopPolling(){this._ownPollInterval&&(clearInterval(this._ownPollInterval),this._ownPollInterval=null)}_formatUptime(t){if(!t||t<0)return"--";let e=Math.floor(t/3600),i=Math.floor(t%3600/60),a=Math.floor(t%60);return e>0?`${e}h ${i}m`:i>0?`${i}m ${a}s`:`${a}s`}_escapeHtml(t){let e=document.createElement("div");return e.textContent=String(t??""),e.innerHTML}_getStatusClass(){switch(this._status.mode){case"running":case"autonomous":return"active";case"paused":return"paused";case"stopped":return"stopped";case"error":return"error";default:return"offline"}}_getStatusLabel(){switch(this._status.mode){case"running":case"autonomous":return"AUTONOMOUS";case"paused":return"PAUSED";case"stopped":return"STOPPED";case"error":return"ERROR";default:return"OFFLINE"}}_triggerStart(){this.dispatchEvent(new CustomEvent("session-start",{detail:this._status}))}async _triggerPause(){try{let t=await this._api.pauseSession();if(t&&t.error)throw new Error(t.error);this._status.mode="paused",this.render(),this.dispatchEvent(new CustomEvent("session-pause",{detail:this._status}))}catch(t){console.error("Failed to pause session:",t),this.render()}}async _triggerResume(){try{let t=await this._api.resumeSession();if(t&&t.error)throw new Error(t.error);this._status.mode="running",this.render(),this.dispatchEvent(new CustomEvent("session-resume",{detail:this._status}))}catch(t){console.error("Failed to resume session:",t),this.render()}}async _triggerStop(){try{let t=await this._api.stopSession();if(t&&t.error)throw new Error(t.error);this._status.mode="stopped",this.render(),this.dispatchEvent(new CustomEvent("session-stop",{detail:this._status}))}catch(t){console.error("Failed to stop session:",t),this.render()}}render(){let t=this.hasAttribute("compact"),e=this._getStatusClass(),i=this._getStatusLabel(),a=["running","autonomous"].includes(this._status.mode),s=this._status.mode==="paused",r=`
2032
2032
  <style>
2033
2033
  ${this.getBaseStyles()}
2034
2034
 
@@ -2307,7 +2307,7 @@ var LokiDashboard=(()=>{var pt=Object.defineProperty;var Pt=Object.getOwnPropert
2307
2307
  `;this.shadowRoot.innerHTML=`
2308
2308
  ${r}
2309
2309
  ${t?o:n}
2310
- `,this._attachEventListeners()}_attachEventListeners(){let t=this.shadowRoot.getElementById("pause-btn"),e=this.shadowRoot.getElementById("resume-btn"),i=this.shadowRoot.getElementById("stop-btn"),a=this.shadowRoot.getElementById("start-btn");t&&t.addEventListener("click",()=>this._triggerPause()),e&&e.addEventListener("click",()=>this._triggerResume()),i&&i.addEventListener("click",()=>this._triggerStop()),a&&a.addEventListener("click",()=>this._triggerStart())}};customElements.get("loki-session-control")||customElements.define("loki-session-control",O);var Et={info:{color:"var(--loki-blue)",label:"INFO"},success:{color:"var(--loki-green)",label:"SUCCESS"},warning:{color:"var(--loki-yellow)",label:"WARN"},error:{color:"var(--loki-red)",label:"ERROR"},step:{color:"var(--loki-purple)",label:"STEP"},agent:{color:"var(--loki-accent)",label:"AGENT"},debug:{color:"var(--loki-text-muted)",label:"DEBUG"}},N=class extends h{static get observedAttributes(){return["api-url","max-lines","auto-scroll","theme","log-file"]}constructor(){super(),this._logs=[],this._maxLines=500,this._autoScroll=!0,this._filter="",this._levelFilter="all",this._api=null,this._pollInterval=null,this._logMessageHandler=null}connectedCallback(){super.connectedCallback(),this._maxLines=parseInt(this.getAttribute("max-lines"))||500,this._autoScroll=this.hasAttribute("auto-scroll"),this._setupApi(),this._startLogPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopLogPolling(),this._api&&this._logMessageHandler&&this._api.removeEventListener(u.LOG_MESSAGE,this._logMessageHandler)}attributeChangedCallback(t,e,i){if(e!==i)switch(t){case"api-url":this._api&&(this._api.baseUrl=i);break;case"max-lines":this._maxLines=parseInt(i)||500,this._trimLogs(),this.render();break;case"auto-scroll":this._autoScroll=this.hasAttribute("auto-scroll"),this.render();break;case"theme":this._applyTheme();break}}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._logMessageHandler=e=>this._addLog(e.detail),this._api.addEventListener(u.LOG_MESSAGE,this._logMessageHandler)}_startLogPolling(){let t=this.getAttribute("log-file");t?this._pollLogFile(t):this._pollApiLogs()}async _pollApiLogs(){let t=0,e=async()=>{try{let i=await this._api.getLogs(200);if(Array.isArray(i)&&i.length>t){let a=i.slice(t);for(let s of a)s.message&&s.message.trim()&&this._addLog({message:s.message,level:s.level||"info",timestamp:s.timestamp||new Date().toLocaleTimeString()});t=i.length}}catch{}};e(),this._apiPollInterval=setInterval(e,2e3)}async _pollLogFile(t){let e=0,i=async()=>{try{let a=await fetch(`${t}?t=${Date.now()}`);if(!a.ok)return;let r=(await a.text()).split(`
2310
+ `,this._attachEventListeners()}_attachEventListeners(){let t=this.shadowRoot.getElementById("pause-btn"),e=this.shadowRoot.getElementById("resume-btn"),i=this.shadowRoot.getElementById("stop-btn"),a=this.shadowRoot.getElementById("start-btn");t&&t.addEventListener("click",()=>this._triggerPause()),e&&e.addEventListener("click",()=>this._triggerResume()),i&&i.addEventListener("click",()=>this._triggerStop()),a&&a.addEventListener("click",()=>this._triggerStart())}};customElements.get("loki-session-control")||customElements.define("loki-session-control",O);var Et={info:{color:"var(--loki-blue)",label:"INFO"},success:{color:"var(--loki-green)",label:"SUCCESS"},warning:{color:"var(--loki-yellow)",label:"WARN"},error:{color:"var(--loki-red)",label:"ERROR"},step:{color:"var(--loki-purple)",label:"STEP"},agent:{color:"var(--loki-accent)",label:"AGENT"},debug:{color:"var(--loki-text-muted)",label:"DEBUG"}},N=class extends h{static get observedAttributes(){return["api-url","max-lines","auto-scroll","theme","log-file"]}constructor(){super(),this._logs=[],this._maxLines=500,this._autoScroll=!0,this._filter="",this._levelFilter="all",this._api=null,this._pollInterval=null,this._logMessageHandler=null}connectedCallback(){super.connectedCallback(),this._maxLines=parseInt(this.getAttribute("max-lines"))||500,this._autoScroll=this.hasAttribute("auto-scroll"),this._setupApi(),this._startLogPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopLogPolling(),this._api&&this._logMessageHandler&&this._api.removeEventListener(u.LOG_MESSAGE,this._logMessageHandler)}attributeChangedCallback(t,e,i){if(e!==i)switch(t){case"api-url":this._api&&(this._api.baseUrl=i);break;case"max-lines":this._maxLines=parseInt(i)||500,this._trimLogs(),this.render();break;case"auto-scroll":this._autoScroll=this.hasAttribute("auto-scroll"),this.render();break;case"theme":this._applyTheme();break}}_setupApi(){let t=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:t}),this._logMessageHandler=e=>this._addLog(e.detail),this._api.addEventListener(u.LOG_MESSAGE,this._logMessageHandler)}_startLogPolling(){let t=this.getAttribute("log-file");t?this._pollLogFile(t):this._pollApiLogs()}async _pollApiLogs(){let t=0,e=async()=>{try{let i=await this._api.getLogs(200);if(Array.isArray(i)&&i.length>t){let a=i.slice(t);for(let s of a)s.message&&s.message.trim()&&this._addLog({message:s.message,level:s.level||"info",timestamp:s.timestamp||new Date().toLocaleTimeString()});t=i.length}}catch{}};e(),this._apiPollInterval=setInterval(e,2e3)}async _pollLogFile(t){let e=0,i=async()=>{try{let a=await fetch(`${t}?t=${Date.now()}`,{credentials:"include"});if(!a.ok)return;let r=(await a.text()).split(`
2311
2311
  `);if(r.length>e){let o=r.slice(e);for(let n of o)n.trim()&&this._addLog(this._parseLine(n));e=r.length}}catch{}};i(),this._pollInterval=setInterval(i,1e3)}_stopLogPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._apiPollInterval&&(clearInterval(this._apiPollInterval),this._apiPollInterval=null)}_parseLine(t){let e=t.match(/^\[([^\]]+)\]\s*\[([^\]]+)\]\s*(.+)$/);if(e)return{timestamp:e[1],level:e[2].toLowerCase(),message:e[3]};let i=t.match(/^(\d{2}:\d{2}:\d{2})\s+(\w+)\s+(.+)$/);return i?{timestamp:i[1],level:i[2].toLowerCase(),message:i[3]}:{timestamp:new Date().toLocaleTimeString(),level:"info",message:t}}_addLog(t){if(!t)return;let e={id:Date.now()+Math.random(),timestamp:t.timestamp||new Date().toLocaleTimeString(),level:(t.level||"info").toLowerCase(),message:t.message||t};this._logs.push(e),this._trimLogs(),this.dispatchEvent(new CustomEvent("log-received",{detail:e})),this._renderLogs(),this._autoScroll&&this._scrollToBottom()}_trimLogs(){this._logs.length>this._maxLines&&(this._logs=this._logs.slice(-this._maxLines))}_clearLogs(){this._logs=[],this.dispatchEvent(new CustomEvent("logs-cleared")),this._renderLogs()}_toggleAutoScroll(){this._autoScroll=!this._autoScroll,this.render(),this._autoScroll&&this._scrollToBottom()}_scrollToBottom(){requestAnimationFrame(()=>{let t=this.shadowRoot.getElementById("log-output");t&&(t.scrollTop=t.scrollHeight)})}_downloadLogs(){let t=this._logs.map(s=>`[${s.timestamp}] [${s.level.toUpperCase()}] ${s.message}`).join(`
2312
2312
  `),e=new Blob([t],{type:"text/plain"}),i=URL.createObjectURL(e),a=document.createElement("a");a.href=i,a.download=`loki-logs-${new Date().toISOString().split("T")[0]}.txt`,a.click(),URL.revokeObjectURL(i)}_setFilter(t){this._filter=t.toLowerCase(),this._renderLogs()}_setLevelFilter(t){this._levelFilter=t,this._renderLogs()}_getFilteredLogs(){return this._logs.filter(t=>!(this._levelFilter!=="all"&&t.level!==this._levelFilter||this._filter&&!t.message.toLowerCase().includes(this._filter)))}_renderLogs(){let t=this.shadowRoot.getElementById("log-output");if(!t)return;let e=this._getFilteredLogs();if(e.length===0){t.innerHTML='<div class="log-empty">No log output yet. Terminal will update when Loki Mode is running.</div>';return}t.innerHTML=e.map(i=>{let a=Et[i.level]||Et.info;return`
2313
2313
  <div class="log-line">
@@ -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.37.2
5
+ **Version:** v6.37.4
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.37.2'
60
+ __version__ = '6.37.4'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.37.2",
3
+ "version": "6.37.4",
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
@@ -685,25 +685,29 @@ def _run_loki_cmd(args: list, cwd: Optional[str] = None, timeout: int = 60) -> t
685
685
  """Run a loki CLI command and return (returncode, combined output).
686
686
 
687
687
  Uses list form -- never shell=True with user input.
688
+ On timeout, the subprocess is explicitly killed to avoid orphaned processes.
688
689
  """
689
690
  loki = _find_loki_cli()
690
691
  if loki is None:
691
692
  return (1, "loki CLI not found")
692
693
  full_cmd = [loki] + args
693
694
  try:
694
- result = subprocess.run(
695
+ proc = subprocess.Popen(
695
696
  full_cmd,
696
697
  stdout=subprocess.PIPE,
697
698
  stderr=subprocess.STDOUT,
698
699
  stdin=subprocess.DEVNULL,
699
700
  text=True,
700
701
  cwd=cwd or session.project_dir or str(Path.home()),
701
- timeout=timeout,
702
702
  env={**os.environ},
703
703
  )
704
- return (result.returncode, result.stdout or "")
705
- except subprocess.TimeoutExpired:
706
- return (1, "Command timed out")
704
+ try:
705
+ stdout, _ = proc.communicate(timeout=timeout)
706
+ return (proc.returncode, stdout or "")
707
+ except subprocess.TimeoutExpired:
708
+ proc.kill()
709
+ proc.wait()
710
+ return (1, "Command timed out")
707
711
  except Exception as e:
708
712
  return (1, str(e))
709
713
 
@@ -1154,7 +1158,10 @@ async def _push_state_to_client(ws: WebSocket) -> None:
1154
1158
  """Background task: push state snapshots to a single WebSocket client.
1155
1159
 
1156
1160
  Pushes every 2s when a session is running, every 30s when idle.
1161
+ Sends only incremental log deltas (new lines since last push) instead
1162
+ of the full log buffer each time.
1157
1163
  """
1164
+ last_log_index = max(len(session.log_lines) - 100, 0) # backfill handled on connect
1158
1165
  while True:
1159
1166
  is_running = (
1160
1167
  session.process is not None
@@ -1214,10 +1221,12 @@ async def _push_state_to_client(ws: WebSocket) -> None:
1214
1221
  except (json.JSONDecodeError, OSError):
1215
1222
  pass
1216
1223
 
1217
- # Build logs payload (last 50 lines)
1218
- recent = session.log_lines[-50:] if session.log_lines else []
1224
+ # Build incremental logs payload (only new lines since last push)
1225
+ current_len = len(session.log_lines)
1226
+ new_lines = session.log_lines[last_log_index:current_len] if current_len > last_log_index else []
1227
+ last_log_index = current_len
1219
1228
  logs_payload = []
1220
- for line in recent:
1229
+ for line in new_lines:
1221
1230
  level = "info"
1222
1231
  lower = line.lower()
1223
1232
  if "error" in lower or "fail" in lower:
@@ -1271,17 +1280,30 @@ async def websocket_endpoint(ws: WebSocket) -> None:
1271
1280
  # Start server-push state task for this connection
1272
1281
  push_task = asyncio.create_task(_push_state_to_client(ws))
1273
1282
 
1283
+ missed_pongs = 0
1274
1284
  try:
1275
1285
  while True:
1276
- # Keep connection alive; handle client messages if needed
1277
- data = await ws.receive_text()
1278
- # Could handle commands here (e.g., stop session)
1279
1286
  try:
1280
- msg = json.loads(data)
1281
- if msg.get("type") == "ping":
1282
- await ws.send_text(json.dumps({"type": "pong"}))
1283
- except json.JSONDecodeError:
1284
- pass
1287
+ data = await asyncio.wait_for(ws.receive_text(), timeout=60.0)
1288
+ missed_pongs = 0 # any message resets idle counter
1289
+ try:
1290
+ msg = json.loads(data)
1291
+ if msg.get("type") == "ping":
1292
+ await ws.send_text(json.dumps({"type": "pong"}))
1293
+ elif msg.get("type") == "pong":
1294
+ pass # client responded to our ping
1295
+ except json.JSONDecodeError:
1296
+ pass
1297
+ except asyncio.TimeoutError:
1298
+ # No message for 60s -- send a ping
1299
+ missed_pongs += 1
1300
+ if missed_pongs >= 2:
1301
+ # Two consecutive pings with no reply -- close idle connection
1302
+ break
1303
+ try:
1304
+ await ws.send_text(json.dumps({"type": "ping"}))
1305
+ except Exception:
1306
+ break
1285
1307
  except WebSocketDisconnect:
1286
1308
  pass
1287
1309
  finally: