loki-mode 6.35.1 → 6.36.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,7 +8,7 @@
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=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-HYTmiwkW.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-CY21Z26x.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-DW1e50zX.css">
13
13
  </head>
14
14
  <body class="bg-background text-charcoal font-sans antialiased">
package/web-app/server.py CHANGED
@@ -69,6 +69,24 @@ class SessionState:
69
69
  self.log_lines: list[str] = []
70
70
  self.ws_clients: set[WebSocket] = set()
71
71
  self._reader_task: Optional[asyncio.Task] = None
72
+ self._lock = asyncio.Lock()
73
+
74
+ async def cleanup(self) -> None:
75
+ """Cancel reader task and close process pipes."""
76
+ if self._reader_task and not self._reader_task.done():
77
+ self._reader_task.cancel()
78
+ try:
79
+ await asyncio.wait_for(self._reader_task, timeout=3)
80
+ except (asyncio.CancelledError, asyncio.TimeoutError, Exception):
81
+ pass
82
+ self._reader_task = None
83
+
84
+ if self.process:
85
+ try:
86
+ if self.process.stdout:
87
+ self.process.stdout.close()
88
+ except Exception:
89
+ pass
72
90
 
73
91
  def reset(self) -> None:
74
92
  self.process = None
@@ -99,6 +117,9 @@ class StopResponse(BaseModel):
99
117
  message: str
100
118
 
101
119
 
120
+ _MAX_PRD_BYTES = 1_048_576 # 1 MB
121
+
122
+
102
123
  class PlanRequest(BaseModel):
103
124
  prd: str
104
125
  provider: str = "claude"
@@ -143,7 +164,7 @@ async def _broadcast(msg: dict) -> None:
143
164
  """Send a JSON message to all connected WebSocket clients."""
144
165
  data = json.dumps(msg)
145
166
  dead: list[WebSocket] = []
146
- for ws in session.ws_clients:
167
+ for ws in list(session.ws_clients):
147
168
  try:
148
169
  await ws.send_text(data)
149
170
  except Exception:
@@ -158,7 +179,7 @@ async def _read_process_output() -> None:
158
179
  if proc is None or proc.stdout is None:
159
180
  return
160
181
 
161
- loop = asyncio.get_event_loop()
182
+ loop = asyncio.get_running_loop()
162
183
 
163
184
  try:
164
185
  while session.running and proc.poll() is None:
@@ -223,72 +244,78 @@ async def start_session(req: StartRequest) -> JSONResponse:
223
244
  """Start a new loki session with the given PRD."""
224
245
  if len(req.prd.encode()) > _MAX_PRD_BYTES:
225
246
  return JSONResponse(status_code=400, content={"error": "PRD exceeds 1 MB limit"})
226
- if session.running:
227
- return JSONResponse(
228
- status_code=409,
229
- content={"error": "A session is already running. Stop it first."},
230
- )
231
-
232
- # Determine project directory
233
- project_dir = req.projectDir
234
- if not project_dir:
235
- project_dir = os.path.join(Path.home(), "purple-lab-projects", f"project-{int(time.time())}")
236
- os.makedirs(project_dir, exist_ok=True)
237
-
238
- # Write PRD to a temp file in the project dir
239
- prd_path = os.path.join(project_dir, "PRD.md")
240
- with open(prd_path, "w") as f:
241
- f.write(req.prd)
242
-
243
- # Build the loki start command
244
- if req.mode == "quick":
245
- # Extract first non-blank line as the task description
246
- first_line = next((l.strip() for l in req.prd.splitlines() if l.strip()), req.prd[:200])
247
- cmd = [
248
- str(LOKI_CLI),
249
- "quick",
250
- first_line,
251
- ]
252
- else:
253
- cmd = [
254
- str(LOKI_CLI),
255
- "start",
256
- "--provider", req.provider,
257
- prd_path,
258
- ]
259
247
 
