loki-mode 6.44.1 → 6.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
10
  <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
11
- <script type="module" crossorigin src="/assets/index-DvhjaUe4.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-BLBd5LBk.css">
11
+ <script type="module" crossorigin src="/assets/index-HocOQZ9L.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BZpAV5M8.css">
13
13
  </head>
14
14
  <body class="bg-background text-ink font-sans antialiased">
15
15
  <div id="root"></div>
package/web-app/server.py CHANGED
@@ -12,10 +12,12 @@ import asyncio
12
12
  import inspect
13
13
  import json
14
14
  import os
15
+ import re
15
16
  import signal
16
17
  import subprocess
17
18
  import sys
18
19
  import time
20
+ import uuid
19
21
  from pathlib import Path
20
22
  from typing import Optional
21
23
 
@@ -109,46 +111,85 @@ class SessionState:
109
111
  self.log_lines = []
110
112
 
111
113
 
112
- def _kill_orphan_loki_processes(project_dir: str = "") -> None:
113
- """Kill any orphaned loki-run processes."""
114
+ def _kill_tracked_child_processes() -> None:
115
+ """Kill only processes that Purple Lab started, not external loki sessions."""
114
116
  import subprocess as _sp
115
- patterns: list[str] = []
116
- if project_dir:
117
- patterns.append(f"loki-run.*{project_dir}")
118
- patterns.append("loki-run-")
117
+ tracked = _get_tracked_child_pids()
118
+ if not tracked:
119
+ return
119
120
 
120
- killed_pids: set[int] = set()
121
- for pattern in patterns:
121
+ for pid in tracked:
122
122
  try:
123
- result = _sp.run(
124
- ["pgrep", "-f", pattern],
125
- capture_output=True, text=True, timeout=5,
126
- )
127
- if result.returncode == 0:
128
- for pid_str in result.stdout.strip().splitlines():
129
- try:
130
- pid = int(pid_str.strip())
131
- if pid not in killed_pids:
132
- os.kill(pid, signal.SIGTERM)
133
- killed_pids.add(pid)
134
- except (ValueError, ProcessLookupError, PermissionError):
135
- pass
136
- except Exception:
123
+ # Kill the entire process tree (children first, then parent)
124
+ _sp.run(["pkill", "-TERM", "-P", str(pid)],
125
+ capture_output=True, timeout=5)
126
+ os.kill(pid, signal.SIGTERM)
127
+ except (ProcessLookupError, PermissionError, OSError):
137
128
  pass
138
129
 
139
- # Wait briefly then SIGKILL any survivors
140
- if killed_pids:
141
- import time as _time
142
- _time.sleep(2)
143
- for pid in killed_pids:
144
- try:
145
- os.kill(pid, signal.SIGKILL)
146
- except (ProcessLookupError, PermissionError, OSError):
147
- pass
130
+ # Wait briefly then SIGKILL survivors
131
+ import time as _time
132
+ _time.sleep(2)
133
+ for pid in tracked:
134
+ try:
135
+ _sp.run(["pkill", "-9", "-P", str(pid)],
136
+ capture_output=True, timeout=5)
137
+ os.kill(pid, signal.SIGKILL)
138
+ except (ProcessLookupError, PermissionError, OSError):
139
+ pass
140
+
141
+ _clear_tracked_pids()
148
142
 
149
143
 
150
144
  session = SessionState()
151
145
 
146
+ # Track PIDs of sessions started by Purple Lab (not by external loki CLI)
147
+ _PURPLE_LAB_PIDS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "child-pids.json"
148
+
149
+
150
+ def _track_child_pid(pid: int) -> None:
151
+ """Record a PID started by Purple Lab so loki web stop can clean it up."""
152
+ _PURPLE_LAB_PIDS_FILE.parent.mkdir(parents=True, exist_ok=True)
153
+ pids: list[int] = []
154
+ if _PURPLE_LAB_PIDS_FILE.exists():
155
+ try:
156
+ pids = json.loads(_PURPLE_LAB_PIDS_FILE.read_text())
157
+ except (json.JSONDecodeError, OSError):
158
+ pids = []
159
+ if pid not in pids:
160
+ pids.append(pid)
161
+ _PURPLE_LAB_PIDS_FILE.write_text(json.dumps(pids))
162
+
163
+
164
+ def _untrack_child_pid(pid: int) -> None:
165
+ """Remove a PID from tracking after it exits."""
166
+ if not _PURPLE_LAB_PIDS_FILE.exists():
167
+ return
168
+ try:
169
+ pids = json.loads(_PURPLE_LAB_PIDS_FILE.read_text())
170
+ pids = [p for p in pids if p != pid]
171
+ _PURPLE_LAB_PIDS_FILE.write_text(json.dumps(pids))
172
+ except (json.JSONDecodeError, OSError):
173
+ pass
174
+
175
+
176
+ def _get_tracked_child_pids() -> list[int]:
177
+ """Get all PIDs started by Purple Lab."""
178
+ if not _PURPLE_LAB_PIDS_FILE.exists():
179
+ return []
180
+ try:
181
+ return json.loads(_PURPLE_LAB_PIDS_FILE.read_text())
182
+ except (json.JSONDecodeError, OSError):
183
+ return []
184
+
185
+
186
+ def _clear_tracked_pids() -> None:
187
+ """Clear all tracked PIDs."""
188
+ try:
189
+ _PURPLE_LAB_PIDS_FILE.unlink(missing_ok=True)
190
+ except OSError:
191
+ pass
192
+
152
193
  # ---------------------------------------------------------------------------
