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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +213 -47
- package/autonomy/run.sh +125 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/registry.py +34 -0
- package/dashboard/server.py +181 -0
- package/dashboard/static/index.html +152 -0
- package/docs/INSTALLATION.md +1 -1
- package/loki-ts/dist/loki.js +178 -159
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
package/dashboard/server.py
CHANGED
|
@@ -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');
|