260
- try:
261
- proc = subprocess.Popen(
262
- cmd,
263
- stdout=subprocess.PIPE,
264
- stderr=subprocess.STDOUT,
265
- stdin=subprocess.DEVNULL,
266
- text=True,
267
- cwd=project_dir,
268
- env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
269
- )
270
- except FileNotFoundError:
271
- return JSONResponse(
272
- status_code=500,
273
- content={"error": f"loki CLI not found at {LOKI_CLI}"},
274
- )
275
- except Exception as e:
276
- return JSONResponse(
277
- status_code=500,
278
- content={"error": f"Failed to start session: {e}"},
279
- )
248
+ async with session._lock:
249
+ if session.running:
250
+ return JSONResponse(
251
+ status_code=409,
252
+ content={"error": "A session is already running. Stop it first."},
253
+ )
254
+
255
+ # Clean up any stale reader task from previous session
256
+ await session.cleanup()
257
+
258
+ # Determine project directory
259
+ project_dir = req.projectDir
260
+ if not project_dir:
261
+ project_dir = os.path.join(Path.home(), "purple-lab-projects", f"project-{int(time.time())}")
262
+ os.makedirs(project_dir, exist_ok=True)
263
+
264
+ # Write PRD to a temp file in the project dir
265
+ prd_path = os.path.join(project_dir, "PRD.md")
266
+ with open(prd_path, "w") as f:
267
+ f.write(req.prd)
268
+
269
+ # Build the loki start command
270
+ if req.mode == "quick":
271
+ # Extract first non-blank line as the task description
272
+ first_line = next((l.strip() for l in req.prd.splitlines() if l.strip()), req.prd[:200])
273
+ cmd = [
274
+ str(LOKI_CLI),
275
+ "quick",
276
+ first_line,
277
+ ]
278
+ else:
279
+ cmd = [
280
+ str(LOKI_CLI),
281
+ "start",
282
+ "--provider", req.provider,
283
+ prd_path,
284
+ ]
280
285
 
281
- # Update session state
282
- session.reset()
283
- session.process = proc
284
- session.running = True
285
- session.provider = req.provider
286
- session.prd_text = req.prd
287
- session.project_dir = project_dir
288
- session.start_time = time.time()
286
+ try:
287
+ proc = subprocess.Popen(
288
+ cmd,
289
+ stdout=subprocess.PIPE,
290
+ stderr=subprocess.STDOUT,
291
+ stdin=subprocess.DEVNULL,
292
+ text=True,
293
+ cwd=project_dir,
294
+ env={**os.environ, "LOKI_DIR": os.path.join(project_dir, ".loki")},
295
+ start_new_session=True, # create new process group for clean kill
296
+ )
297
+ except FileNotFoundError:
298
+ return JSONResponse(
299
+ status_code=500,
300
+ content={"error": f"loki CLI not found at {LOKI_CLI}"},
301
+ )
302
+ except Exception as e:
303
+ return JSONResponse(
304
+ status_code=500,
305
+ content={"error": f"Failed to start session: {e}"},
306
+ )
307
+
308
+ # Update session state
309
+ session.reset()
310
+ session.process = proc
311
+ session.running = True
312
+ session.provider = req.provider
313
+ session.prd_text = req.prd
314
+ session.project_dir = project_dir
315
+ session.start_time = time.time()
289
316
 
290
- # Start background output reader
291
- session._reader_task = asyncio.create_task(_read_process_output())
317
+ # Start background output reader
318
+ session._reader_task = asyncio.create_task(_read_process_output())
292
319
 
