loki-mode 6.3.1 → 6.5.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
@@ -2642,33 +2642,52 @@ init_loki_dir() {
2642
2642
  # Clean up stale control files ONLY if no other session is running
2643
2643
  # Deleting these while another session is active would destroy its signals
2644
2644
  # Use flock if available to avoid TOCTOU race
2645
- local lock_file=".loki/session.lock"
2646
- local can_cleanup=false
2647
-
2648
- if command -v flock >/dev/null 2>&1 && [ -f "$lock_file" ]; then
2649
- # Try non-blocking lock - if we get it, no other session is running
2650
- {
2651
- if flock -n 201 2>/dev/null; then
2645
+ #
2646
+ # Per-session locking (v6.4.0): When LOKI_SESSION_ID is set, only clean up
2647
+ # that session's files. Global control files (PAUSE/STOP) are only cleaned
2648
+ # when NO sessions are active.
2649
+ local lock_file can_cleanup=false
2650
+
2651
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
2652
+ # Per-session: check only this session's lock
2653
+ lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock"
2654
+ local session_pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
2655
+ if command -v flock >/dev/null 2>&1 && [ -f "$lock_file" ]; then
2656
+ { if flock -n 201 2>/dev/null; then can_cleanup=true; fi } 201>"$lock_file"
2657
+ else
2658
+ local existing_pid=""
2659
+ if [ -f "$session_pid_file" ]; then
2660
+ existing_pid=$(cat "$session_pid_file" 2>/dev/null)
2661
+ fi
2662
+ if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
2652
2663
  can_cleanup=true
2653
2664
  fi
2654
- } 201>"$lock_file"
2665
+ fi
2666
+ if [ "$can_cleanup" = "true" ]; then
2667
+ rm -f "$session_pid_file" 2>/dev/null
2668
+ rm -f "$lock_file" 2>/dev/null
2669
+ fi
2655
2670
  else
2656
- # Fallback: check PID file
2657
- local existing_pid=""
2658
- if [ -f ".loki/loki.pid" ]; then
2659
- existing_pid=$(cat ".loki/loki.pid" 2>/dev/null)
2671
+ # Global: original behavior
2672
+ lock_file=".loki/session.lock"
2673
+ if command -v flock >/dev/null 2>&1 && [ -f "$lock_file" ]; then
2674
+ { if flock -n 201 2>/dev/null; then can_cleanup=true; fi } 201>"$lock_file"
2675
+ else
2676
+ local existing_pid=""
2677
+ if [ -f ".loki/loki.pid" ]; then
2678
+ existing_pid=$(cat ".loki/loki.pid" 2>/dev/null)
2679
+ fi
2680
+ if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
2681
+ can_cleanup=true
2682
+ fi
2660
2683
  fi
2661
- if [ -z "$existing_pid" ] || ! kill -0 "$existing_pid" 2>/dev/null; then
2662
- can_cleanup=true
2684
+ if [ "$can_cleanup" = "true" ]; then
2685
+ rm -f .loki/PAUSE .loki/STOP .loki/HUMAN_INPUT.md 2>/dev/null
2686
+ rm -f .loki/loki.pid 2>/dev/null
2687
+ rm -f .loki/session.lock 2>/dev/null
2663
2688
  fi
2664
2689
  fi
2665
2690
 
2666
- if [ "$can_cleanup" = "true" ]; then
2667
- rm -f .loki/PAUSE .loki/STOP .loki/HUMAN_INPUT.md 2>/dev/null
2668
- rm -f .loki/loki.pid 2>/dev/null
2669
- rm -f .loki/session.lock 2>/dev/null
2670
- fi
2671
-
2672
2691
  mkdir -p .loki/{state,queue,messages,logs,config,prompts,artifacts,scripts}
2673
2692
  mkdir -p .loki/queue
2674
2693
  mkdir -p .loki/state/checkpoints
@@ -7415,12 +7434,13 @@ run_autonomous() {
7415
7434
  audit_agent_action "cli_invoke" "Starting iteration $ITERATION_COUNT" "provider=${PROVIDER_NAME:-claude},tier=$CURRENT_TIER"
7416
7435
 
7417
7436
  # Provider-specific invocation with dynamic tier selection
7437
+ local exit_code=0
7418
7438
  case "${PROVIDER_NAME:-claude}" in
7419
7439
  claude)
7420
7440
  # Claude: Full features with stream-json output and agent tracking
7421
7441
  # Uses dynamic tier for model selection based on RARV phase
7422
7442
  # Pass tier to Python via environment for dashboard display
7423
- LOKI_CURRENT_MODEL="$tier_param" \
7443
+ { LOKI_CURRENT_MODEL="$tier_param" \
7424
7444
  claude --dangerously-skip-permissions --model "$tier_param" -p "$prompt" \
7425
7445
  --output-format stream-json --verbose 2>&1 | \
7426
7446
  tee -a "$log_file" "$agent_log" | \
@@ -7628,7 +7648,7 @@ if __name__ == "__main__":
7628
7648
  except BrokenPipeError:
7629
7649
  sys.exit(0)
7630
7650
  '
7631
- local exit_code=${PIPESTATUS[0]}
7651
+ } && exit_code=0 || exit_code=$?
7632
7652
  ;;
7633
7653
 
7634
7654
  codex)
