loki-mode 7.7.28 → 7.7.30

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.
@@ -2140,6 +2140,187 @@ async def clear_focus():
2140
2140
  return {"project_dir": None, "loki_dir": str(_get_loki_dir())}
2141
2141
 
2142
2142
 
2143
+ @app.get("/api/running-projects")
2144
+ async def list_running_projects():
2145
+ """List registered projects enriched with live status for the dashboard
2146
+ project switcher (v7.7.29 multi-project support).
2147
+
2148
+ NOTE: deliberately NOT under /api/projects/* because /api/projects/{id}
2149
+ (int path param) would shadow a /api/projects/running literal and 422.
2150
+
2151
+ Returns every registered project (from ~/.loki/dashboard/projects.json,
2152
+ populated by `loki start`), each annotated with:
2153
+ - running: whether the recorded orchestrator pid is still alive
2154
+ - is_active: whether it is the currently focused project
2155
+ Live-vs-stale is derived from pid liveness, which is robust even when a
2156
+ session is hard-killed (no exit hook fires). Never raises: registry
2157
+ problems degrade to an empty list.
2158
+ """
2159
+ out = []
2160
+ try:
2161
+ projects = registry.list_projects(include_inactive=True)
2162
+ except Exception:
2163
+ projects = []
2164
+ active = _active_project_dir
2165
+ for p in projects:
2166
+ path = p.get("path", "")
2167
+ pid = p.get("pid")
2168
+ running = False
2169
+ if isinstance(pid, int) and pid > 0:
2170
+ try:
2171
+ os.kill(pid, 0)
2172
+ running = True # signal 0 delivered -> pid alive
2173
+ except PermissionError:
2174
+ running = True # pid exists but owned by another user
2175
+ except (ProcessLookupError, OSError):
2176
+ running = False # ESRCH -> dead
2177
+ # A project is also "live" if its .loki/session.json says running.
2178
+ if not running and path:
2179
+ try:
2180
+ sess = _Path(path) / ".loki" / "session.json"
2181
+ if sess.is_file():
2182
+ import json as _json
2183
+ s = _json.loads(sess.read_text())
2184
+ running = s.get("status") == "running"
2185
+ except Exception:
2186
+ pass
2187
+ # Compare via realpath: /api/focus resolves symlinks (e.g. macOS
2188
+ # /tmp -> /private/tmp) while the registry stores abspath, so a plain
2189
+ # abspath compare would never match a focused symlinked project.
2190
+ is_active = False
2191
+ if active and path:
2192
+ try:
2193
+ is_active = os.path.realpath(active) == os.path.realpath(path)
2194
+ except OSError:
2195
+ is_active = os.path.abspath(active) == os.path.abspath(path)
2196
+ out.append({
2197
+ "id": p.get("id"),
2198
+ "name": p.get("name") or (os.path.basename(path) if path else "project"),
2199
+ "path": path,
2200
+ "port": p.get("port"),
2201
+ "status": p.get("status"),
2202
+ "running": running,
2203
+ "is_active": is_active,
2204
+ })
2205
+ return {"projects": out, "active_project_dir": active}
2206
+
2207
+
2208
+ class RunningProjectStopRequest(BaseModel):
2209
+ """Schema for stopping a specific registered project from the switcher.
2210
+
2211
+ Exactly one of id or project_dir must be provided. id is preferred;
2212
+ project_dir is accepted for symmetry with /api/focus. The provided value
2213
+ is resolved through the dashboard registry, so an arbitrary filesystem
2214
+ path is never used directly as a write target.
2215
+ """
2216
+ id: Optional[str] = None
2217
+ project_dir: Optional[str] = None
2218
+
2219
+
2220
+ @app.post("/api/running-projects/stop", dependencies=[Depends(auth.require_scope("control"))])
2221
+ async def stop_running_project(request: Request, body: RunningProjectStopRequest):
2222
+ """Stop a specific registered project (v7.7.30 per-project switcher stop).
2223
+
2224
+ Resolves the project via the dashboard registry (by id or path), writes a
2225
+ STOP file into that project's .loki for a clean runner teardown, then runs
2226
+ the graceful SIGTERM -> poll-5s -> SIGKILL dance against the recorded
2227
+ orchestrator pid (not the dashboard's own _get_loki_dir). Marks the
2228
+ project's session.json and registry entry stopped so the switcher reflects
2229
+ it immediately.
2230
+
2231
+ Security: the STOP file is only ever written to the path already stored in
2232
+ the registry for the resolved id, never to a caller-supplied path.
2233
+ """
2234
+ if not _control_limiter.check("control"):
2235
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
2236
+
2237
+ identifier = (body.id or body.project_dir or "").strip()
2238
+ if not identifier:
2239
+ raise HTTPException(status_code=400, detail="id or project_dir is required")
2240
+
2241
+ project = registry.get_project(identifier)
2242
+ if not project:
2243
+ raise HTTPException(status_code=404, detail="project not found")
2244
+
2245
+ project_id = project.get("id")
2246
+ audit.log_event(
2247
+ action="stop",
2248
+ resource_type="session",
2249
+ details={"source": "api", "project_id": project_id},
2250
+ ip_address=request.client.host if request.client else None,
2251
+ )
2252
+
2253
+ # Validate the registry-stored path is a real dir containing .loki before
2254
+ # writing into it. Mirrors the /api/focus guard. If invalid we still mark
2255
+ # the project stopped but skip the STOP-file write.
2256
+ path = project.get("path", "")
2257
+ loki_dir = None
2258
+ if path:
2259
+ p = _Path(path)
2260
+ if p.is_dir() and (p / ".loki").is_dir():
2261
+ loki_dir = p / ".loki"
2262
+
2263
+ pid = project.get("pid")
2264
+ if not isinstance(pid, int) or pid <= 0:
2265
+ # Already not running: nothing to signal, just reconcile the registry.
2266
+ registry.mark_project_stopped(project_id)
2267
+ return {
2268
+ "success": True,
2269
+ "project_id": project_id,
2270
+ "stopped": False,
2271
+ "already_stopped": True,
2272
+ }
2273
+
2274
+ # Write the STOP file so the runner's own cleanup STOP-branch fires for a
2275
+ # clean teardown. Only into the registry-resolved .loki dir.
2276
+ if loki_dir is not None:
2277
+ try:
2278
+ (loki_dir / "STOP").write_text(datetime.now(timezone.utc).isoformat())
2279
+ except OSError:
2280
+ pass
2281
+
2282
+ # Graceful dance against the recorded orchestrator pid.
2283
+ stopped = False
2284
+ try:
2285
+ os.kill(pid, 15) # SIGTERM
2286
+ for _ in range(10):
2287
+ await asyncio.sleep(0.5)
2288
+ try:
2289
+ os.kill(pid, 0) # Check if still alive
2290
+ except OSError:
2291
+ stopped = True
2292
+ break
2293
+ if not stopped:
2294
+ try:
2295
+ os.kill(pid, 9) # SIGKILL
2296
+ stopped = True
2297
+ except (OSError, ProcessLookupError):
2298
+ stopped = True
2299
+ except (ValueError, OSError, ProcessLookupError):
2300
+ # pid already dead or unsignalable -- treat as stopped.
2301
+ stopped = True
2302
+
2303
+ # Mark session.json stopped in that project's .loki.
2304
+ if loki_dir is not None:
2305
+ session_file = loki_dir / "session.json"
2306
+ if session_file.exists():
2307
+ try:
2308
+ sd = json.loads(session_file.read_text())
2309
+ sd["status"] = "stopped"
2310
+ atomic_write_json(session_file, sd, use_lock=True)
2311
+ except Exception:
2312
+ pass
2313
+
2314
+ registry.mark_project_stopped(project_id)
2315
+
2316
+ return {
2317
+ "success": True,
2318
+ "project_id": project_id,
2319
+ "stopped": stopped,
2320
+ "already_stopped": False,
2321
+ }
2322
+
2323
+
2143
2324
  # =============================================================================