293
320
  await _broadcast({"type": "session_start", "data": {
294
321
  "provider": req.provider,
@@ -307,24 +334,48 @@ async def start_session(req: StartRequest) -> JSONResponse:
307
334
  @app.post("/api/session/stop")
308
335
  async def stop_session() -> JSONResponse:
309
336
  """Stop the current loki session."""
310
- if not session.running or session.process is None:
311
- return JSONResponse(content={"stopped": False, "message": "No session running"})
337
+ async with session._lock:
338
+ if not session.running or session.process is None:
339
+ return JSONResponse(content={"stopped": False, "message": "No session running"})
340
+
341
+ proc = session.process
342
+
343
+ # 1. Mark stopped first so reader task loop exits
344
+ session.running = False
345
+
346
+ # 2. Cancel reader task before killing process
347
+ await session.cleanup()
348
+
349
+ # 3. Kill the process group (catches child processes too)
350
+ try:
351
+ pgid = os.getpgid(proc.pid)
352
+ os.killpg(pgid, signal.SIGTERM)
353
+ except (ProcessLookupError, PermissionError, OSError):
354
+ # Fallback: kill the process directly
355
+ try:
356
+ proc.terminate()
357
+ except Exception:
358
+ pass
312
359
 
313
- try:
314
- # Send SIGTERM, then SIGKILL after 5s
315
- session.process.terminate()
316
360
  try:
317
- session.process.wait(timeout=5)
361
+ proc.wait(timeout=5)
318
362
  except subprocess.TimeoutExpired:
319
- session.process.kill()
320
- session.process.wait(timeout=3)
321
- except Exception:
322
- pass
363
+ try:
364
+ pgid = os.getpgid(proc.pid)
365
+ os.killpg(pgid, signal.SIGKILL)
366
+ except (ProcessLookupError, PermissionError, OSError):
367
+ try:
368
+ proc.kill()
369
+ except Exception:
370
+ pass
371
+ try:
372
+ proc.wait(timeout=3)
373
+ except Exception:
374
+ pass
323
375
 
324
- session.running = False
325
- await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})
376
+ await _broadcast({"type": "session_end", "data": {"message": "Session stopped by user"}})
326
377
 
327
- return JSONResponse(content={"stopped": True, "message": "Session stopped"})
378
+ return JSONResponse(content={"stopped": True, "message": "Session stopped"})
328
379
 
329
380
 
330
381
  @app.get("/api/session/status")
@@ -607,9 +658,6 @@ def _run_loki_cmd(args: list, cwd: Optional[str] = None, timeout: int = 60) -> t
607
658
  return (1, str(e))
608
659
 
609
660
 
610
- _MAX_PRD_BYTES = 1_048_576 # 1 MB
611
-
612
-
613
661
  @app.post("/api/session/plan")
614
662
  async def plan_session(req: PlanRequest) -> JSONResponse:
615
663
  """Run loki plan dry-run analysis and return structured result."""
@@ -620,7 +668,7 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
620
668
  f.write(req.prd)
621
669
  prd_tmp = f.name
622
670
  try:
623
- rc, output = await asyncio.get_event_loop().run_in_executor(
671
+ rc, output = await asyncio.get_running_loop().run_in_executor(
624
672
  None, lambda: _run_loki_cmd(["plan", prd_tmp], timeout=90)
625
673
  )
626
674
  finally:
@@ -671,7 +719,7 @@ async def plan_session(req: PlanRequest) -> JSONResponse:
671
719
  async def generate_report(req: ReportRequest) -> JSONResponse:
672
720
  """Run loki report and return content."""
673
721
  fmt = req.format if req.format in ("html", "markdown") else "markdown"
674
- rc, output = await asyncio.get_event_loop().run_in_executor(
722
+ rc, output = await asyncio.get_running_loop().run_in_executor(
675
723
  None, lambda: _run_loki_cmd(["report", "--format", fmt], timeout=60)
676
724
  )
677
725
  return JSONResponse(content={
@@ -684,7 +732,7 @@ async def generate_report(req: ReportRequest) -> JSONResponse:
684
732
  @app.post("/api/session/share")
685
733
  async def share_session() -> JSONResponse:
686
734
  """Run loki share and return Gist URL."""
687
- rc, output = await asyncio.get_event_loop().run_in_executor(
735
+ rc, output = await asyncio.get_running_loop().run_in_executor(
688
736
  None, lambda: _run_loki_cmd(["share"], timeout=60)
689
737
  )
690
738
  # Try to extract URL from output
@@ -748,7 +796,7 @@ async def set_provider(req: ProviderSetRequest) -> JSONResponse:
748
796
  @app.get("/api/session/metrics")
749
797
  async def get_metrics() -> JSONResponse:
750
798
  """Run loki metrics --json and return parsed output."""
751
- rc, output = await asyncio.get_event_loop().run_in_executor(
799
+ rc, output = await asyncio.get_running_loop().run_in_executor(
752
800
  None, lambda: _run_loki_cmd(["metrics", "--json"], timeout=30)
753
801
  )
754
802
  # Try JSON parse
@@ -852,7 +900,7 @@ async def onboard_session(req: OnboardRequest) -> JSONResponse:
852
900
  if not target.is_dir():
853
901
  return JSONResponse(status_code=400, content={"error": "Path must be a directory"})
854
902
 
855
- rc, output = await asyncio.get_event_loop().run_in_executor(
903
+ rc, output = await asyncio.get_running_loop().run_in_executor(
856
904
  None, lambda: _run_loki_cmd(["onboard", str(target)], cwd=str(target), timeout=120)
857
905
  )
858
906
  # Try to read generated CLAUDE.md
@@ -870,6 +918,17 @@ async def onboard_session(req: OnboardRequest) -> JSONResponse:
870
918
  })
871
919
 
872
920
 
921
+ # ---------------------------------------------------------------------------
922
+ # Health check
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ @app.get("/health")
927
+ async def health_check() -> JSONResponse:
928
+ """Health check for load balancers and orchestrators."""
929
+ return JSONResponse(content={"status": "ok", "service": "purple-lab"})
930
+
931
+
873
932
  # ---------------------------------------------------------------------------
874
933
  # WebSocket
875
934
  # ---------------------------------------------------------------------------
@@ -1011,6 +1070,10 @@ async def websocket_endpoint(ws: WebSocket) -> None:
1011
1070
  pass
1012
1071
  finally:
1013
1072
  push_task.cancel()
1073
+ try:
1074
+ await push_task
1075
+ except (asyncio.CancelledError, Exception):
1076
+ pass
1014
1077
  session.ws_clients.discard(ws)
1015
1078
 
1016
1079