loki-mode 7.43.0 → 7.45.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 +7 -33
- package/SKILL.md +11 -2
- package/VERSION +1 -1
- package/autonomy/docker-run.sh +243 -0
- package/autonomy/loki +234 -9
- package/autonomy/run.sh +18 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +244 -4
- package/dashboard/static/index.html +78 -21
- package/docs/INSTALLATION.md +75 -9
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/autonomy/run.sh
CHANGED
|
@@ -1550,7 +1550,24 @@ validate_api_keys() {
|
|
|
1550
1550
|
|
|
1551
1551
|
local key_var=""
|
|
1552
1552
|
case "$provider" in
|
|
1553
|
-
claude)
|
|
1553
|
+
claude)
|
|
1554
|
+
# Inside Docker, the Claude Code CLI can authenticate either via
|
|
1555
|
+
# ANTHROPIC_API_KEY OR via a mounted OAuth credentials file (the
|
|
1556
|
+
# zero-friction `loki docker` wrapper mounts the host login at
|
|
1557
|
+
# ~/.claude/.credentials.json). If that file is present, the CLI
|
|
1558
|
+
# has a valid login and we must NOT block on the env var -- doing
|
|
1559
|
+
# so was the bug that made OAuth-based Docker runs exit at
|
|
1560
|
+
# pre-flight with "Required API key ... is not set". Mirror the
|
|
1561
|
+
# OAuth-aware doctor check (run.sh:9268).
|
|
1562
|
+
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
|
1563
|
+
local _claude_creds="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
|
|
1564
|
+
if [[ -s "$_claude_creds" ]]; then
|
|
1565
|
+
log_info "Claude Code OAuth credentials detected ($_claude_creds); using CLI login."
|
|
1566
|
+
return 0
|
|
1567
|
+
fi
|
|
1568
|
+
fi
|
|
1569
|
+
key_var="ANTHROPIC_API_KEY"
|
|
1570
|
+
;;
|
|
1554
1571
|
codex) key_var="OPENAI_API_KEY" ;;
|
|
1555
1572
|
cline) # Cline manages its own keys via `cline auth`
|
|
1556
1573
|
if ! command -v cline &>/dev/null; then
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -53,7 +53,7 @@ from . import auth
|
|
|
53
53
|
from . import audit
|
|
54
54
|
from . import app_secrets as secrets_mod
|
|
55
55
|
from . import telemetry as _telemetry
|
|
56
|
-
from .control import atomic_write_json
|
|
56
|
+
from .control import atomic_write_json, find_skill_dir, is_process_running
|
|
57
57
|
from .activity_logger import get_activity_logger
|
|
58
58
|
|
|
59
59
|
try:
|
|
@@ -2629,6 +2629,231 @@ async def list_running_projects():
|
|
|
2629
2629
|
return {"projects": out, "active_project_dir": active}
|
|
2630
2630
|
|
|
2631
2631
|
|
|
2632
|
+
class StartBuildRequest(BaseModel):
|
|
2633
|
+
"""Schema for starting a build from a spec via the dashboard.
|
|
2634
|
+
|
|
2635
|
+
Absorbs the browser PRD-input capability: the caller supplies a spec as
|
|
2636
|
+
inline text (prd_text -- a one-line brief or a full PRD) OR as a path to an
|
|
2637
|
+
existing spec file (prd_path). Exactly one is required. provider is
|
|
2638
|
+
optional and validated against the supported provider list.
|
|
2639
|
+
"""
|
|
2640
|
+
# prd_text is capped at 1 MiB: a real PRD is well under this, and the cap
|
|
2641
|
+
# bounds disk-fill / oversized-spawn from browser input (pydantic returns
|
|
2642
|
+
# 422 on overflow before any file write or subprocess spawn).
|
|
2643
|
+
prd_text: Optional[str] = Field(default=None, max_length=1_048_576)
|
|
2644
|
+
prd_path: Optional[str] = None
|
|
2645
|
+
provider: str = "claude"
|
|
2646
|
+
parallel: bool = False
|
|
2647
|
+
|
|
2648
|
+
def validate_provider(self) -> None:
|
|
2649
|
+
"""Validate provider is from the supported list.
|
|
2650
|
+
|
|
2651
|
+
Mirrors dashboard/control.py StartRequest.validate_provider so the
|
|
2652
|
+
dashboard and the standalone control app accept the same set.
|
|
2653
|
+
"""
|
|
2654
|
+
allowed = ["claude", "codex", "gemini", "cline", "aider"]
|
|
2655
|
+
if self.provider not in allowed:
|
|
2656
|
+
raise ValueError(
|
|
2657
|
+
f"Invalid provider: {self.provider}. "
|
|
2658
|
+
f"Must be one of: {', '.join(allowed)}"
|
|
2659
|
+
)
|
|
2660
|
+
|
|
2661
|
+
|
|
2662
|
+
def _validate_prd_path(raw_path: str, project_dir: _Path) -> _Path:
|
|
2663
|
+
"""Path-guard a caller-supplied PRD path.
|
|
2664
|
+
|
|
2665
|
+
Ports the proven traversal-safety logic from
|
|
2666
|
+
dashboard/control.py:StartRequest.validate_prd_path, but anchors the
|
|
2667
|
+
allowed roots to the resolved target project directory (the active
|
|
2668
|
+
dashboard project) plus the user's home, rather than the dashboard
|
|
2669
|
+
process CWD. Returns the resolved, verified path. Raises ValueError on
|
|
2670
|
+
any unsafe / nonexistent / non-file path.
|
|
2671
|
+
"""
|
|
2672
|
+
# Reject literal traversal sequences before any resolution.
|
|
2673
|
+
if ".." in raw_path:
|
|
2674
|
+
raise ValueError("PRD path contains path traversal sequence (..)")
|
|
2675
|
+
|
|
2676
|
+
prd_path = _Path(raw_path).expanduser().resolve()
|
|
2677
|
+
|
|
2678
|
+
if not prd_path.exists():
|
|
2679
|
+
raise ValueError(f"PRD file does not exist: {raw_path}")
|
|
2680
|
+
if not prd_path.is_file():
|
|
2681
|
+
raise ValueError(f"PRD path is not a file: {raw_path}")
|
|
2682
|
+
|
|
2683
|
+
# Must resolve within the target project dir or the user's home. This is
|
|
2684
|
+
# the post-resolution containment check: even a symlink that escaped the
|
|
2685
|
+
# no-".." check is caught here because relative_to is computed on the
|
|
2686
|
+
# fully-resolved real path.
|
|
2687
|
+
roots = [project_dir.resolve(), _Path.home().resolve()]
|
|
2688
|
+
for root in roots:
|
|
2689
|
+
try:
|
|
2690
|
+
prd_path.relative_to(root)
|
|
2691
|
+
return prd_path
|
|
2692
|
+
except ValueError:
|
|
2693
|
+
continue
|
|
2694
|
+
raise ValueError(f"PRD path is outside allowed directories: {raw_path}")
|
|
2695
|
+
|
|
2696
|
+
|
|
2697
|
+
def _write_spec_text(prd_text: str, project_dir: _Path) -> _Path:
|
|
2698
|
+
"""Persist an inline spec to .loki/specs/ and return its path.
|
|
2699
|
+
|
|
2700
|
+
The browser one-line-brief / PRD-textarea flow lands here. The file is
|
|
2701
|
+
written inside the target project's .loki/specs so it is contained and
|
|
2702
|
+
auditable; run.sh is then started against that file path.
|
|
2703
|
+
"""
|
|
2704
|
+
specs_dir = project_dir / ".loki" / "specs"
|
|
2705
|
+
specs_dir.mkdir(parents=True, exist_ok=True)
|
|
2706
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
2707
|
+
spec_file = specs_dir / f"dashboard-spec-{stamp}.md"
|
|
2708
|
+
spec_file.write_text(prd_text, encoding="utf-8")
|
|
2709
|
+
return spec_file
|
|
2710
|
+
|
|
2711
|
+
|
|
2712
|
+
def _project_run_active(loki_dir: _Path) -> Optional[int]:
|
|
2713
|
+
"""Single-flight check: return the live orchestrator PID if a run is active
|
|
2714
|
+
in this project, else None.
|
|
2715
|
+
|
|
2716
|
+
Honest: checks loki.pid (process alive) first, then session.json
|
|
2717
|
+
status=running with a 6h staleness window (mirrors control.get_status), so
|
|
2718
|
+
a crashed run that left a stale session.json does not block a fresh start.
|
|
2719
|
+
"""
|
|
2720
|
+
pid_file = loki_dir / "loki.pid"
|
|
2721
|
+
pid_str = _safe_read_text(pid_file).strip()
|
|
2722
|
+
if pid_str.isdigit():
|
|
2723
|
+
pid = int(pid_str)
|
|
2724
|
+
if is_process_running(pid):
|
|
2725
|
+
return pid
|
|
2726
|
+
|
|
2727
|
+
session_file = loki_dir / "session.json"
|
|
2728
|
+
if session_file.exists():
|
|
2729
|
+
try:
|
|
2730
|
+
sd = json.loads(session_file.read_text())
|
|
2731
|
+
if sd.get("status") == "running":
|
|
2732
|
+
started_at = sd.get("startedAt", "")
|
|
2733
|
+
if started_at:
|
|
2734
|
+
try:
|
|
2735
|
+
st = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
2736
|
+
age_h = (datetime.now(timezone.utc) - st).total_seconds() / 3600
|
|
2737
|
+
if age_h <= 6:
|
|
2738
|
+
return -1 # running per session.json, no usable pid
|
|
2739
|
+
except (ValueError, TypeError):
|
|
2740
|
+
return -1
|
|
2741
|
+
else:
|
|
2742
|
+
return -1
|
|
2743
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
2744
|
+
pass
|
|
2745
|
+
return None
|
|
2746
|
+
|
|
2747
|
+
|
|
2748
|
+
@app.post("/api/control/start", dependencies=[Depends(auth.require_scope("control"))])
|
|
2749
|
+
async def start_build(request: Request, body: StartBuildRequest):
|
|
2750
|
+
"""Start a Loki Mode build from a spec, kicked off from the browser.
|
|
2751
|
+
|
|
2752
|
+
Absorbs the one unique browser capability: PRD-input to kick off a build.
|
|
2753
|
+
Accepts a spec as inline text (prd_text) OR as a path (prd_path), validates
|
|
2754
|
+
and path-guards it, writes inline text into .loki/specs/, then spawns
|
|
2755
|
+
`run.sh` against the resolved spec via subprocess (same mechanism the
|
|
2756
|
+
standalone control app uses).
|
|
2757
|
+
|
|
2758
|
+
Single-flight: refuses with 409 if a run is already active in the target
|
|
2759
|
+
project.
|
|
2760
|
+
"""
|
|
2761
|
+
if not _control_limiter.check("control"):
|
|
2762
|
+
raise HTTPException(status_code=429, detail="Rate limit exceeded")
|
|
2763
|
+
|
|
2764
|
+
# Validate provider.
|
|
2765
|
+
try:
|
|
2766
|
+
body.validate_provider()
|
|
2767
|
+
except ValueError as e:
|
|
2768
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
2769
|
+
|
|
2770
|
+
# Exactly one spec source.
|
|
2771
|
+
has_text = bool(body.prd_text and body.prd_text.strip())
|
|
2772
|
+
has_path = bool(body.prd_path and body.prd_path.strip())
|
|
2773
|
+
if has_text == has_path:
|
|
2774
|
+
raise HTTPException(
|
|
2775
|
+
status_code=400,
|
|
2776
|
+
detail="Provide exactly one of prd_text or prd_path",
|
|
2777
|
+
)
|
|
2778
|
+
|
|
2779
|
+
# Resolve the target project directory from the active dashboard project.
|
|
2780
|
+
loki_dir = _get_loki_dir()
|
|
2781
|
+
project_dir = loki_dir.parent if loki_dir.name == ".loki" else _Path.cwd()
|
|
2782
|
+
project_dir = project_dir.resolve()
|
|
2783
|
+
|
|
2784
|
+
# Single-flight: refuse if a run is already active in this project.
|
|
2785
|
+
active_pid = _project_run_active(loki_dir)
|
|
2786
|
+
if active_pid is not None:
|
|
2787
|
+
detail = "A build is already running in this project"
|
|
2788
|
+
if active_pid > 0:
|
|
2789
|
+
detail += f" (PID {active_pid})"
|
|
2790
|
+
raise HTTPException(status_code=409, detail=detail)
|
|
2791
|
+
|
|
2792
|
+
# Resolve the spec to a concrete, path-guarded file.
|
|
2793
|
+
try:
|
|
2794
|
+
if has_path:
|
|
2795
|
+
spec_file = _validate_prd_path(body.prd_path.strip(), project_dir)
|
|
2796
|
+
else:
|
|
2797
|
+
spec_file = _write_spec_text(body.prd_text, project_dir)
|
|
2798
|
+
except ValueError as e:
|
|
2799
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
2800
|
+
except OSError as e:
|
|
2801
|
+
raise HTTPException(status_code=500, detail=f"Could not write spec: {e}")
|
|
2802
|
+
|
|
2803
|
+
# Locate run.sh (same resolver the standalone control app uses).
|
|
2804
|
+
skill_dir = find_skill_dir()
|
|
2805
|
+
run_sh = skill_dir / "autonomy" / "run.sh"
|
|
2806
|
+
if not run_sh.exists():
|
|
2807
|
+
raise HTTPException(status_code=500, detail=f"run.sh not found at {run_sh}")
|
|
2808
|
+
|
|
2809
|
+
# Build args: mirror control.py:start_session (provider, optional parallel,
|
|
2810
|
+
# background, then the spec path).
|
|
2811
|
+
args = [str(run_sh), "--provider", body.provider]
|
|
2812
|
+
if body.parallel:
|
|
2813
|
+
args.append("--parallel")
|
|
2814
|
+
args.append("--bg")
|
|
2815
|
+
args.append(str(spec_file))
|
|
2816
|
+
|
|
2817
|
+
try:
|
|
2818
|
+
process = subprocess.Popen(
|
|
2819
|
+
args,
|
|
2820
|
+
stdout=subprocess.DEVNULL,
|
|
2821
|
+
stderr=subprocess.DEVNULL,
|
|
2822
|
+
start_new_session=True,
|
|
2823
|
+
cwd=str(project_dir),
|
|
2824
|
+
)
|
|
2825
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
2826
|
+
raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
|
|
2827
|
+
|
|
2828
|
+
# Persist provider for status tracking (same as control.py).
|
|
2829
|
+
try:
|
|
2830
|
+
state_dir = loki_dir / "state"
|
|
2831
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
2832
|
+
(state_dir / "provider").write_text(body.provider)
|
|
2833
|
+
except OSError:
|
|
2834
|
+
pass
|
|
2835
|
+
|
|
2836
|
+
audit.log_event(
|
|
2837
|
+
action="start",
|
|
2838
|
+
resource_type="session",
|
|
2839
|
+
details={
|
|
2840
|
+
"source": "dashboard",
|
|
2841
|
+
"provider": body.provider,
|
|
2842
|
+
"spec": str(spec_file),
|
|
2843
|
+
"pid": process.pid,
|
|
2844
|
+
},
|
|
2845
|
+
ip_address=request.client.host if request.client else None,
|
|
2846
|
+
)
|
|
2847
|
+
|
|
2848
|
+
return {
|
|
2849
|
+
"success": True,
|
|
2850
|
+
"message": f"Build started with provider {body.provider}",
|
|
2851
|
+
"pid": process.pid,
|
|
2852
|
+
"spec": str(spec_file),
|
|
2853
|
+
"provider": body.provider,
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
|
|
2632
2857
|
class RunningProjectStopRequest(BaseModel):
|
|
2633
2858
|
"""Schema for stopping a specific registered project from the switcher.
|
|
2634
2859
|
|
|
@@ -2686,13 +2911,28 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
|
|
|
2686
2911
|
|
|
2687
2912
|
pid = project.get("pid")
|
|
2688
2913
|
if not isinstance(pid, int) or pid <= 0:
|
|
2689
|
-
#
|
|
2914
|
+
# No host pid. Two sub-cases:
|
|
2915
|
+
# - A `loki docker` project registers with pid=None but may still be
|
|
2916
|
+
# actively building inside a container. Its runner polls .loki/STOP
|
|
2917
|
+
# (bind-mounted to the host path), so writing STOP here actually
|
|
2918
|
+
# stops the containerized build -- this is the unified-dashboard Stop
|
|
2919
|
+
# parity for Docker projects (the container pid is meaningless on the
|
|
2920
|
+
# host, so os.kill is not an option).
|
|
2921
|
+
# - A genuinely stopped project: the STOP write is harmless.
|
|
2922
|
+
# Write STOP when we resolved a real .loki dir, then reconcile.
|
|
2923
|
+
stop_signaled = False
|
|
2924
|
+
if loki_dir is not None:
|
|
2925
|
+
try:
|
|
2926
|
+
(loki_dir / "STOP").write_text(datetime.now(timezone.utc).isoformat())
|
|
2927
|
+
stop_signaled = True
|
|
2928
|
+
except OSError:
|
|
2929
|
+
pass
|
|
2690
2930
|
registry.mark_project_stopped(project_id)
|
|
2691
2931
|
return {
|
|
2692
2932
|
"success": True,
|
|
2693
2933
|
"project_id": project_id,
|
|
2694
|
-
"stopped":
|
|
2695
|
-
"already_stopped":
|
|
2934
|
+
"stopped": stop_signaled,
|
|
2935
|
+
"already_stopped": not stop_signaled,
|
|
2696
2936
|
}
|
|
2697
2937
|
|
|
2698
2938
|
# Write the STOP file so the runner's own cleanup STOP-branch fires for a
|