2144
2325
  # Enterprise Features (Optional - enabled via environment variables)
2145
2326
  # =============================================================================
@@ -285,6 +285,69 @@
285
285
  box-shadow: 0 0 0 3px var(--loki-accent-glow);
286
286
  }
287
287
 
288
+ /* v7.7.29 multi-project switcher */
289
+ .project-switcher {
290
+ margin-left: 14px;
291
+ padding: 5px 10px;
292
+ background: var(--loki-bg-primary);
293
+ border: 1px solid var(--loki-border);
294
+ border-radius: 6px;
295
+ font-size: 12px;
296
+ font-family: 'Inter', system-ui, sans-serif;
297
+ color: var(--loki-text-primary);
298
+ cursor: pointer;
299
+ max-width: 280px;
300
+ }
301
+ .project-switcher:focus {
302
+ outline: none;
303
+ border-color: var(--loki-accent);
304
+ box-shadow: 0 0 0 3px var(--loki-accent-glow);
305
+ }
306
+ /* v7.7.30 per-project stop list */
307
+ .project-stop-list {
308
+ display: flex;
309
+ flex-wrap: wrap;
310
+ align-items: center;
311
+ gap: 6px;
312
+ margin-left: 10px;
313
+ }
314
+ .project-stop-row {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ gap: 6px;
318
+ padding: 3px 6px 3px 10px;
319
+ background: var(--loki-bg-primary);
320
+ border: 1px solid var(--loki-border);
321
+ border-radius: 14px;
322
+ font-size: 11px;
323
+ font-family: 'Inter', system-ui, sans-serif;
324
+ color: var(--loki-text-primary);
325
+ }
326
+ .project-stop-row .project-stop-name {
327
+ max-width: 160px;
328
+ overflow: hidden;
329
+ text-overflow: ellipsis;
330
+ white-space: nowrap;
331
+ }
332
+ .project-stop-row button {
333
+ padding: 2px 8px;
334
+ background: transparent;
335
+ border: 1px solid var(--loki-border);
336
+ border-radius: 10px;
337
+ font-size: 11px;
338
+ font-family: 'Inter', system-ui, sans-serif;
339
+ color: var(--loki-text-secondary);
340
+ cursor: pointer;
341
+ }
342
+ .project-stop-row button:hover:not(:disabled) {
343
+ border-color: #d64545;
344
+ color: #d64545;
345
+ }
346
+ .project-stop-row button:disabled {
347
+ opacity: 0.6;
348
+ cursor: default;
349
+ }
350
+
288
351
  /* Main Content */