153
194
  # Request / Response models
154
195
  # ---------------------------------------------------------------------------
@@ -203,6 +244,11 @@ class ChatRequest(BaseModel):
203
244
  message: str
204
245
  mode: str = "quick" # "quick" or "standard"
205
246
 
247
+
248
+ class SecretRequest(BaseModel):
249
+ key: str
250
+ value: str
251
+
206
252
  # ---------------------------------------------------------------------------
207
253
  # Helpers
208
254
  # ---------------------------------------------------------------------------
@@ -328,6 +374,48 @@ def _build_file_tree(root: Path, max_depth: int = 4, _depth: int = 0) -> list[di
328
374
  entries.append(node)
329
375
  return entries
330
376
 
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Secrets management (plaintext -- this is a local dev tool, not a vault)
380
+ # ---------------------------------------------------------------------------
381
+
382
+ _SECRETS_FILE = SCRIPT_DIR.parent / ".loki" / "purple-lab" / "secrets.json"
383
+
384
+
385
+ def _load_secrets() -> dict[str, str]:
386
+ """Load secrets from disk."""
387
+ if _SECRETS_FILE.exists():
388
+ try:
389
+ data = json.loads(_SECRETS_FILE.read_text())
390
+ if isinstance(data, dict):
391
+ return data
392
+ except (json.JSONDecodeError, OSError):
393
+ pass
394
+ return {}
395
+
396
+
397
+ def _save_secrets(secrets: dict[str, str]) -> None:
398
+ """Save secrets to disk. WARNING: stored in plaintext."""
399
+ _SECRETS_FILE.parent.mkdir(parents=True, exist_ok=True)
400
+ _SECRETS_FILE.write_text(json.dumps(secrets, indent=2))
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Chat task tracking (non-blocking chat via polling)
405
+ # ---------------------------------------------------------------------------
406
+
407
+
408
+ class ChatTask:
409
+ def __init__(self) -> None:
410
+ self.id = str(uuid.uuid4())[:8]
411
+ self.output_lines: list[str] = []
412
+ self.complete = False
413
+ self.returncode: int = -1
414
+ self.files_changed: list[str] = []
415
+
416
+
417
+ _chat_tasks: dict[str, ChatTask] = {}
418
+
331
419
  # ---------------------------------------------------------------------------
332
420
  # API endpoints
333
421
  # ---------------------------------------------------------------------------
@@ -378,6 +466,10 @@ async def start_session(req: StartRequest) -> JSONResponse:
378
466
  ]
379
467
 
380
468
  try:
469
+ # Load secrets and inject as env vars
470
+ build_env = {**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")}
471
+ build_env.update(_load_secrets())
472
+
381
473
  proc = subprocess.Popen(
382
474
  cmd,
383
475
  stdout=subprocess.PIPE,
@@ -385,7 +477,7 @@ async def start_session(req: StartRequest) -> JSONResponse:
385
477
  stdin=subprocess.DEVNULL,
386
478
  text=True,
387
479
  cwd=project_dir,
388
- env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
480
+ env=build_env,
389
481
  **({"start_new_session": True} if sys.platform != "win32"
390
482
  else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
391
483
  )
@@ -409,6 +501,9 @@ async def start_session(req: StartRequest) -> JSONResponse:
409
501
  session.project_dir = project_dir
410
502
  session.start_time = time.time()
411
503
 
504
+ # Track this PID so loki web stop knows it's ours
505
+ _track_child_pid(proc.pid)
506
+
412
507
  # Start background output reader
413
508
  session._reader_task = asyncio.create_task(_read_process_output())
414
509
 
@@ -485,7 +580,7 @@ async def stop_session() -> JSONResponse:
485
580
  # Kill any orphaned loki-run processes for this project
486
581
  if session.project_dir:
487
582
  await asyncio.get_running_loop().run_in_executor(
488
- None, _kill_orphan_loki_processes, session.project_dir
583
+ None, _kill_tracked_child_processes
489
584
  )
490
585
 
491
586
  await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})
