loki-mode 5.32.2 → 5.34.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/autonomy/run.sh CHANGED
@@ -1067,14 +1067,16 @@ import_github_issues() {
1067
1067
  local pending_file=".loki/queue/pending.json"
1068
1068
  local task_count=0
1069
1069
 
1070
- # Ensure pending.json exists with correct format for GitHub import
1070
+ # BUG #14 fix: Normalize to bare [] format (consistent with init_loki_dir
1071
+ # and all other queue consumers). Previously used {"tasks":[]} wrapper here
1072
+ # but bare [] everywhere else, causing format mismatch.
1071
1073
  if [ ! -f "$pending_file" ]; then
1072
- echo '{"tasks":[]}' > "$pending_file"
1073
- elif jq -e 'type == "array"' "$pending_file" &>/dev/null; then
1074
- # Normalize bare array format to {"tasks":[...]} for GitHub import compatibility
1074
+ echo '[]' > "$pending_file"
1075
+ elif jq -e 'type == "object"' "$pending_file" &>/dev/null; then
1076
+ # Normalize {"tasks":[...]} wrapper to bare array
1075
1077
  local _tmp_normalize
1076
1078
  _tmp_normalize=$(mktemp)
1077
- jq '{tasks: .}' "$pending_file" > "$_tmp_normalize" && mv "$_tmp_normalize" "$pending_file"
1079
+ jq 'if type == "object" then .tasks // [] else . end' "$pending_file" > "$_tmp_normalize" && mv "$_tmp_normalize" "$pending_file"
1078
1080
  rm -f "$_tmp_normalize"
1079
1081
  fi
1080
1082
 
@@ -1094,8 +1096,8 @@ import_github_issues() {
1094
1096
  url=$(echo "$issue" | jq -r '.url')
1095
1097
  labels=$(echo "$issue" | jq -c '[.labels[].name]')
1096
1098
 
1097
- # Check if task already exists
1098
- if jq -e ".tasks[] | select(.github_issue == $number)" "$pending_file" &>/dev/null; then
1099
+ # Check if task already exists (bare array format)
1100
+ if jq -e ".[] | select(.github_issue == $number)" "$pending_file" &>/dev/null; then
1099
1101
  log_info "Issue #$number already imported, skipping"
1100
1102
  continue
1101
1103
  fi
@@ -1137,10 +1139,10 @@ import_github_issues() {
1137
1139
  created_at: $created
1138
1140
  }')
1139
1141
 
1140
- # Append to pending.json with temp file cleanup on error
1142
+ # Append to pending.json (bare array format) with temp file cleanup on error
1141
1143
  local temp_file
1142
1144
  temp_file=$(mktemp)
1143
- if jq ".tasks += [$task_json]" "$pending_file" > "$temp_file" && mv "$temp_file" "$pending_file"; then
1145
+ if jq ". += [$task_json]" "$pending_file" > "$temp_file" && mv "$temp_file" "$pending_file"; then
1144
1146
  log_info "Imported issue #$number: $title"
1145
1147
  task_count=$((task_count + 1))
1146
1148
  else
@@ -1293,8 +1295,8 @@ export_tasks_to_github() {
1293
1295
  return 0
1294
1296
  fi
1295
1297
 
1296
- # Export non-GitHub tasks as issues
1297
- jq -c '.tasks[] | select(.source != "github")' "$pending_file" 2>/dev/null | while read -r task; do
1298
+ # Export non-GitHub tasks as issues (handles both bare array and wrapper formats)
1299
+ jq -c 'if type == "object" then .tasks // [] else . end | .[] | select(.source != "github")' "$pending_file" 2>/dev/null | while read -r task; do
1298
1300
  local title desc
1299
1301
  title=$(echo "$task" | jq -r '.title')
1300
1302
  desc=$(echo "$task" | jq -r '.description // ""')
@@ -2415,13 +2417,20 @@ write_dashboard_state() {
2415
2417
  local project_path=$(pwd)
2416
2418
  local _tmp_state="${output_file}.tmp"
2417
2419
 
2420
+ # BUG #49 fix: Escape project path/name for JSON to handle special chars
2421
+ # (spaces, quotes, backslashes in directory names)
2422
+ local project_name_escaped
2423
+ local project_path_escaped
2424
+ project_name_escaped=$(printf '%s' "$project_name" | sed 's/\\/\\\\/g; s/"/\\"/g')
2425
+ project_path_escaped=$(printf '%s' "$project_path" | sed 's/\\/\\\\/g; s/"/\\"/g')
2426
+
2418
2427
  cat > "$_tmp_state" << EOF
2419
2428
  {
2420
2429
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
2421
2430
  "version": "$version",
2422
2431
  "project": {
2423
- "name": "$project_name",
2424
- "path": "$project_path"
2432
+ "name": "$project_name_escaped",
2433
+ "path": "$project_path_escaped"
2425
2434
  },
2426
2435
  "mode": "$mode",
2427
2436
  "provider": "${PROVIDER_NAME:-claude}",
@@ -3870,6 +3879,167 @@ if top:
3870
3879
  SOLUTIONS_SCRIPT
3871
3880
  }
3872
3881
 
3882
+ # ============================================================================
3883
+ # Checkpoint/Snapshot System (v5.34.0)
3884
+ # Git-based checkpoints after task completion with state snapshots
3885
+ # Inspired by Cursor Self-Driving Codebases + Entire.io provenance tracking
3886
+ # ============================================================================
3887
+
3888
+ create_checkpoint() {
3889
+ # Create a git checkpoint after task completion
3890
+ # Args: $1 = task description, $2 = task_id (optional)
3891
+ local task_desc="${1:-task completed}"
3892
+ local task_id="${2:-unknown}"
3893
+ local checkpoint_dir=".loki/state/checkpoints"
3894
+ local iteration="${ITERATION_COUNT:-0}"
3895
+
3896
+ mkdir -p "$checkpoint_dir"
3897
+
3898
+ # Only checkpoint if there are uncommitted changes
3899
+ if ! git diff --quiet 2>/dev/null && ! git diff --cached --quiet 2>/dev/null; then
3900
+ log_info "No uncommitted changes to checkpoint"
3901
+ return 0
3902
+ fi
3903
+
3904
+ # Capture git state
3905
+ local git_sha
3906
+ git_sha=$(git rev-parse HEAD 2>/dev/null || echo "no-git")
3907
+ local git_branch
3908
+ git_branch=$(git branch --show-current 2>/dev/null || echo "unknown")
3909
+
3910
+ # Snapshot .loki state files
3911
+ local timestamp
3912
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
3913
+ local checkpoint_id="cp-${iteration}-$(date +%s)"
3914
+ local cp_dir="${checkpoint_dir}/${checkpoint_id}"
3915
+
3916
+ mkdir -p "$cp_dir"
3917
+
3918
+ # Copy critical state files (lightweight -- not full .loki/)
3919
+ for f in state/orchestrator.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json; do
3920
+ if [ -f ".loki/$f" ]; then
3921
+ local target_dir="$cp_dir/$(dirname "$f")"
3922
+ mkdir -p "$target_dir"
3923
+ cp ".loki/$f" "$cp_dir/$f" 2>/dev/null || true
3924
+ fi
3925
+ done
3926
+
3927
+ # Write checkpoint metadata
3928
+ local safe_desc
3929
+ safe_desc=$(printf '%s' "$task_desc" | sed 's/\\/\\\\/g; s/"/\\"/g' | head -c 200)
3930
+ cat > "$cp_dir/metadata.json" << CPEOF
3931
+ {
3932
+ "id": "${checkpoint_id}",
3933
+ "timestamp": "${timestamp}",
3934
+ "iteration": ${iteration},
3935
+ "task_id": "${task_id}",
3936
+ "task_description": "${safe_desc}",
3937
+ "git_sha": "${git_sha}",
3938
+ "git_branch": "${git_branch}",
3939
+ "provider": "${PROVIDER_NAME:-claude}",
3940
+ "phase": "$(cat .loki/state/orchestrator.json 2>/dev/null | python3 -c 'import sys,json; print(json.load(sys.stdin).get("currentPhase","unknown"))' 2>/dev/null || echo 'unknown')"
3941
+ }
3942
+ CPEOF
3943
+
3944
+ # Maintain checkpoint index for fast listing
3945
+ local index_file="${checkpoint_dir}/index.jsonl"
3946
+ printf '{"id":"%s","ts":"%s","iter":%d,"task":"%s","sha":"%s"}\n' \
3947
+ "$checkpoint_id" "$timestamp" "$iteration" "$safe_desc" "$git_sha" \
3948
+ >> "$index_file"
3949
+
3950
+ # Retention: keep last 50 checkpoints, prune older
3951
+ local cp_count
3952
+ cp_count=$(find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | wc -l | tr -d ' ')
3953
+ if [ "$cp_count" -gt 50 ]; then
3954
+ local to_remove=$((cp_count - 50))
3955
+ find "$checkpoint_dir" -maxdepth 1 -type d -name "cp-*" 2>/dev/null | sort | head -n "$to_remove" | while read -r old_cp; do
3956
+ rm -r "$old_cp" 2>/dev/null || true
3957
+ done
3958
+ # Rebuild index from remaining checkpoints
3959
+ : > "$index_file"
3960
+ for remaining in "$checkpoint_dir"/cp-*/metadata.json; do
3961
+ [ -f "$remaining" ] || continue
3962
+ python3 -c "
3963
+ import json,sys
3964
+ m=json.load(open('$remaining'))
3965
+ print(json.dumps({'id':m['id'],'ts':m['timestamp'],'iter':m['iteration'],'task':m.get('task_description',''),'sha':m['git_sha']}))
3966
+ " >> "$index_file" 2>/dev/null || true
3967
+ done
3968
+ fi
3969
+
3970
+ log_info "Checkpoint created: ${checkpoint_id} (git: ${git_sha:0:8})"
3971
+ }
3972
+
3973
+ rollback_to_checkpoint() {
3974
+ # Rollback state files to a specific checkpoint
3975
+ # Args: $1 = checkpoint_id
3976
+ local checkpoint_id="$1"
3977
+ local checkpoint_dir=".loki/state/checkpoints"
3978
+ local cp_dir="${checkpoint_dir}/${checkpoint_id}"
3979
+
3980
+ if [ ! -d "$cp_dir" ]; then
3981
+ log_error "Checkpoint not found: ${checkpoint_id}"
3982
+ return 1
3983
+ fi
3984
+
3985
+ # Read checkpoint metadata
3986
+ local git_sha
3987
+ git_sha=$(python3 -c "import json; print(json.load(open('${cp_dir}/metadata.json'))['git_sha'])" 2>/dev/null || echo "")
3988
+
3989
+ log_warn "Rolling back to checkpoint: ${checkpoint_id}"
3990
+
3991
+ # Create a pre-rollback checkpoint first
3992
+ create_checkpoint "pre-rollback snapshot" "rollback"
3993
+
3994
+ # Restore state files
3995
+ for f in state/orchestrator.json queue/pending.json queue/completed.json queue/in-progress.json queue/current-task.json; do
3996
+ if [ -f "${cp_dir}/${f}" ]; then
3997
+ local target_dir=".loki/$(dirname "$f")"
3998
+ mkdir -p "$target_dir"
3999
+ cp "${cp_dir}/${f}" ".loki/${f}" 2>/dev/null || true
4000
+ fi
4001
+ done
4002
+
4003
+ # Log the rollback
4004
+ local timestamp
4005
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
4006
+ printf '{"event":"rollback","checkpoint":"%s","git_sha":"%s","timestamp":"%s"}\n' \
4007
+ "$checkpoint_id" "$git_sha" "$timestamp" \
4008
+ >> ".loki/events.jsonl" 2>/dev/null || true
4009
+
4010
+ log_info "State files restored from checkpoint: ${checkpoint_id}"
4011
+
4012
+ if [ -n "$git_sha" ] && [ "$git_sha" != "no-git" ]; then
4013
+ log_info "Git SHA at checkpoint: ${git_sha}"
4014
+ log_info "To rollback code: git reset --hard ${git_sha}"
4015
+ fi
4016
+ }
4017
+
4018
+ list_checkpoints() {
4019
+ # List recent checkpoints
4020
+ local checkpoint_dir=".loki/state/checkpoints"
4021
+ local index_file="${checkpoint_dir}/index.jsonl"
4022
+ local limit="${1:-10}"
4023
+
4024
+ if [ ! -f "$index_file" ]; then
4025
+ echo "No checkpoints found."
4026
+ return
4027
+ fi
4028
+
4029
+ tail -n "$limit" "$index_file" | python3 -c "
4030
+ import sys, json
4031
+ lines = sys.stdin.readlines()
4032
+ for line in reversed(lines):
4033
+ try:
4034
+ cp = json.loads(line)
4035
+ sha = cp.get('sha','')[:8]
4036
+ task = cp.get('task','')[:60]
4037
+ print(f\" {cp['id']} {cp['ts']} [{sha}] {task}\")
4038
+ except:
4039
+ continue
4040
+ "
4041
+ }
4042
+
3873
4043
  start_dashboard() {
3874
4044
  log_header "Starting Loki Dashboard"
3875
4045
 
@@ -4665,12 +4835,12 @@ build_prompt() {
4665
4835
  fi
4666
4836
 
4667
4837
  # Human directive injection (from HUMAN_INPUT.md)
4838
+ # NOTE: Do NOT unset LOKI_HUMAN_INPUT here - build_prompt runs in a subshell
4839
+ # (command substitution) so unset would not affect the parent shell.
4840
+ # The caller (run_autonomous) clears it after consuming the prompt.
4668
4841
  local human_directive=""
4669
4842
  if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then
4670
4843
  human_directive="HUMAN_DIRECTIVE (PRIORITY): $LOKI_HUMAN_INPUT Execute this directive BEFORE continuing normal tasks."
4671
- # Clear after consumption so it doesn't repeat every iteration
4672
- unset LOKI_HUMAN_INPUT
4673
- rm -f "${TARGET_DIR:-.}/.loki/HUMAN_INPUT.md"
4674
4844
  fi
4675
4845
 
4676
4846
  # Queue task injection (from dashboard or API)
@@ -4795,6 +4965,15 @@ run_autonomous() {
4795
4965
 
4796
4966
  local prompt=$(build_prompt $retry "$prd_path" $ITERATION_COUNT)
4797
4967
 
4968
+ # BUG #5 fix: Clear LOKI_HUMAN_INPUT in the parent shell after build_prompt
4969
+ # consumed it. build_prompt runs in a subshell (command substitution), so
4970
+ # any unset inside it does not affect the parent. Clear here to prevent
4971
+ # the same directive from repeating every iteration.
4972
+ if [ -n "${LOKI_HUMAN_INPUT:-}" ]; then
4973
+ unset LOKI_HUMAN_INPUT
4974
+ rm -f "${TARGET_DIR:-.}/.loki/HUMAN_INPUT.md"
4975
+ fi
4976
+
4798
4977
  echo ""
4799
4978
  log_header "Attempt $((retry + 1)) of $MAX_RETRIES"
4800
4979
  log_info "Prompt: $prompt"
@@ -5234,11 +5413,19 @@ check_human_intervention() {
5234
5413
  local loki_dir="${TARGET_DIR:-.}/.loki"
5235
5414
 
5236
5415
  # Check for PAUSE file
5416
+ # BUG #4 fix: Check handle_pause return value before deleting PAUSE file.
5417
+ # handle_pause returns 1 if STOP was requested during the pause, so we must
5418
+ # propagate that as return 2 (stop) instead of always returning 1 (continue).
5237
5419
  if [ -f "$loki_dir/PAUSE" ]; then
5238
5420
  log_warn "PAUSE file detected - pausing execution"
5239
5421
  notify_intervention_needed "Execution paused via PAUSE file"
5240
5422
  handle_pause
5423
+ local pause_result=$?
5241
5424
  rm -f "$loki_dir/PAUSE"
5425
+ if [ "$pause_result" -eq 1 ]; then
5426
+ # STOP was requested during pause
5427
+ return 2
5428
+ fi
5242
5429
  return 1
5243
5430
  fi
5244
5431
 
@@ -5287,8 +5474,13 @@ check_human_intervention() {
5287
5474
  rm -f "$loki_dir/signals/COUNCIL_REVIEW_REQUESTED"
5288
5475
  if type council_vote &>/dev/null && council_vote; then
5289
5476
  log_header "COMPLETION COUNCIL: FORCE REVIEW - PROJECT COMPLETE"
5290
- # Complete the missing steps: COMPLETED marker, memory consolidation, report
5291
- touch "$loki_dir/COMPLETED"
5477
+ # BUG #17 fix: Write COMPLETED marker, generate council report, and
5478
+ # run memory consolidation (matching the normal council approval path
5479
+ # in council_should_stop).
5480
+ echo "Council force-review approved at iteration $ITERATION_COUNT on $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$loki_dir/COMPLETED"
5481
+ if type council_write_report &>/dev/null; then
5482
+ council_write_report
5483
+ fi
5292
5484
  log_info "Running memory consolidation..."
5293
5485
  run_memory_consolidation
5294
5486
  notify_all_complete
@@ -5764,6 +5956,9 @@ main() {
5764
5956
  # Compound learnings into structured solution files (v5.30.0)
5765
5957
  compound_session_to_solutions
5766
5958
 
5959
+ # Create session-end checkpoint (v5.34.0)
5960
+ create_checkpoint "session end (iterations=$ITERATION_COUNT)" "session-end"
5961
+
5767
5962
  # Log session end for audit
5768
5963
  audit_log "SESSION_END" "result=$result,prd=$PRD_PATH"
5769
5964
 
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "5.32.2"
10
+ __version__ = "5.34.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -56,10 +56,13 @@ app = FastAPI(
56
56
  version="1.0.0"
57
57
  )
58
58
 
59
- # CORS middleware for dashboard frontend
59
+ # CORS middleware for dashboard frontend - restricted to localhost by default.
60
+ # Set LOKI_DASHBOARD_CORS to override (comma-separated origins).
61
+ _cors_default = "http://localhost:57374,http://127.0.0.1:57374"
62
+ _cors_origins = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default).split(",")
60
63
  app.add_middleware(
61
64
  CORSMiddleware,
62
- allow_origins=["*"],
65
+ allow_origins=[o.strip() for o in _cors_origins if o.strip()],
63
66
  allow_credentials=True,
64
67
  allow_methods=["*"],
65
68
  allow_headers=["*"],
@@ -511,4 +514,5 @@ async def asyncio_sleep(seconds: float):
511
514
  if __name__ == "__main__":
512
515
  import uvicorn
513
516
  port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
514
- uvicorn.run(app, host="0.0.0.0", port=port)
517
+ host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
518
+ uvicorn.run(app, host=host, port=port)
@@ -209,9 +209,10 @@ app = FastAPI(
209
209
  lifespan=lifespan,
210
210
  )
211
211
 
212
- # Add CORS middleware - allow_origins=* is safe because server binds to
213
- # 127.0.0.1 by default. Set LOKI_DASHBOARD_HOST to override if LAN access needed.
214
- _cors_origins = os.environ.get("CORS_ALLOWED_ORIGINS", "*").split(",")
212
+ # Add CORS middleware - restricted to localhost by default.
213
+ # Set LOKI_DASHBOARD_CORS to override (comma-separated origins).
214
+ _cors_default = "http://localhost:57374,http://127.0.0.1:57374"
215
+ _cors_origins = os.environ.get("LOKI_DASHBOARD_CORS", _cors_default).split(",")
215
216
  app.add_middleware(
216
217
  CORSMiddleware,
217
218
  allow_origins=[o.strip() for o in _cors_origins if o.strip()],
@@ -1983,6 +1984,149 @@ async def force_council_review():
1983
1984
  return {"success": True, "message": "Council review requested"}
1984
1985
 
1985
1986
 
1987
+ # =============================================================================
1988
+ # Checkpoint API (v5.34.0)
1989
+ # =============================================================================
1990
+
1991
+ class CheckpointCreate(BaseModel):
1992
+ """Schema for creating a checkpoint."""
1993
+ message: Optional[str] = Field(None, description="Optional description for the checkpoint")
1994
+
1995
+
1996
+ def _sanitize_checkpoint_id(checkpoint_id: str) -> str:
1997
+ """Validate checkpoint_id contains only safe characters for file paths."""
1998
+ if not checkpoint_id or ".." in checkpoint_id or not _SAFE_ID_RE.match(checkpoint_id):
1999
+ raise HTTPException(
2000
+ status_code=400,
2001
+ detail="Invalid checkpoint_id: must contain only alphanumeric characters, hyphens, and underscores",
2002
+ )
2003
+ return checkpoint_id
2004
+
2005
+
2006
+ @app.get("/api/checkpoints")
2007
+ async def list_checkpoints(limit: int = Query(default=20, ge=1, le=200)):
2008
+ """List recent checkpoints from index.jsonl."""
2009
+ loki_dir = _get_loki_dir()
2010
+ index_file = loki_dir / "state" / "checkpoints" / "index.jsonl"
2011
+ checkpoints = []
2012
+
2013
+ if index_file.exists():
2014
+ try:
2015
+ for line in index_file.read_text().strip().split("\n"):
2016
+ if line.strip():
2017
+ try:
2018
+ checkpoints.append(json.loads(line))
2019
+ except json.JSONDecodeError:
2020
+ pass
2021
+ except Exception:
2022
+ pass
2023
+
2024
+ # Return most recent first, limited
2025
+ checkpoints.reverse()
2026
+ return checkpoints[:limit]
2027
+
2028
+
2029
+ @app.get("/api/checkpoints/{checkpoint_id}")
2030
+ async def get_checkpoint(checkpoint_id: str):
2031
+ """Get checkpoint details by ID."""
2032
+ checkpoint_id = _sanitize_checkpoint_id(checkpoint_id)
2033
+ loki_dir = _get_loki_dir()
2034
+ metadata_file = loki_dir / "state" / "checkpoints" / checkpoint_id / "metadata.json"
2035
+
2036
+ if not metadata_file.exists():
2037
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
2038
+
2039
+ try:
2040
+ return json.loads(metadata_file.read_text())
2041
+ except (json.JSONDecodeError, IOError) as e:
2042
+ raise HTTPException(status_code=500, detail=f"Failed to read checkpoint: {e}")
2043
+
2044
+
2045
+ @app.post("/api/checkpoints", status_code=201)
2046
+ async def create_checkpoint(body: CheckpointCreate = None):
2047
+ """Create a new checkpoint capturing current state."""
2048
+ import subprocess
2049
+ import shutil
2050
+
2051
+ loki_dir = _get_loki_dir()
2052
+ checkpoints_dir = loki_dir / "state" / "checkpoints"
2053
+ checkpoints_dir.mkdir(parents=True, exist_ok=True)
2054
+
2055
+ # Generate checkpoint ID from timestamp
2056
+ now = datetime.now(timezone.utc)
2057
+ checkpoint_id = now.strftime("chk-%Y%m%d-%H%M%S")
2058
+
2059
+ # Create checkpoint directory
2060
+ checkpoint_dir = checkpoints_dir / checkpoint_id
2061
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
2062
+
2063
+ # Capture git SHA
2064
+ git_sha = ""
2065
+ try:
2066
+ result = subprocess.run(
2067
+ ["git", "rev-parse", "HEAD"],
2068
+ capture_output=True, text=True, timeout=5,
2069
+ )
2070
+ if result.returncode == 0:
2071
+ git_sha = result.stdout.strip()
2072
+ except Exception:
2073
+ pass
2074
+
2075
+ # Copy key state files into checkpoint
2076
+ state_files = [
2077
+ "dashboard-state.json",
2078
+ "session.json",
2079
+ ]
2080
+ for fname in state_files:
2081
+ src = loki_dir / fname
2082
+ if src.exists():
2083
+ try:
2084
+ shutil.copy2(str(src), str(checkpoint_dir / fname))
2085
+ except Exception:
2086
+ pass
2087
+
2088
+ # Copy queue directory if present
2089
+ queue_src = loki_dir / "queue"
2090
+ if queue_src.exists():
2091
+ try:
2092
+ shutil.copytree(str(queue_src), str(checkpoint_dir / "queue"), dirs_exist_ok=True)
2093
+ except Exception:
2094
+ pass
2095
+
2096
+ # Build metadata
2097
+ message = ""
2098
+ if body and body.message:
2099
+ message = body.message
2100
+
2101
+ metadata = {
2102
+ "id": checkpoint_id,
2103
+ "created_at": now.isoformat(),
2104
+ "git_sha": git_sha,
2105
+ "message": message,
2106
+ "files": [f.name for f in checkpoint_dir.iterdir() if f.is_file()],
2107
+ }
2108
+
2109
+ # Write metadata.json
2110
+ (checkpoint_dir / "metadata.json").write_text(json.dumps(metadata, indent=2))
2111
+
2112
+ # Append to index.jsonl
2113
+ index_file = checkpoints_dir / "index.jsonl"
2114
+ with open(str(index_file), "a") as f:
2115
+ f.write(json.dumps(metadata) + "\n")
2116
+
2117
+ # Retention policy: keep last 50 checkpoints
2118
+ MAX_CHECKPOINTS = 50
2119
+ all_dirs = sorted(
2120
+ [d for d in checkpoints_dir.iterdir() if d.is_dir()],
2121
+ key=lambda d: d.name,
2122
+ )
2123
+ while len(all_dirs) > MAX_CHECKPOINTS:
2124
+ oldest = all_dirs.pop(0)
2125
+ shutil.rmtree(str(oldest), ignore_errors=True)
2126
+
2127
+ return metadata
2128
+
2129
+
1986
2130
  # =============================================================================
1987
2131
  # Agent Management API (v5.25.0)
1988
2132
  # =============================================================================
@@ -2046,6 +2190,7 @@ async def get_agents():
2046
2190
  @app.post("/api/agents/{agent_id}/kill")
2047
2191
  async def kill_agent(agent_id: str):
2048
2192
  """Kill a specific agent by ID."""
2193
+ agent_id = _sanitize_agent_id(agent_id)
2049
2194
  agents_file = _get_loki_dir() / "state" / "agents.json"
2050
2195
  if not agents_file.exists():
2051
2196
  raise HTTPException(404, "No agents file found")
@@ -2296,7 +2441,7 @@ def run_server(host: str = None, port: int = None) -> None:
2296
2441
  """Run the dashboard server."""
2297
2442
  import uvicorn
2298
2443
  if host is None:
2299
- # Default to localhost-only; CORS * is safe since not exposed to LAN
2444
+ # Default to localhost-only for security
2300
2445
  host = os.environ.get("LOKI_DASHBOARD_HOST", "127.0.0.1")
2301
2446
  if port is None:
2302
2447
  port = int(os.environ.get("LOKI_DASHBOARD_PORT", "57374"))
@@ -3750,7 +3750,7 @@ var LokiDashboard=(()=>{var N=Object.defineProperty;var se=Object.getOwnProperty
3750
3750
  ${t}
3751
3751
  </div>
3752
3752
  </div>
3753
- `,this._attachEventListeners()}_attachEventListeners(){let e=this.shadowRoot.getElementById("time-range-select");e&&e.addEventListener("change",r=>this._setFilter("timeRange",r.target.value));let t=this.shadowRoot.getElementById("signal-type-select");t&&t.addEventListener("change",r=>this._setFilter("signalType",r.target.value));let a=this.shadowRoot.getElementById("source-select");a&&a.addEventListener("change",r=>this._setFilter("source",r.target.value));let i=this.shadowRoot.getElementById("refresh-btn");i&&i.addEventListener("click",()=>this._loadData());let s=this.shadowRoot.getElementById("close-detail");s&&s.addEventListener("click",()=>this._closeDetail()),this.shadowRoot.querySelectorAll(".list-item").forEach(r=>{r.addEventListener("click",()=>{let n=r.dataset.type,d=r.dataset.id,p=this._findItemData(n,d);p&&this._selectMetric(n,p)}),r.addEventListener("keydown",n=>{(n.key==="Enter"||n.key===" ")&&(n.preventDefault(),r.click())})})}_findItemData(e,t){if(!this._metrics?.aggregation)return null;switch(e){case"preference":return this._metrics.aggregation.preferences?.find(a=>a.preference_key===t);case"error_pattern":return this._metrics.aggregation.error_patterns?.find(a=>a.error_type===t);case"success_pattern":return this._metrics.aggregation.success_patterns?.find(a=>a.pattern_name===t);case"tool_efficiency":return this._metrics.aggregation.tool_efficiencies?.find(a=>a.tool_name===t);default:return null}}};customElements.get("loki-learning-dashboard")||customElements.define("loki-learning-dashboard",H);var me=[{id:"overview",label:"Overview"},{id:"decisions",label:"Decision Log"},{id:"convergence",label:"Convergence"},{id:"agents",label:"Agents"}],O=class extends c{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._loading=!1,this._error=null,this._api=null,this._activeTab="overview",this._pollInterval=null,this._councilState=null,this._verdicts=[],this._convergence=[],this._agents=[],this._selectedAgent=null,this._lastDataHash=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadData(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling()}attributeChangedCallback(e,t,a){t!==a&&(e==="api-url"&&this._api&&(this._api.baseUrl=a,this._loadData()),e==="theme"&&this._applyTheme())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:e})}_startPolling(){this._pollInterval=setInterval(()=>this._loadData(),3e3),this._visibilityHandler=()=>{document.hidden?this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null):this._pollInterval||(this._loadData(),this._pollInterval=setInterval(()=>this._loadData(),3e3))},document.addEventListener("visibilitychange",this._visibilityHandler)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._visibilityHandler&&(document.removeEventListener("visibilitychange",this._visibilityHandler),this._visibilityHandler=null)}async _loadData(){try{let[t,a,i,s]=await Promise.allSettled([this._api._get("/api/council/state"),this._api._get("/api/council/verdicts"),this._api._get("/api/council/convergence"),this._api._get("/api/agents")]);t.status==="fulfilled"&&(this._councilState=t.value),a.status==="fulfilled"&&(this._verdicts=a.value.verdicts||[]),i.status==="fulfilled"&&(this._convergence=i.value.dataPoints||[]),s.status==="fulfilled"&&(this._agents=Array.isArray(s.value)?s.value:[]),this._error=null}catch(t){this._error=t.message}let e=JSON.stringify({s:this._councilState,v:this._verdicts,c:this._convergence,a:this._agents,e:this._error});e!==this._lastDataHash&&(this._lastDataHash=e,this.render())}async _forceReview(){try{await this._api._post("/api/council/force-review"),this.dispatchEvent(new CustomEvent("council-action",{detail:{action:"force-review"},bubbles:!0}))}catch(e){this._error=`Failed to force review: ${e.message}`,this.render()}}async _killAgent(e){if(confirm(`Kill agent ${e}?`))try{await this._api._post(`/api/agents/${e}/kill`),this.dispatchEvent(new CustomEvent("council-action",{detail:{action:"kill-agent",agentId:e},bubbles:!0})),await this._loadData()}catch(t){this._error=`Failed to kill agent: ${t.message}`,this.render()}}async _pauseAgent(e){try{await this._api._post(`/api/agents/${e}/pause`),await this._loadData()}catch(t){this._error=`Failed to pause agent: ${t.message}`,this.render()}}async _resumeAgent(e){try{await this._api._post(`/api/agents/${e}/resume`),await this._loadData()}catch(t){this._error=`Failed to resume agent: ${t.message}`,this.render()}}_setTab(e){this._activeTab=e,this.render()}_selectAgent(e){this._selectedAgent=this._selectedAgent?.id===e.id?null:e,this.render()}render(){let e=this.shadowRoot;e&&(e.innerHTML=`
3753
+ `,this._attachEventListeners()}_attachEventListeners(){let e=this.shadowRoot.getElementById("time-range-select");e&&e.addEventListener("change",r=>this._setFilter("timeRange",r.target.value));let t=this.shadowRoot.getElementById("signal-type-select");t&&t.addEventListener("change",r=>this._setFilter("signalType",r.target.value));let a=this.shadowRoot.getElementById("source-select");a&&a.addEventListener("change",r=>this._setFilter("source",r.target.value));let i=this.shadowRoot.getElementById("refresh-btn");i&&i.addEventListener("click",()=>this._loadData());let s=this.shadowRoot.getElementById("close-detail");s&&s.addEventListener("click",()=>this._closeDetail()),this.shadowRoot.querySelectorAll(".list-item").forEach(r=>{r.addEventListener("click",()=>{let n=r.dataset.type,d=r.dataset.id,p=this._findItemData(n,d);p&&this._selectMetric(n,p)}),r.addEventListener("keydown",n=>{(n.key==="Enter"||n.key===" ")&&(n.preventDefault(),r.click())})})}_findItemData(e,t){if(!this._metrics?.aggregation)return null;switch(e){case"preference":return this._metrics.aggregation.preferences?.find(a=>a.preference_key===t);case"error_pattern":return this._metrics.aggregation.error_patterns?.find(a=>a.error_type===t);case"success_pattern":return this._metrics.aggregation.success_patterns?.find(a=>a.pattern_name===t);case"tool_efficiency":return this._metrics.aggregation.tool_efficiencies?.find(a=>a.tool_name===t);default:return null}}};customElements.get("loki-learning-dashboard")||customElements.define("loki-learning-dashboard",H);var me=[{id:"overview",label:"Overview"},{id:"decisions",label:"Decision Log"},{id:"convergence",label:"Convergence"},{id:"agents",label:"Agents"}],O=class extends c{static get observedAttributes(){return["api-url","theme"]}constructor(){super(),this._loading=!1,this._error=null,this._api=null,this._activeTab="overview",this._pollInterval=null,this._councilState=null,this._verdicts=[],this._convergence=[],this._agents=[],this._selectedAgent=null,this._lastDataHash=null}connectedCallback(){super.connectedCallback(),this._setupApi(),this._loadData(),this._startPolling()}disconnectedCallback(){super.disconnectedCallback(),this._stopPolling()}attributeChangedCallback(e,t,a){t!==a&&(e==="api-url"&&this._api&&(this._api.baseUrl=a,this._loadData()),e==="theme"&&this._applyTheme())}_setupApi(){let e=this.getAttribute("api-url")||window.location.origin;this._api=g({baseUrl:e})}_startPolling(){this._pollInterval=setInterval(()=>this._loadData(),3e3),this._visibilityHandler=()=>{document.hidden?this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null):this._pollInterval||(this._loadData(),this._pollInterval=setInterval(()=>this._loadData(),3e3))},document.addEventListener("visibilitychange",this._visibilityHandler)}_stopPolling(){this._pollInterval&&(clearInterval(this._pollInterval),this._pollInterval=null),this._visibilityHandler&&(document.removeEventListener("visibilitychange",this._visibilityHandler),this._visibilityHandler=null),this._pendingRaf&&(cancelAnimationFrame(this._pendingRaf),this._pendingRaf=null)}async _loadData(){try{let[t,a,i,s]=await Promise.allSettled([this._api._get("/api/council/state"),this._api._get("/api/council/verdicts"),this._api._get("/api/council/convergence"),this._api._get("/api/agents")]);t.status==="fulfilled"&&(this._councilState=t.value),a.status==="fulfilled"&&(this._verdicts=a.value.verdicts||[]),i.status==="fulfilled"&&(this._convergence=i.value.dataPoints||[]),s.status==="fulfilled"&&(this._agents=Array.isArray(s.value)?s.value:[]),this._error=null}catch(t){this._error=t.message}let e=JSON.stringify({s:this._councilState,v:this._verdicts,c:this._convergence,a:this._agents,e:this._error});e!==this._lastDataHash&&(this._lastDataHash=e,this.render())}async _forceReview(){try{await this._api._post("/api/council/force-review"),this.dispatchEvent(new CustomEvent("council-action",{detail:{action:"force-review"},bubbles:!0}))}catch(e){this._error=`Failed to force review: ${e.message}`,this.render()}}async _killAgent(e){if(confirm(`Kill agent ${e}?`))try{await this._api._post(`/api/agents/${e}/kill`),this.dispatchEvent(new CustomEvent("council-action",{detail:{action:"kill-agent",agentId:e},bubbles:!0})),await this._loadData()}catch(t){this._error=`Failed to kill agent: ${t.message}`,this.render()}}async _pauseAgent(e){try{await this._api._post(`/api/agents/${e}/pause`),await this._loadData()}catch(t){this._error=`Failed to pause agent: ${t.message}`,this.render()}}async _resumeAgent(e){try{await this._api._post(`/api/agents/${e}/resume`),await this._loadData()}catch(t){this._error=`Failed to resume agent: ${t.message}`,this.render()}}_setTab(e){this._activeTab=e,this.render()}_selectAgent(e){this._selectedAgent=this._selectedAgent?.id===e.id?null:e,this.render()}render(){let e=this.shadowRoot;e&&(this._pendingRaf&&(cancelAnimationFrame(this._pendingRaf),this._pendingRaf=null),e.innerHTML=`
3754
3754
  <style>${this.getBaseStyles()}${this._getStyles()}</style>
3755
3755
  <div class="council-dashboard">
3756
3756
  <div class="council-header">
@@ -3758,7 +3758,7 @@ var LokiDashboard=(()=>{var N=Object.defineProperty;var se=Object.getOwnProperty
3758
3758
  <h2 class="title">Completion Council</h2>
3759
3759
  ${this._councilState?.enabled!==!1?'<span class="badge badge-active">Active</span>':'<span class="badge badge-inactive">Disabled</span>'}
3760
3760
  </div>
3761
- <button class="btn btn-primary" onclick="this.getRootNode().host._forceReview()">
3761
+ <button class="btn btn-primary" id="force-review-btn">
3762
3762
  Force Review
3763
3763
  </button>
3764
3764
  </div>
@@ -3767,7 +3767,7 @@ var LokiDashboard=(()=>{var N=Object.defineProperty;var se=Object.getOwnProperty
3767
3767
  ${me.map(t=>`
3768
3768
  <button
3769
3769
  class="tab ${this._activeTab===t.id?"active":""}"
3770
- onclick="this.getRootNode().host._setTab('${t.id}')"
3770
+ data-tab="${t.id}"
3771
3771
  >${t.label}</button>
3772
3772
  `).join("")}
3773
3773
  </div>
@@ -3778,7 +3778,7 @@ var LokiDashboard=(()=>{var N=Object.defineProperty;var se=Object.getOwnProperty
3778
3778
 
3779
3779
  ${this._error?`<div class="error-banner">${this._error}</div>`:""}
3780
3780
  </div>
3781
- `)}_renderTabContent(){switch(this._activeTab){case"overview":return this._renderOverview();case"decisions":return this._renderDecisions();case"convergence":return this._renderConvergence();case"agents":return this._renderAgents();default:return""}}_renderOverview(){let e=this._councilState||{},t=e.consecutive_no_change||0,a=e.done_signals||0,i=e.total_votes||0,s=e.approve_votes||0,r=this._verdicts.length>0?this._verdicts[this._verdicts.length-1]:null,n=this._agents.filter(d=>d.alive).length;return`
3781
+ `,this._attachEventListeners())}_attachEventListeners(){let e=this.shadowRoot;if(!e)return;let t=e.getElementById("force-review-btn");t&&t.addEventListener("click",()=>this._forceReview()),e.querySelectorAll(".tab[data-tab]").forEach(a=>{a.addEventListener("click",()=>this._setTab(a.dataset.tab))})}_renderTabContent(){switch(this._activeTab){case"overview":return this._renderOverview();case"decisions":return this._renderDecisions();case"convergence":return this._renderConvergence();case"agents":return this._renderAgents();default:return""}}_renderOverview(){let e=this._councilState||{},t=e.consecutive_no_change||0,a=e.done_signals||0,i=e.total_votes||0,s=e.approve_votes||0,r=this._verdicts.length>0?this._verdicts[this._verdicts.length-1]:null,n=this._agents.filter(d=>d.alive).length;return`
3782
3782
  <div class="overview-grid">
3783
3783
  <div class="stat-card">
3784
3784
  <div class="stat-label">Council Status</div>
@@ -3918,7 +3918,7 @@ var LokiDashboard=(()=>{var N=Object.defineProperty;var se=Object.getOwnProperty
3918
3918
  </div>
3919
3919
  `).join("")}
3920
3920
  </div>
3921
- `;return requestAnimationFrame(()=>{let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(a=>{let i=parseInt(a.dataset.agentIndex,10),s=this._agents[i];s&&(a.addEventListener("click",()=>this._selectAgent(s)),a.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",n=>{n.stopPropagation();let d=r.dataset.action,p=r.dataset.agentId;d==="pause"?this._pauseAgent(p):d==="kill"?this._killAgent(p):d==="resume"&&this._resumeAgent(p)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_getStyles(){return`
3921
+ `;return this._pendingRaf=requestAnimationFrame(()=>{this._pendingRaf=null;let t=this.shadowRoot;t&&t.querySelectorAll(".agent-card[data-agent-index]").forEach(a=>{let i=parseInt(a.dataset.agentIndex,10),s=this._agents[i];s&&(a.addEventListener("click",()=>this._selectAgent(s)),a.querySelectorAll("[data-action]").forEach(r=>{r.addEventListener("click",n=>{n.stopPropagation();let d=r.dataset.action,p=r.dataset.agentId;d==="pause"?this._pauseAgent(p):d==="kill"?this._killAgent(p):d==="resume"&&this._resumeAgent(p)})}))})}),e}_formatTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}catch{return e}}_getStyles(){return`
3922
3922
  :host {
3923
3923
  display: block;
3924
3924
  font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;