289
352
  .main-content {
290
353
  padding: 28px 32px;
@@ -532,6 +595,16 @@
532
595
  </button>
533
596
  <span class="logo-brand">Loki Mode</span>
534
597
  <span class="logo-subtitle">powered by Autonomi</span>
598
+ <!-- v7.7.29 multi-project switcher: lists projects running loki in
599
+ different folders and switches which one the dashboard shows. -->
600
+ <select class="project-switcher" id="project-switcher" title="Switch project" aria-label="Switch project">
601
+ <option value="">All projects (current dir)</option>
602
+ </select>
603
+ <!-- v7.7.30 per-project stop: a compact list of running projects, each
604
+ with a Stop button that gracefully halts that project's runner
605
+ without affecting any other folder. Built at runtime; empty when
606
+ no project is running. -->
607
+ <div class="project-stop-list" id="project-stop-list" aria-label="Running projects"></div>
535
608
  </div>
536
609
 
537
610
  <nav class="nav-links">
@@ -13373,6 +13446,85 @@ document.addEventListener('DOMContentLoaded', function() {
13373
13446
  var initResult = LokiDashboard.init({ autoDetectContext: true });
13374
13447
  console.log('Loki Dashboard initialized:', initResult);
13375
13448
 
13449
+ // v7.7.29 multi-project switcher: populate from /api/running-projects and
13450
+ // switch the focused project via /api/focus. Fully best-effort; if the
13451
+ // endpoint is unavailable the dropdown simply stays at "All projects".
13452
+ (function initProjectSwitcher() {
13453
+ var sel = document.getElementById('project-switcher');
13454
+ if (!sel) return;
13455
+ var stopList = document.getElementById('project-stop-list');
13456
+ // v7.7.30: build a per-row Stop control for each running project using
13457
+ // createElement + textContent only (never innerHTML for project-supplied
13458
+ // strings), so a project name can never inject markup.
13459
+ function buildStopList(projects) {
13460
+ if (!stopList) return;
13461
+ while (stopList.firstChild) stopList.removeChild(stopList.firstChild);
13462
+ projects.forEach(function(p){
13463
+ if (p.running !== true) return;
13464
+ var row = document.createElement('div');
13465
+ row.className = 'project-stop-row';
13466
+ var name = document.createElement('span');
13467
+ name.className = 'project-stop-name';
13468
+ name.textContent = p.name || p.path || 'project';
13469
+ var btn = document.createElement('button');
13470
+ btn.type = 'button';
13471
+ btn.textContent = 'Stop';
13472
+ if (p.id) btn.setAttribute('data-id', p.id);
13473
+ btn.addEventListener('click', function(){
13474
+ if (!p.id) return;
13475
+ btn.disabled = true;
13476
+ btn.textContent = 'Stopping...';
13477
+ fetch('/api/running-projects/stop', {
13478
+ method: 'POST',
13479
+ headers: { 'Content-Type': 'application/json' },
13480
+ body: JSON.stringify({ id: p.id })
13481
+ })
13482
+ .then(function(){ refresh(); })
13483
+ .catch(function(){ btn.disabled = false; btn.textContent = 'Stop'; });
13484
+ });
13485
+ row.appendChild(name);
13486
+ row.appendChild(btn);
13487
+ stopList.appendChild(row);
13488
+ });
13489
+ }
13490
+ function refresh() {
13491
+ fetch('/api/running-projects')
13492
+ .then(function(r){ return r.ok ? r.json() : null; })
13493
+ .then(function(data){
13494
+ if (!data || !Array.isArray(data.projects)) return;
13495
+ var current = sel.value;
13496
+ // Rebuild options: keep the "All projects" default first.
13497
+ sel.innerHTML = '';
13498
+ var optAll = document.createElement('option');
13499
+ optAll.value = ''; optAll.textContent = 'All projects (current dir)';
13500
+ sel.appendChild(optAll);
13501
+ data.projects.forEach(function(p){
13502
+ var o = document.createElement('option');
13503
+ o.value = p.path || '';
13504
+ var dot = p.running ? '* ' : ''; // running marker (ASCII)
13505
+ o.textContent = dot + (p.name || p.path || 'project');
13506
+ if (p.is_active) o.selected = true;
13507
+ sel.appendChild(o);
13508
+ });
13509
+ if (!data.active_project_dir && current === '') sel.value = '';
13510
+ buildStopList(data.projects);
13511
+ })
13512
+ .catch(function(){ /* offline / no endpoint: leave as-is */ });
13513
+ }
13514
+ sel.addEventListener('change', function(){
13515
+ var dir = sel.value;
13516
+ var req = dir
13517
+ ? fetch('/api/focus', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_dir: dir }) })
13518
+ : fetch('/api/focus', { method: 'DELETE' });
13519
+ req.then(function(){
13520
+ // Reload so every panel re-fetches against the newly focused project.
13521
+ window.location.reload();
13522
+ }).catch(function(){ /* ignore */ });
13523
+ });
13524
+ refresh();
13525
+ setInterval(refresh, 15000);
13526
+ })();
13527
+
13376
13528
  // Theme toggle functionality
13377
13529
  var themeToggle = document.getElementById('theme-toggle');
13378
13530
  var themeLabel = document.getElementById('theme-label');
@@ -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:** v7.7.28
5
+ **Version:** v7.7.30
6
6
 
7
7
  ---
8
8