@@ -7636,10 +7656,10 @@ if __name__ == "__main__":
7636
7656
  # Uses positional prompt after exec subcommand
7637
7657
  # Note: Effort is set via env var, not CLI flag
7638
7658
  # Uses dynamic tier from RARV phase (tier_param already set above)
7639
- CODEX_MODEL_REASONING_EFFORT="$tier_param" \
7659
+ { CODEX_MODEL_REASONING_EFFORT="$tier_param" \
7640
7660
  codex exec --full-auto \
7641
- "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"
7642
- local exit_code=${PIPESTATUS[0]}
7661
+ "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \
7662
+ } && exit_code=0 || exit_code=$?
7643
7663
  ;;
7644
7664
 
7645
7665
  gemini)
@@ -7653,8 +7673,8 @@ if __name__ == "__main__":
7653
7673
  # Try primary model, fallback on rate limit
7654
7674
  local tmp_output
7655
7675
  tmp_output=$(mktemp)
7656
- gemini --approval-mode=yolo --model "$model" "$prompt" < /dev/null 2>&1 | tee "$tmp_output" | tee -a "$log_file" "$agent_log"
7657
- local exit_code=${PIPESTATUS[0]}
7676
+ { gemini --approval-mode=yolo --model "$model" "$prompt" < /dev/null 2>&1 | tee "$tmp_output" | tee -a "$log_file" "$agent_log"; \
7677
+ } && exit_code=0 || exit_code=$?
7658
7678
 
7659
7679
  if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$tmp_output"; then
7660
7680
  log_warn "Rate limit hit on $model, falling back to $fallback"
