loki-mode 7.83.1 → 7.84.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/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.83.1
6
+ # Loki Mode v7.84.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.83.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.84.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.83.1
1
+ 7.84.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.83.1"
10
+ __version__ = "7.84.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -342,6 +342,60 @@ def mark_project_stopped(identifier: str) -> Optional[dict]:
342
342
  return None
343
343
 
344
344
 
345
+ def prune_missing_projects(running_ids: Optional[set] = None) -> list[dict]:
346
+ """Remove registry entries whose project path no longer exists on disk.
347
+
348
+ The registry records every cwd that has ever been seen by `loki start` and,
349
+ until this function runs, never garbage-collects them: deleted / renamed /
350
+ temp project directories accumulate forever and bloat the dashboard project
351
+ switcher with paths that no longer exist. This prunes those dead entries.
352
+
353
+ Safety:
354
+ - A path is considered dead only when os.path.isdir(path) is False.
355
+ - A currently-running project is NEVER pruned even if its path check is
356
+ racy (e.g. a transient unmount): pass its project id in running_ids and
357
+ the entry is kept regardless of the disk check. An entry with a live pid
358
+ recorded in the registry is also kept defensively (a running build whose
359
+ dir momentarily fails to stat must not be dropped, which would orphan
360
+ the switcher's Stop target).
361
+ - The whole load->mutate->save runs under _registry_lock() with the atomic
362
+ _save_registry, so it is safe under concurrency with the leaf mutators.
363
+
364
+ Args:
365
+ running_ids: Optional set of project ids that are known to be running
366
+ and must be retained unconditionally. None means "trust the disk
367
+ check and the per-entry recorded pid only".
368
+
369
+ Returns:
370
+ The list of pruned (removed) project entries. Empty when nothing was
371
+ removed.
372
+ """
373
+ keep_running = running_ids or set()
374
+ pruned: list[dict] = []
375
+ with _registry_lock():
376
+ registry = _load_registry()
377
+ projects = registry.get("projects", {})
378
+
379
+ survivors = {}
380
+ for project_id, project in projects.items():
381
+ path = project.get("path", "")
382
+ # Keep unconditionally if the caller marked it running, or if the
383
+ # registry has a live pid recorded for it.
384
+ if project_id in keep_running or _pid_alive(project.get("pid")):
385
+ survivors[project_id] = project
386
+ continue
387
+ # Otherwise keep only if the path still exists on disk.
388
+ if path and os.path.isdir(path):
389
+ survivors[project_id] = project
390
+ else:
391
+ pruned.append(project)
392
+
393
+ if pruned:
394
+ registry["projects"] = survivors
395
+ _save_registry(registry)
396
+ return pruned
397
+
398
+
345
399
  def check_project_health(identifier: str) -> dict:
346
400
  """
347
401
  Check the health status of a project.
@@ -2900,6 +2900,17 @@ async def list_running_projects():
2900
2900
  Live-vs-stale is derived from pid liveness, which is robust even when a
2901
2901
  session is hard-killed (no exit hook fires). Never raises: registry
2902
2902
  problems degrade to an empty list.
2903
+
2904
+ Registry hygiene (project switcher): the registry records every cwd ever
2905
+ seen by `loki start` and never garbage-collects, so the switcher list grows
2906
+ without bound and shows paths that no longer exist on disk. This endpoint:
2907
+ - opportunistically prunes registry entries whose path is gone AND which
2908
+ are not running (registry.prune_missing_projects), keeping the on-disk
2909
+ store small. Running projects are never pruned.
2910
+ - returns a CLEAN list: a project is included only if its path still
2911
+ exists on disk OR it is currently running (or is the active project).
2912
+ - sorts by last_accessed desc (most relevant first) and caps the list
2913
+ defensively, but never drops a running or the active project.
2903
2914
  """
2904
2915
  out = []
2905
2916
  try:
@@ -2907,6 +2918,19 @@ async def list_running_projects():
2907
2918
  except Exception:
2908
2919
  projects = []
2909
2920
  active = _active_project_dir
2921
+
2922
+ def _is_active(path: str) -> bool:
2923
+ # Compare via realpath: /api/focus resolves symlinks (e.g. macOS
2924
+ # /tmp -> /private/tmp) while the registry stores abspath, so a plain
2925
+ # abspath compare would never match a focused symlinked project.
2926
+ if not (active and path):
2927
+ return False
2928
+ try:
2929
+ return os.path.realpath(active) == os.path.realpath(path)
2930
+ except OSError:
2931
+ return os.path.abspath(active) == os.path.abspath(path)
2932
+
2933
+ running_ids = set()
2910
2934
  for p in projects:
2911
2935
  path = p.get("path", "")
2912
2936
  pid = p.get("pid")
@@ -2936,15 +2960,15 @@ async def list_running_projects():
2936
2960
  running = s.get("status") == "running"
2937
2961
  except Exception:
2938
2962
  pass
2939
- # Compare via realpath: /api/focus resolves symlinks (e.g. macOS
2940
- # /tmp -> /private/tmp) while the registry stores abspath, so a plain
2941
- # abspath compare would never match a focused symlinked project.
2942
- is_active = False
2943
- if active and path:
2944
- try:
2945
- is_active = os.path.realpath(active) == os.path.realpath(path)
2946
- except OSError:
2947
- is_active = os.path.abspath(active) == os.path.abspath(path)
2963
+ path_exists = bool(path) and os.path.isdir(path)
2964
+ is_active = _is_active(path)
2965
+ if running:
2966
+ running_ids.add(p.get("id"))
2967
+ # CLEAN list: include only projects whose path still exists, or which
2968
+ # are running, or which are the active project. A dead path that is not
2969
+ # running is excluded (and pruned from the store below).
2970
+ if not (path_exists or running or is_active):
2971
+ continue
2948
2972
  out.append({
2949
2973
  "id": p.get("id"),
2950
2974
  "name": p.get("name") or (os.path.basename(path) if path else "project"),
@@ -2953,7 +2977,29 @@ async def list_running_projects():
2953
2977
  "status": p.get("status"),
2954
2978
  "running": running,
2955
2979
  "is_active": is_active,
2980
+ "_last_accessed": p.get("last_accessed") or p.get("registered_at") or "",
2956
2981
  })
2982
+
2983
+ # Opportunistically garbage-collect dead entries from the on-disk registry
2984
+ # so it never grows without bound. Running projects (by id, plus any with a
2985
+ # live recorded pid inside the helper) are retained even if the disk check
2986
+ # is racy. Best-effort: a prune failure must not break the listing.
2987
+ try:
2988
+ registry.prune_missing_projects(running_ids=running_ids)
2989
+ except Exception:
2990
+ pass
2991
+
2992
+ # Most relevant first: last_accessed desc. Cap defensively, but never hide a
2993
+ # running or active project (partition them out before the cap).
2994
+ _SWITCHER_CAP = 50
2995
+ out.sort(key=lambda r: r.get("_last_accessed", ""), reverse=True)
2996
+ if len(out) > _SWITCHER_CAP:
2997
+ pinned = [r for r in out if r["running"] or r["is_active"]]
2998
+ rest = [r for r in out if not (r["running"] or r["is_active"])]
2999
+ out = pinned + rest[: max(0, _SWITCHER_CAP - len(pinned))]
3000
+ # Drop the internal sort key from the response shape.
3001
+ for r in out:
3002
+ r.pop("_last_accessed", None)
2957
3003
  return {"projects": out, "active_project_dir": active}
2958
3004
 
2959
3005