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.
- package/README.md +1 -1
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/web-app/dist/assets/index-CY21Z26x.js +66 -0
- package/web-app/dist/index.html +1 -1
- package/web-app/server.py +149 -86
- package/web-app/dist/assets/index-HYTmiwkW.js +0 -66
package/web-app/dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
361
|
+
proc.wait(timeout=5)
|
|
318
362
|
except subprocess.TimeoutExpired:
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|