@@ -8093,6 +8113,10 @@ cleanup() {
8093
8113
  stop_status_monitor
8094
8114
  kill_all_registered
8095
8115
  rm -f "$loki_dir/loki.pid" 2>/dev/null
8116
+ # Clean up per-session PID file if running with session ID
8117
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8118
+ rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8119
+ fi
8096
8120
  if [ -f "$loki_dir/session.json" ]; then
8097
8121
  _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
8098
8122
  import json, os
@@ -8120,13 +8144,18 @@ except (json.JSONDecodeError, OSError): pass
8120
8144
  stop_dashboard
8121
8145
  stop_status_monitor
8122
8146
  kill_all_registered
8123
- rm -f .loki/loki.pid .loki/PAUSE 2>/dev/null
8147
+ rm -f "$loki_dir/loki.pid" "$loki_dir/PAUSE" 2>/dev/null
8148
+ # Clean up per-session PID file if running with session ID
8149
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8150
+ rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8151
+ fi
8124
8152
  # Mark session.json as stopped
8125
- if [ -f ".loki/session.json" ]; then
8126
- python3 -c "
8127
- import json
8153
+ if [ -f "$loki_dir/session.json" ]; then
8154
+ _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
8155
+ import json, os
8156
+ sf = os.environ['_LOKI_SESSION_FILE']
8128
8157
  try:
8129
- with open('.loki/session.json', 'r+') as f:
8158
+ with open(sf, 'r+') as f:
8130
8159
  d = json.load(f); d['status'] = 'stopped'
8131
8160
  f.seek(0); f.truncate(); json.dump(d, f)
8132
8161
  except (json.JSONDecodeError, OSError): pass
@@ -8325,7 +8354,13 @@ main() {
8325
8354
  mkdir -p .loki/logs
8326
8355
 
8327
8356
  local log_file=".loki/logs/background-$(date +%Y%m%d-%H%M%S).log"
8328
- local pid_file=".loki/loki.pid"
8357
+ local pid_file
8358
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8359
+ mkdir -p ".loki/sessions/${LOKI_SESSION_ID}"
8360
+ pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
8361
+ else
8362
+ pid_file=".loki/loki.pid"
8363
+ fi
8329
8364
  local project_path=$(pwd)
8330
8365
  local project_name=$(basename "$project_path")
8331
8366
 
@@ -8419,12 +8454,22 @@ main() {
8419
8454
  # Initialize session continuity file with empty template
8420
8455
  update_continuity
8421
8456
 
8422
- # Session lock: prevent concurrent sessions on same repo
8423
- # Use flock for atomic locking to prevent TOCTOU race conditions
8424
- local pid_file=".loki/loki.pid"
8425
- local lock_file=".loki/session.lock"
8457
+ # Session lock: prevent concurrent sessions
8458
+ # Per-session locking (v6.4.0): LOKI_SESSION_ID enables multiple concurrent
8459
+ # sessions (e.g., loki run 52 -d && loki run 54 -d). Each session gets its
8460
+ # own PID/lock files under .loki/sessions/<id>/.
8461
+ # Without LOKI_SESSION_ID, the global .loki/loki.pid lock is used (single session).
8462
+ local pid_file lock_file
8463
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8464
+ mkdir -p ".loki/sessions/${LOKI_SESSION_ID}"
8465
+ pid_file=".loki/sessions/${LOKI_SESSION_ID}/loki.pid"
8466
+ lock_file=".loki/sessions/${LOKI_SESSION_ID}/session.lock"
8467
+ else
8468
+ pid_file=".loki/loki.pid"
8469
+ lock_file=".loki/session.lock"
8470
+ fi
8426
8471
 
8427
- # Try to acquire exclusive lock with flock (if available)
8472
+ # Use flock for atomic locking to prevent TOCTOU race conditions
8428
8473
  if command -v flock >/dev/null 2>&1; then
8429
8474
  # Create lock file
8430
8475
  touch "$lock_file"
@@ -8435,8 +8480,13 @@ main() {
8435
8480
 
8436
8481
  # Try to acquire exclusive lock (non-blocking)
8437
8482
  if ! flock -n 200 2>/dev/null; then
8438
- log_error "Another Loki session is already running (locked)"
8439
- log_error "Stop it first with: loki stop"
8483
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8484
+ log_error "Session '${LOKI_SESSION_ID}' is already running (locked)"
8485
+ log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
8486
+ else
8487
+ log_error "Another Loki session is already running (locked)"
8488
+ log_error "Stop it first with: loki stop"
8489
+ fi
8440
8490
  exit 1
8441
8491
  fi
8442
8492
 
@@ -8446,8 +8496,13 @@ main() {
8446
8496
  existing_pid=$(cat "$pid_file" 2>/dev/null)
8447
8497
  # Skip if it's our own PID or parent PID (background mode writes PID before child starts)
8448
8498
  if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then
8449
- log_error "Another Loki session is already running (PID: $existing_pid)"
8450
- log_error "Stop it first with: loki stop"
8499
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8500
+ log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)"
8501
+ log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
8502
+ else
8503
+ log_error "Another Loki session is already running (PID: $existing_pid)"
8504
+ log_error "Stop it first with: loki stop"
8505
+ fi
8451
8506
  exit 1
8452
8507
  fi
8453
8508
  fi
@@ -8459,8 +8514,13 @@ main() {
8459
8514
  existing_pid=$(cat "$pid_file" 2>/dev/null)
8460
8515
  # Skip if it's our own PID or parent PID (background mode writes PID before child starts)
8461
8516
  if [ -n "$existing_pid" ] && [ "$existing_pid" != "$$" ] && [ "$existing_pid" != "$PPID" ] && kill -0 "$existing_pid" 2>/dev/null; then
8462
- log_error "Another Loki session is already running (PID: $existing_pid)"
8463
- log_error "Stop it first with: loki stop"
8517
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8518
+ log_error "Session '${LOKI_SESSION_ID}' is already running (PID: $existing_pid)"
8519
+ log_error "Stop it first with: loki stop ${LOKI_SESSION_ID}"
8520
+ else
8521
+ log_error "Another Loki session is already running (PID: $existing_pid)"
8522
+ log_error "Stop it first with: loki stop"
8523
+ fi
8464
8524
  exit 1
8465
8525
  fi
8466
8526
  fi
@@ -8468,6 +8528,10 @@ main() {
8468
8528
 
8469
8529
  # Write PID file for ALL modes (foreground + background)
8470
8530
  echo "$$" > "$pid_file"
8531
+ # Store session ID in state for dashboard/status visibility
8532
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8533
+ echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id"
8534
+ fi
8471
8535
 
8472
8536
  # Initialize PID registry and clean up orphans from previous sessions
8473
8537
  init_pid_registry
@@ -8674,13 +8738,19 @@ main() {
8674
8738
  fi
8675
8739
  stop_dashboard
8676
8740
  stop_status_monitor
8677
- rm -f .loki/loki.pid 2>/dev/null
8741
+ local loki_dir="${TARGET_DIR:-.}/.loki"
8742
+ rm -f "$loki_dir/loki.pid" 2>/dev/null
8743
+ # Clean up per-session PID file if running with session ID
8744
+ if [ -n "${LOKI_SESSION_ID:-}" ]; then
8745
+ rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8746
+ fi
8678
8747
  # Mark session.json as stopped
8679
- if [ -f ".loki/session.json" ]; then
8680
- python3 -c "
8681
- import json
8748
+ if [ -f "$loki_dir/session.json" ]; then
8749
+ _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
8750
+ import json, os
8751
+ sf = os.environ['_LOKI_SESSION_FILE']
8682
8752
  try:
8683
- with open('.loki/session.json', 'r+') as f:
8753
+ with open(sf, 'r+') as f:
8684
8754
  d = json.load(f); d['status'] = 'stopped'
8685
8755
  f.seek(0); f.truncate(); json.dump(d, f)
8686
8756
  except (json.JSONDecodeError, OSError): pass
@@ -68,28 +68,32 @@ PROMPT_INJECTION_ENABLED="${LOKI_PROMPT_INJECTION:-false}"
68
68
 
69
69
  # Preset definitions: "host_path:container_path:mode"
70
70
  # Container runs as user 'loki' (UID 1000), so mount to /home/loki/
71
- declare -A DOCKER_MOUNT_PRESETS
72
- DOCKER_MOUNT_PRESETS=(
73
- # Note: gh and git are also hardcoded in start_sandbox() defaults
74
- [gh]="$HOME/.config/gh:/home/loki/.config/gh:ro"
75
- [git]="$HOME/.gitconfig:/home/loki/.gitconfig:ro"
76
- [ssh]="$HOME/.ssh/known_hosts:/home/loki/.ssh/known_hosts:ro"
77
- [aws]="$HOME/.aws:/home/loki/.aws:ro"
78
- [azure]="$HOME/.azure:/home/loki/.azure:ro"
79
- [kube]="$HOME/.kube:/home/loki/.kube:ro"
80
- [terraform]="$HOME/.terraform.d:/home/loki/.terraform.d:ro"
81
- [gcloud]="$HOME/.config/gcloud:/home/loki/.config/gcloud:ro"
82
- [npm]="$HOME/.npmrc:/home/loki/.npmrc:ro"
83
- )
71
+ # Uses functions instead of declare -A for bash 3.2 compatibility (macOS default)
72
+ _get_mount_preset() {
73
+ case "$1" in
74
+ gh) echo "$HOME/.config/gh:/home/loki/.config/gh:ro" ;;
75
+ git) echo "$HOME/.gitconfig:/home/loki/.gitconfig:ro" ;;
76
+ ssh) echo "$HOME/.ssh/known_hosts:/home/loki/.ssh/known_hosts:ro" ;;
77
+ aws) echo "$HOME/.aws:/home/loki/.aws:ro" ;;
78
+ azure) echo "$HOME/.azure:/home/loki/.azure:ro" ;;
79
+ kube) echo "$HOME/.kube:/home/loki/.kube:ro" ;;
80
+ terraform) echo "$HOME/.terraform.d:/home/loki/.terraform.d:ro" ;;
81
+ gcloud) echo "$HOME/.config/gcloud:/home/loki/.config/gcloud:ro" ;;
82
+ npm) echo "$HOME/.npmrc:/home/loki/.npmrc:ro" ;;
83
+ *) echo "" ;;
84
+ esac
85
+ }
84
86
 