@@ -1558,44 +1653,61 @@ async def onboard_session(req: OnboardRequest) -> JSONResponse:
1558
1653
 
1559
1654
  @app.post("/api/sessions/{session_id}/chat")
1560
1655
  async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
1561
- """Run iterative chat command on a project."""
1562
- import re
1656
+ """Start a chat command (non-blocking). Returns task_id for polling."""
1563
1657
  if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1564
1658
  return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1565
-
1566
- # Find project directory
1567
1659
  target = _find_session_dir(session_id)
1568
-
1569
1660
  if target is None:
1570
1661
  return JSONResponse(status_code=404, content={"error": "Session not found"})
1571
1662
 
1572
- # Build command based on mode
1573
- if req.mode == "quick":
1574
- cmd_args = ["quick", req.message]
1575
- else:
1576
- cmd_args = ["start", "--provider", "claude", str(target / "PRD.md")]
1577
-
1578
- rc, output = await asyncio.get_running_loop().run_in_executor(
1579
- None, lambda: _run_loki_cmd(cmd_args, cwd=str(target), timeout=300)
1580
- )
1663
+ task = ChatTask()
1664
+ _chat_tasks[task.id] = task
1581
1665
 
1582
- # Detect changed files
1583
- files_changed: list[str] = []
1584
- try:
1585
- import subprocess as _sp
1586
- result = _sp.run(
1587
- ["git", "diff", "--name-only", "HEAD~1"],
1588
- cwd=str(target), capture_output=True, text=True, timeout=10
1666
+ async def run_chat() -> None:
1667
+ loop = asyncio.get_running_loop()
1668
+ if req.mode == "quick":
1669
+ cmd_args = ["quick", req.message]
1670
+ else:
1671
+ cmd_args = ["start", "--provider", "claude", str(target / "PRD.md")]
1672
+ rc, output = await loop.run_in_executor(
1673
+ None, lambda: _run_loki_cmd(cmd_args, cwd=str(target), timeout=300)
1589
1674
  )
1590
- if result.returncode == 0:
1591
- files_changed = [f for f in result.stdout.strip().splitlines() if f]
1592
- except Exception:
1593
- pass
1675
+ task.output_lines = output.splitlines()
1676
+ task.returncode = rc
1677
+ # Detect changed files
1678
+ try:
1679
+ import subprocess as _sp
1680
+ result = _sp.run(
1681
+ ["git", "diff", "--name-only", "HEAD~1"],
1682
+ cwd=str(target), capture_output=True, text=True, timeout=10
1683
+ )
1684
+ if result.returncode == 0:
1685
+ task.files_changed = [f for f in result.stdout.strip().splitlines() if f]
1686
+ except Exception:
1687
+ pass
1688
+ task.complete = True
1689
+
1690
+ asyncio.create_task(run_chat())
1594
1691
 
1595
1692
  return JSONResponse(content={
1596
- "output": output,
1597
- "files_changed": files_changed,
1598
- "returncode": rc,
1693
+ "task_id": task.id,
1694
+ "status": "running",
1695
+ })
1696
+
1697
+
1698
+ @app.get("/api/sessions/{session_id}/chat/{task_id}")
1699
+ async def get_chat_status(session_id: str, task_id: str) -> JSONResponse:
1700
+ """Poll chat task status and get partial output."""
1701
+ task = _chat_tasks.get(task_id)
1702
+ if task is None:
1703
+ return JSONResponse(status_code=404, content={"error": "Task not found"})
1704
+ return JSONResponse(content={
1705
+ "task_id": task.id,
1706
+ "status": "complete" if task.complete else "running",
1707
+ "output_lines": task.output_lines,
1708
+ "returncode": task.returncode,
1709
+ "files_changed": task.files_changed,
1710
+ "complete": task.complete,
1599
1711
  })
1600
1712
 
1601
1713
 
@@ -1659,6 +1771,160 @@ async def export_session(session_id: str) -> JSONResponse:
1659
1771
  return JSONResponse(content={"output": output, "returncode": rc})
1660
1772
 
1661
1773
 
1774
+ # ---------------------------------------------------------------------------
1775
+ # Secrets management endpoints
1776
+ # ---------------------------------------------------------------------------
1777
+
1778
+
1779
+ @app.get("/api/secrets")
1780
+ async def get_secrets() -> JSONResponse:
1781
+ """List secret keys (values masked)."""
1782
+ secrets = _load_secrets()
1783
+ masked = {k: "***" for k in secrets}
1784
+ return JSONResponse(content=masked)
1785
+
1786
+
1787
+ @app.post("/api/secrets")
1788
+ async def set_secret(req: SecretRequest) -> JSONResponse:
1789
+ """Set or update a secret."""
1790
+ if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', req.key):
1791
+ return JSONResponse(status_code=400, content={"error": "Invalid key. Use ENV_VAR style names."})
1792
+ secrets = _load_secrets()
1793
+ secrets[req.key] = req.value
1794
+ _save_secrets(secrets)
1795
+ return JSONResponse(content={"set": True, "key": req.key})
1796
+
1797
+
1798
+ @app.delete("/api/secrets/{key}")
1799
+ async def delete_secret(key: str) -> JSONResponse:
1800
+ """Delete a secret."""
1801
+ secrets = _load_secrets()
1802
+ if key not in secrets:
1803
+ return JSONResponse(status_code=404, content={"error": "Secret not found"})
1804
+ del secrets[key]
1805
+ _save_secrets(secrets)
1806
+ return JSONResponse(content={"deleted": True, "key": key})
1807
+
1808
+
1809
+ # ---------------------------------------------------------------------------
1810
+ # Preview info (smart project type detection)
1811
+ # ---------------------------------------------------------------------------
1812
+
1813
+
1814
+ @app.get("/api/sessions/{session_id}/preview-info")
1815
+ async def get_preview_info(session_id: str) -> JSONResponse:
1816
+ """Detect project type and determine the best preview strategy."""
1817
+ if not re.match(r"^[a-zA-Z0-9._-]+$", session_id):
1818
+ return JSONResponse(status_code=400, content={"error": "Invalid session ID"})
1819
+ target = _find_session_dir(session_id)
1820
+ if target is None:
1821
+ return JSONResponse(status_code=404, content={"error": "Session not found"})
1822
+
1823
+ info: dict = {
1824
+ "type": "unknown",
1825
+ "preview_url": None,
1826
+ "entry_file": None,
1827
+ "dev_command": None,
1828
+ "port": None,
1829
+ "description": "No preview available",
1830
+ }
1831
+
1832
+ # Detect project type from files
1833
+ files = {f.name for f in target.iterdir() if f.is_file()} if target.is_dir() else set()
1834
+ has_package_json = "package.json" in files
1835
+ has_index_html = (
1836
+ "index.html" in files
1837
+ or (target / "public" / "index.html").exists()
1838
+ or (target / "src" / "index.html").exists()
1839
+ )
1840
+ has_pyproject = "pyproject.toml" in files or "setup.py" in files or "requirements.txt" in files
1841
+ has_go_mod = "go.mod" in files
1842
+ has_cargo = "Cargo.toml" in files
1843
+ has_dockerfile = "Dockerfile" in files or "docker-compose.yml" in files
1844
+
1845
+ # Read package.json for more info
1846
+ pkg_scripts: dict = {}
1847
+ pkg_deps: dict = {}
1848
+ if has_package_json:
1849
+ try:
1850
+ pkg = json.loads((target / "package.json").read_text())
1851
+ pkg_scripts = pkg.get("scripts", {})
1852
+ pkg_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
1853
+ except (json.JSONDecodeError, OSError):
1854
+ pass
1855
+
1856
+ # Determine project type and preview strategy
1857
+ is_react = "react" in pkg_deps or "next" in pkg_deps or "vite" in pkg_deps
1858
+ is_express = "express" in pkg_deps or "fastify" in pkg_deps or "koa" in pkg_deps or "hono" in pkg_deps
1859
+ is_flask = has_pyproject and any((target / f).exists() for f in ["app.py", "main.py", "server.py"])
1860
+ is_fastapi = has_pyproject and any(
1861
+ "fastapi" in (target / f).read_text(errors="replace")
1862
+ for f in ["app.py", "main.py", "server.py"]
1863
+ if (target / f).exists()
1864
+ )
1865
+
1866
+ if is_react or (has_package_json and has_index_html):
1867
+ info["type"] = "web-app"
1868
+ info["entry_file"] = "index.html"
1869
+ info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
1870
+ info["dev_command"] = pkg_scripts.get("dev") or pkg_scripts.get("start")
1871
+ info["description"] = "Web application -- serves HTML/CSS/JS"
1872
+ elif is_express or (has_package_json and ("start" in pkg_scripts or "dev" in pkg_scripts) and not has_index_html):
1873
+ # API/server project
1874
+ port = 3000 # default
1875
+ # Try to detect port from scripts
1876
+ start_script = pkg_scripts.get("start", "") + pkg_scripts.get("dev", "")
1877
+ port_match = re.search(r"(?:PORT|port)[=: ]*(\d+)", start_script)
1878
+ if port_match:
1879
+ port = int(port_match.group(1))
1880
+ info["type"] = "api"
1881
+ info["port"] = port
1882
+ info["dev_command"] = pkg_scripts.get("dev") or pkg_scripts.get("start")
1883
+ info["description"] = f"API server -- runs on port {port}"
1884
+ # Check for swagger/openapi
1885
+ for swagger_path in ["swagger.json", "openapi.json", "docs", "api-docs"]:
1886
+ if (target / swagger_path).exists():
1887
+ info["preview_url"] = f"/api/sessions/{session_id}/preview/{swagger_path}"
1888
+ break
1889
+ elif is_fastapi or is_flask:
1890
+ info["type"] = "python-api"
1891
+ info["port"] = 8000
1892
+ info["dev_command"] = "uvicorn app:app --reload" if is_fastapi else "flask run"
1893
+ info["description"] = "Python API server"
1894
+ elif has_index_html:
1895
+ info["type"] = "static-site"
1896
+ info["entry_file"] = "index.html"
1897
+ info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
1898
+ info["description"] = "Static site -- serves HTML directly"
1899
+ elif has_package_json and "test" in pkg_scripts:
1900
+ info["type"] = "library"
1901
+ info["dev_command"] = pkg_scripts.get("test")
1902
+ info["description"] = "Library/package -- run tests to verify"
1903
+ elif has_go_mod:
1904
+ info["type"] = "go-app"
1905
+ info["dev_command"] = "go run ."
1906
+ info["description"] = "Go application"
1907
+ elif has_cargo:
1908
+ info["type"] = "rust-app"
1909
+ info["dev_command"] = "cargo run"
1910
+ info["description"] = "Rust application"
1911
+ elif has_dockerfile:
1912
+ info["type"] = "containerized"
1913
+ info["dev_command"] = "docker compose up"
1914
+ info["description"] = "Containerized application"
1915
+ else:
1916
+ # Check for any README or docs
1917
+ for doc_file in ["README.md", "readme.md", "README.txt"]:
1918
+ if (target / doc_file).exists():
1919
+ info["type"] = "project"
1920
+ info["entry_file"] = doc_file
1921
+ info["preview_url"] = f"/api/sessions/{session_id}/preview/{doc_file}"
1922
+ info["description"] = "Project -- showing README"
1923
+ break
1924
+
1925
+ return JSONResponse(content=info)
1926
+
1927
+
1662
1928
  # ---------------------------------------------------------------------------
1663
1929
  # Health check
1664
1930
  # ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- import{r as p,j as t}from"./index-DvhjaUe4.js";const m={primary:"bg-[#553DE9] text-white hover:bg-[#4432c4] shadow-button rounded-btn",secondary:"border border-[#553DE9] text-[#553DE9] hover:bg-[#E8E4FD] bg-transparent rounded-btn",ghost:"text-[#36342E] hover:bg-[#F8F4F0] rounded-btn",danger:"bg-[#C45B5B]/10 text-[#C45B5B] border border-[#C45B5B]/20 hover:bg-[#C45B5B]/20 rounded-btn"},b={sm:"px-3 py-1.5 text-xs",md:"px-4 py-2 text-sm",lg:"px-6 py-3 text-base"},u={sm:14,md:16,lg:18};function h({size:e}){return t.jsxs("svg",{className:"animate-spin",width:e,height:e,viewBox:"0 0 24 24",fill:"none",children:[t.jsx("circle",{className:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor",strokeWidth:"4"}),t.jsx("path",{className:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"})]})}const B=p.forwardRef(({variant:e="primary",size:o="md",icon:n,iconRight:i,loading:r=!1,disabled:a,className:c="",children:l,...x},d)=>{const s=u[o];return t.jsxs("button",{ref:d,disabled:a||r,className:["inline-flex items-center justify-center gap-2 font-medium transition-colors",m[e],b[o],(a||r)&&"opacity-60 cursor-not-allowed",c].filter(Boolean).join(" "),...x,children:[r?t.jsx(h,{size:s}):n?t.jsx(n,{size:s}):null,l,i&&!r&&t.jsx(i,{size:s})]})});B.displayName="Button";export{B};