85
87
  # Environment variables auto-passed per preset (comma-separated)
86
- declare -A DOCKER_ENV_PRESETS
87
- DOCKER_ENV_PRESETS=(
88
- [aws]="AWS_REGION,AWS_PROFILE,AWS_DEFAULT_REGION"
89
- [azure]="AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID"
90
- [gcloud]="GOOGLE_PROJECT,GOOGLE_REGION,GCLOUD_PROJECT"
91
- [terraform]="TF_VAR_*"
92
- )
88
+ _get_env_preset() {
89
+ case "$1" in
90
+ aws) echo "AWS_REGION,AWS_PROFILE,AWS_DEFAULT_REGION" ;;
91
+ azure) echo "AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID" ;;
92
+ gcloud) echo "GOOGLE_PROJECT,GOOGLE_REGION,GCLOUD_PROJECT" ;;
93
+ terraform) echo "TF_VAR_*" ;;
94
+ *) echo "" ;;
95
+ esac
96
+ }
93
97
 
94
98
  #===============================================================================
95
99
  # Utility Functions
@@ -836,7 +840,8 @@ except: pass
836
840
  while IFS= read -r name; do
837
841
  [[ -z "$name" ]] && continue
838
842
 
839
- local preset_value="${DOCKER_MOUNT_PRESETS[$name]:-}"
843
+ local preset_value
844
+ preset_value="$(_get_mount_preset "$name")"
840
845
  if [[ -z "$preset_value" ]]; then
841
846
  log_warn "Unknown Docker mount preset: $name"
842
847
  continue
@@ -861,7 +866,8 @@ except: pass
861
866
  fi
862
867
 
863
868
  # Add associated env vars
864
- local env_list="${DOCKER_ENV_PRESETS[$name]:-}"
869
+ local env_list
870
+ env_list="$(_get_env_preset "$name")"
865
871
  if [[ -n "$env_list" ]]; then
866
872
  IFS=',' read -ra env_names <<< "$env_list"
867
873
  local env_name
@@ -52,17 +52,30 @@ loki_telemetry() {
52
52
  os_name=$(uname -s 2>/dev/null || echo "unknown")
53
53
  arch=$(uname -m 2>/dev/null || echo "unknown")
54
54
 
55
- # Build properties from remaining args (key=value pairs)
56
- local extra_props=""
55
+ # Build JSON payload safely using Python to prevent injection
56
+ local extra_args=""
57
57
  for arg in "$@"; do
58
- local key="${arg%%=*}"
59
- local val="${arg#*=}"
60
- extra_props="${extra_props},\"${key}\":\"${val}\""
58
+ extra_args="${extra_args}${extra_args:+ }${arg}"
61
59
  done
62
60
 
63
61
  local payload
64
- payload=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{"os":"%s","arch":"%s","version":"%s","channel":"%s"%s}}' \
65
- "$LOKI_POSTHOG_KEY" "$event" "$distinct_id" "$os_name" "$arch" "$version" "$channel" "$extra_props")
62
+ payload=$(python3 -c "
63
+ import json, sys
64
+ props = {'os': sys.argv[1], 'arch': sys.argv[2], 'version': sys.argv[3], 'channel': sys.argv[4]}
65
+ for arg in sys.argv[5:]:
66
+ if '=' in arg:
67
+ k, v = arg.split('=', 1)
68
+ props[k] = v
69
+ print(json.dumps({'api_key': '$LOKI_POSTHOG_KEY', 'event': sys.argv[5] if len(sys.argv) > 5 else '', 'distinct_id': '$distinct_id', 'properties': props}))
70
+ " "$os_name" "$arch" "$version" "$channel" $extra_args 2>/dev/null) || return 0
71
+ # Re-inject event and distinct_id properly
72
+ payload=$(python3 -c "
73
+ import json, sys
74
+ d = json.loads(sys.argv[1])
75
+ d['event'] = sys.argv[2]
76
+ d['distinct_id'] = sys.argv[3]
77
+ print(json.dumps(d))
78
+ " "$payload" "$event" "$distinct_id" 2>/dev/null) || return 0
66
79
 
67
80
  (curl -sS --max-time 3 -X POST "${LOKI_POSTHOG_HOST}/capture/" \
68
81
  -H "Content-Type: application/json" \
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.3.1"
10
+ __version__ = "6.5.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -886,7 +886,7 @@ Summary: {summary}
886
886
  if len(entries) > 50:
887
887
  header = entries[0]
888
888
  recent = entries[-50:]
889
- content = header + "\n## Session:".join(recent)
889
+ content = header + "\n## Session:" + "\n## Session:".join(recent)
890
890
  else:
891
891
  content = existing
892
892
  content += entry
@@ -203,6 +203,14 @@ class TaskResponse(BaseModel):
203
203
  from_attributes = True
204
204
 
205
205
 
206
+ class SessionInfo(BaseModel):
207
+ """Info about a single running session."""
208
+ session_id: str
209
+ pid: int
210
+ status: str = "running"
211
+ log_file: str = ""
212
+
213
+
206
214
  class StatusResponse(BaseModel):
207
215
  """Schema for system status response."""
208
216
  status: str
@@ -219,6 +227,8 @@ class StatusResponse(BaseModel):
219
227
  mode: str = ""
220
228
  provider: str = "claude"
221
229
  current_task: str = ""
230
+ # Concurrent sessions (v6.4.0)
231
+ sessions: list[SessionInfo] = []
222
232
 
223
233
 
224
234
  # WebSocket connection manager
@@ -521,11 +531,77 @@ async def get_status() -> StatusResponse:
521
531
  except Exception:
522
532
  pass
523
533
 
534
+ # Discover all running sessions (v6.4.0 - concurrent session support)
535
+ active_session_list: list[SessionInfo] = []
536
+
537
+ # Global session
538
+ if running:
539
+ try:
540
+ _global_pid = int(pid_str) if pid_str else 0
541
+ except (ValueError, TypeError):
542
+ _global_pid = 0
543
+ active_session_list.append(SessionInfo(
544
+ session_id="global",
545
+ pid=_global_pid,
546
+ status=status,
547
+ ))
548
+
549
+ # Per-session PIDs under .loki/sessions/<id>/
550
+ sessions_dir = loki_dir / "sessions"
551
+ if sessions_dir.is_dir():
552
+ for session_path in sessions_dir.iterdir():
553
+ if not session_path.is_dir():
554
+ continue
555
+ sid = session_path.name
556
+ spid_file = session_path / "loki.pid"
557
+ if spid_file.exists():
558
+ try:
559
+ spid_str = spid_file.read_text().strip()
560
+ spid = int(spid_str)
561
+ os.kill(spid, 0)
562
+ # Find log file if available
563
+ log_path = ""
564
+ log_candidate = loki_dir / "logs" / f"run-{sid}.log"
565
+ if log_candidate.exists():
566
+ log_path = str(log_candidate)
567
+ active_session_list.append(SessionInfo(
568
+ session_id=sid,
569
+ pid=spid,
570
+ status="running",
571
+ log_file=log_path,
572
+ ))
573
+ except (ValueError, OSError, ProcessLookupError):
574
+ pass
575
+
576
+ # Legacy run-*.pid files
577
+ for rpf in loki_dir.glob("run-*.pid"):
578
+ sid = rpf.stem.removeprefix("run-")
579
+ # Skip if already found in sessions/
580
+ if any(s.session_id == sid for s in active_session_list):
581
+ continue
582
+ try:
583
+ rpid = int(rpf.read_text().strip())
584
+ os.kill(rpid, 0)
585
+ log_path = ""
586
+ log_candidate = loki_dir / "logs" / f"run-{sid}.log"
587
+ if log_candidate.exists():
588
+ log_path = str(log_candidate)
589
+ active_session_list.append(SessionInfo(
590
+ session_id=sid,
591
+ pid=rpid,
592
+ status="running",
593
+ log_file=log_path,
594
+ ))
595
+ except (ValueError, OSError, ProcessLookupError):
596
+ pass
597
+
598
+ total_active = len(active_session_list)
599
+
524
600
  return StatusResponse(
525
601
  status=status,
526
602
  version=version,
527
603
  uptime_seconds=uptime,
528
- active_sessions=1 if running else 0,
604
+ active_sessions=total_active,
529
605
  running_agents=running_agents,
530
606
  pending_tasks=pending_tasks,
531
607
  database_connected=True,
@@ -535,6 +611,7 @@ async def get_status() -> StatusResponse:
535
611
  mode=mode,
536
612
  provider=provider,
537
613
  current_task=current_task,
614
+ sessions=active_session_list,
538
615
  )
539
616
 
540
617
 
@@ -1368,7 +1445,7 @@ async def query_audit_logs(
1368
1445
  action: Optional[str] = None,
1369
1446
  resource_type: Optional[str] = None,
1370
1447
  resource_id: Optional[str] = None,
1371
- limit: int = 100,
1448
+ limit: int = Query(default=100, ge=1, le=1000),
1372
1449
  offset: int = 0,
1373
1450
  ):
1374
1451
  """
@@ -1488,7 +1565,7 @@ async def get_memory_summary():
1488
1565
 
1489
1566
 
1490
1567
  @app.get("/api/memory/episodes")
1491
- async def list_episodes(limit: int = 50):
1568
+ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
1492
1569
  """List episodic memory entries."""
1493
1570
  ep_dir = _get_loki_dir() / "memory" / "episodic"
1494
1571
  episodes = []
@@ -1505,11 +1582,15 @@ async def list_episodes(limit: int = 50):
1505
1582
  @app.get("/api/memory/episodes/{episode_id}")
1506
1583
  async def get_episode(episode_id: str):
1507
1584
  """Get a specific episodic memory entry."""
1508
- ep_dir = _get_loki_dir() / "memory" / "episodic"
1585
+ loki_dir = _get_loki_dir()
1586
+ ep_dir = loki_dir / "memory" / "episodic"
1509
1587
  if not ep_dir.exists():
1510
1588
  raise HTTPException(status_code=404, detail="Episode not found")
1511
1589
  # Try direct filename match
1512
1590
  for f in ep_dir.glob("*.json"):
1591
+ resolved = os.path.realpath(f)
1592
+ if not resolved.startswith(os.path.realpath(str(loki_dir))):
1593
+ raise HTTPException(status_code=403, detail="Access denied")
1513
1594
  try:
1514
1595
  data = json.loads(f.read_text())
1515
1596
  if data.get("id") == episode_id or f.stem == episode_id:
@@ -1560,10 +1641,14 @@ async def list_skills():
1560
1641
  @app.get("/api/memory/skills/{skill_id}")
1561
1642
  async def get_skill(skill_id: str):
1562
1643
  """Get a specific procedural skill."""
1563
- skills_dir = _get_loki_dir() / "memory" / "skills"
1644
+ loki_dir = _get_loki_dir()
1645
+ skills_dir = loki_dir / "memory" / "skills"
1564
1646
  if not skills_dir.exists():
1565
1647
  raise HTTPException(status_code=404, detail="Skill not found")
1566
1648
  for f in skills_dir.glob("*.json"):
1649
+ resolved = os.path.realpath(f)
1650
+ if not resolved.startswith(os.path.realpath(str(loki_dir))):
1651
+ raise HTTPException(status_code=403, detail="Access denied")
1567
1652
  try:
1568
1653
  data = json.loads(f.read_text())
1569
1654
  if data.get("id") == skill_id or f.stem == skill_id:
@@ -1633,6 +1718,7 @@ def _read_learning_signals(signal_type: Optional[str] = None, limit: int = 50) -
1633
1718
  (learning/emitter.py). Each file contains a single signal object with fields:
1634
1719
  id, type, source, action, timestamp, confidence, outcome, data, context.
1635
1720
  """
1721
+ limit = min(limit, 1000)
1636
1722
  signals_dir = _get_loki_dir() / "learning" / "signals"
1637
1723
  if not signals_dir.exists() or not signals_dir.is_dir():
1638
1724
  return []
@@ -1752,7 +1838,7 @@ async def get_learning_signals(
1752
1838
  timeRange: str = "7d",
1753
1839
  signalType: Optional[str] = None,
1754
1840
  source: Optional[str] = None,
1755
- limit: int = 50,
1841
+ limit: int = Query(default=50, ge=1, le=1000),
1756
1842
  offset: int = 0,
1757
1843
  ):
1758
1844
  """Get raw learning signals from both events.jsonl and learning signals directory."""
@@ -1957,7 +2043,7 @@ async def trigger_aggregation():
1957
2043
 
1958
2044
 
1959
2045
  @app.get("/api/learning/preferences")
1960
- async def get_learning_preferences(limit: int = 50):
2046
+ async def get_learning_preferences(limit: int = Query(default=50, ge=1, le=1000)):
1961
2047
  """Get aggregated user preferences from events and learning signals directory."""
1962
2048
  events = _read_events("30d")
1963
2049
  prefs = [e for e in events if e.get("type") == "user_preference"]
@@ -1969,7 +2055,7 @@ async def get_learning_preferences(limit: int = 50):
1969
2055
 
1970
2056
 
1971
2057
  @app.get("/api/learning/errors")
1972
- async def get_learning_errors(limit: int = 50):
2058
+ async def get_learning_errors(limit: int = Query(default=50, ge=1, le=1000)):
1973
2059
  """Get aggregated error patterns from events and learning signals directory."""
1974
2060
  events = _read_events("30d")
1975
2061
  errors = [e for e in events if e.get("type") == "error_pattern"]
@@ -1981,7 +2067,7 @@ async def get_learning_errors(limit: int = 50):
1981
2067
 
1982
2068
 
1983
2069
  @app.get("/api/learning/success")
1984
- async def get_learning_success(limit: int = 50):
2070
+ async def get_learning_success(limit: int = Query(default=50, ge=1, le=1000)):
1985
2071
  """Get aggregated success patterns from events and learning signals directory."""
1986
2072
  events = _read_events("30d")
1987
2073
  successes = [e for e in events if e.get("type") == "success_pattern"]
@@ -1993,7 +2079,7 @@ async def get_learning_success(limit: int = 50):
1993
2079
 
1994
2080
 
1995
2081
  @app.get("/api/learning/tools")
1996
- async def get_tool_efficiency(limit: int = 50):
2082
+ async def get_tool_efficiency(limit: int = Query(default=50, ge=1, le=1000)):
1997
2083
  """Get tool efficiency rankings from events and learning signals directory."""
1998
2084
  events = _read_events("30d")
1999
2085
  tools = [e for e in events if e.get("type") == "tool_efficiency"]
@@ -2432,7 +2518,7 @@ async def get_council_state():
2432
2518
 
2433
2519
 
2434
2520
  @app.get("/api/council/verdicts")
2435
- async def get_council_verdicts(limit: int = 20):
2521
+ async def get_council_verdicts(limit: int = Query(default=20, ge=1, le=1000)):
2436
2522
  """Get council vote history (decision log)."""
2437
2523
  state_file = _get_loki_dir() / "council" / "state.json"
2438
2524
  verdicts = []
@@ -2,11 +2,11 @@
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:** v6.3.1
5
+ **Version:** v6.5.0
6
6
 
7
7
  ---
8
8
 
9
- ## What's New in v6.3.1
9
+ ## What's New in v6.5.0
10
10
 
11
11
  ### Dual-Mode Architecture (v6.0.0)
12
12
  - `loki run` command for direct autonomous execution
package/events/emit.sh CHANGED
@@ -43,7 +43,7 @@ fi
43
43
 
44
44
  # JSON escape helper: handles \, ", and control characters
45
45
  json_escape() {
46
- printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | tr -d '\n'
46
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | awk '{if(NR>1) printf "\\n"; printf "%s", $0}'
47
47
  }
48
48
 
49
49
  # Build payload JSON