loki-mode 7.21.0 → 7.23.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
@@ -781,6 +781,21 @@ MAX_PARALLEL_SESSIONS=${LOKI_MAX_PARALLEL_SESSIONS:-3}
781
781
  PARALLEL_TESTING=${LOKI_PARALLEL_TESTING:-true}
782
782
  PARALLEL_DOCS=${LOKI_PARALLEL_DOCS:-true}
783
783
 
784
+ # Dynamic resource-aware session concurrency (Release 3, slice 3).
785
+ # DEFAULT OFF: when LOKI_DYNAMIC_CONCURRENCY is unset, effective_session_cap()
786
+ # returns exactly MAX_PARALLEL_SESSIONS, so behavior is identical to before.
787
+ # Opt in with LOKI_DYNAMIC_CONCURRENCY=1 to scale the session cap down when
788
+ # system CPU or memory is under pressure (read from .loki/state/resources.json).
789
+ DYNAMIC_CONCURRENCY=${LOKI_DYNAMIC_CONCURRENCY:-0}
790
+ # Optional higher ceiling on capable machines. Only takes effect with dynamic
791
+ # concurrency enabled; still resource-gated. Defaults to MAX_PARALLEL_SESSIONS.
792
+ MAX_PARALLEL_SESSIONS_CEILING=${LOKI_MAX_PARALLEL_SESSIONS_CEILING:-$MAX_PARALLEL_SESSIONS}
793
+ # Usage thresholds (percent). At/above CPU or MEM threshold the cap is halved.
794
+ CONCURRENCY_CPU_THRESHOLD=${LOKI_CONCURRENCY_CPU_THRESHOLD:-85}
795
+ CONCURRENCY_MEM_THRESHOLD=${LOKI_CONCURRENCY_MEM_THRESHOLD:-85}
796
+ # Critical threshold (percent). At/above this the cap is forced to 1.
797
+ CONCURRENCY_CRITICAL_THRESHOLD=${LOKI_CONCURRENCY_CRITICAL_THRESHOLD:-95}
798
+
784
799
  # Gate Escalation Ladder (v6.10.0)
785
800
  GATE_CLEAR_LIMIT=${LOKI_GATE_CLEAR_LIMIT:-3}
786
801
  GATE_ESCALATE_LIMIT=${LOKI_GATE_ESCALATE_LIMIT:-5}
@@ -2355,6 +2370,15 @@ notify_all_complete() {
2355
2370
 
2356
2371
  notify_intervention_needed() {
2357
2372
  local reason="$1"
2373
+ # Delegate-then-notify: this helper ONLY fires the (gated) desktop ping. It
2374
+ # deliberately does NOT write the durable COMPLETION.txt / completion.json
2375
+ # record. Reason: notify_intervention_needed is also called from NON-terminal
2376
+ # sites (the perpetual-mode PAUSE auto-clear branch, uncertainty escalation)
2377
+ # where the run keeps going. Writing a "Needs input" durable file there would
2378
+ # falsely tell a detached user the run is done / blocked when it is not. The
2379
+ # durable intervention write now lives only at the genuinely blocking pause
2380
+ # sites (immediately before handle_pause), so the durable state matches the
2381
+ # actual run state.
2358
2382
  send_notification "Intervention Needed" "$reason" "critical"
2359
2383
  }
2360
2384
 
@@ -2363,6 +2387,265 @@ notify_rate_limit() {
2363
2387
  send_notification "Rate Limited" "Waiting ${wait_time}s before retry" "normal"
2364
2388
  }
2365
2389
 
2390
+ #===============================================================================
2391
+ # Delegate-then-notify: completion summary (Release 2, "delegate then notify")
2392
+ #
2393
+ # build_completion_summary <outcome> writes two durable files that survive a
2394
+ # detached (--bg) run where the terminal is gone and a bell would be useless:
2395
+ # .loki/COMPLETION.txt human plain text (no emojis, no dashes)
2396
+ # .loki/state/completion.json machine-readable record of the same facts
2397
+ # It also exports two strings for send_notification to consume:
2398
+ # _LOKI_SUMMARY_TITLE short notification subtitle
2399
+ # _LOKI_SUMMARY_BODY short notification body (outcome + branch + file count)
2400
+ #
2401
+ # All git reads are best-effort and non-fatal. The diff window is the run-start
2402
+ # SHA captured once at runner init (_LOKI_RUN_START_SHA); we REUSE it and never
2403
+ # recapture, so the reported diff matches the evidence gate's window exactly.
2404
+ #
2405
+ # This function NEVER sends a notification and NEVER gates on
2406
+ # NOTIFICATIONS_ENABLED: the files are state, not a notification, and must be
2407
+ # written even when desktop notifications are disabled. emit_completion_summary
2408
+ # below is the wrapper that writes the files AND (gated) fires the desktop ping.
2409
+ #===============================================================================
2410
+ build_completion_summary() {
2411
+ local outcome="${1:-complete}"
2412
+ local loki_dir="${TARGET_DIR:-.}/.loki"
2413
+ mkdir -p "$loki_dir/state" 2>/dev/null || true
2414
+
2415
+ # Human-readable outcome label and notification title.
2416
+ local outcome_label notify_title
2417
+ case "$outcome" in
2418
+ complete) outcome_label="Completed"; notify_title="Run complete" ;;
2419
+ max_iterations) outcome_label="Max iterations"; notify_title="Run stopped (max iterations)" ;;
2420
+ stopped) outcome_label="Stopped"; notify_title="Run stopped" ;;
2421
+ failed) outcome_label="Failed"; notify_title="Run failed" ;;
2422
+ intervention) outcome_label="Needs input"; notify_title="Input needed" ;;
2423
+ *) outcome_label="$outcome"; notify_title="Run finished" ;;
2424
+ esac
2425
+
2426
+ # Branch + diff stats vs the run-start SHA (best-effort; non-git or empty
2427
+ # baseline yields empty values, which we render as "unknown"/"0").
2428
+ local start_sha="${_LOKI_RUN_START_SHA:-}"
2429
+ local branch="" head_sha="" diff_stat="" files_changed=0 insertions=0 deletions=0 review_cmd=""
2430
+ branch="$( (cd "${TARGET_DIR:-.}" && git rev-parse --abbrev-ref HEAD) 2>/dev/null || true )"
2431
+ [ -z "$branch" ] && branch="unknown"
2432
+ head_sha="$( (cd "${TARGET_DIR:-.}" && git rev-parse HEAD) 2>/dev/null || true )"
2433
+
2434
+ if [ -n "$start_sha" ]; then
2435
+ diff_stat="$( (cd "${TARGET_DIR:-.}" && git diff --stat "${start_sha}..HEAD") 2>/dev/null || true )"
2436
+ # Parse the git diff --shortstat tail for counts (locale-stable enough
2437
+ # for our display; failures leave the zeros in place).
2438
+ local shortstat
2439
+ shortstat="$( (cd "${TARGET_DIR:-.}" && git diff --shortstat "${start_sha}..HEAD") 2>/dev/null || true )"
2440
+ if [ -n "$shortstat" ]; then
2441
+ files_changed="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' | head -1)"
2442
+ insertions="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+' | head -1)"
2443
+ deletions="$(printf '%s\n' "$shortstat" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+' | head -1)"
2444
+ fi
2445
+ review_cmd="git diff ${start_sha}..HEAD"
2446
+ else
2447
+ review_cmd="git diff HEAD"
2448
+ fi
2449
+ [ -z "$files_changed" ] && files_changed=0
2450
+ [ -z "$insertions" ] && insertions=0
2451
+ [ -z "$deletions" ] && deletions=0
2452
+
2453
+ # Task counts: reuse the SAME queue reads as update_status_file.
2454
+ local pending=0 in_progress=0 completed=0 failed=0
2455
+ [ -f "$loki_dir/queue/pending.json" ] && pending=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/pending.json'))))" 2>/dev/null || echo "0")
2456
+ [ -f "$loki_dir/queue/in-progress.json" ] && in_progress=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/in-progress.json'))))" 2>/dev/null || echo "0")
2457
+ [ -f "$loki_dir/queue/completed.json" ] && completed=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/completed.json'))))" 2>/dev/null || echo "0")
2458
+ [ -f "$loki_dir/queue/failed.json" ] && failed=$(python3 -c "import json; print(len(json.load(open('$loki_dir/queue/failed.json'))))" 2>/dev/null || echo "0")
2459
+
2460
+ # Optional delegate-mode extras populated by Slice 3 (branch isolation / PR).
2461
+ local delegate_branch="${_LOKI_DELEGATE_BRANCH_NAME:-}"
2462
+ local pr_url="${_LOKI_DELEGATE_PR_URL:-}"
2463
+
2464
+ local ts
2465
+ ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date)"
2466
+
2467
+ # ---- Durable human-readable file: .loki/COMPLETION.txt --------------------
2468
+ {
2469
+ echo "Loki Mode run summary"
2470
+ echo "====================="
2471
+ echo ""
2472
+ echo "Outcome: $outcome_label"
2473
+ echo "Branch: $branch"
2474
+ echo "Files changed: $files_changed (+$insertions / -$deletions)"
2475
+ echo "Finished: $ts"
2476
+ echo ""
2477
+ if [ -n "$delegate_branch" ]; then
2478
+ echo "Delegate branch: $delegate_branch"
2479
+ fi
2480
+ if [ -n "$pr_url" ]; then
2481
+ echo "Pull request: $pr_url"
2482
+ elif [ "$outcome" = "complete" ]; then
2483
+ echo "Pull request: not opened (set LOKI_DELEGATE_PR=1 to open one)"
2484
+ fi
2485
+ echo ""
2486
+ echo "Tasks: pending=$pending in_progress=$in_progress completed=$completed failed=$failed"
2487
+ echo ""
2488
+ echo "Review the work:"
2489
+ echo " $review_cmd"
2490
+ echo ""
2491
+ if [ -n "$diff_stat" ]; then
2492
+ echo "Diff stat:"
2493
+ echo "$diff_stat"
2494
+ else
2495
+ echo "Diff stat: (no changes detected vs run start, or git unavailable)"
2496
+ fi
2497
+ } > "$loki_dir/COMPLETION.txt" 2>/dev/null || true
2498
+
2499
+ # ---- Durable machine-readable file: .loki/state/completion.json -----------
2500
+ _LOKI_CS_OUTCOME="$outcome" \
2501
+ _LOKI_CS_BRANCH="$branch" \
2502
+ _LOKI_CS_START_SHA="$start_sha" \
2503
+ _LOKI_CS_HEAD_SHA="$head_sha" \
2504
+ _LOKI_CS_FILES="$files_changed" \
2505
+ _LOKI_CS_INS="$insertions" \
2506
+ _LOKI_CS_DEL="$deletions" \
2507
+ _LOKI_CS_REVIEW="$review_cmd" \
2508
+ _LOKI_CS_DELEGATE_BRANCH="$delegate_branch" \
2509
+ _LOKI_CS_PR_URL="$pr_url" \
2510
+ _LOKI_CS_TS="$ts" \
2511
+ _LOKI_CS_OUT_FILE="$loki_dir/state/completion.json" \
2512
+ python3 -c "
2513
+ import json, os, tempfile
2514
+ out = os.environ['_LOKI_CS_OUT_FILE']
2515
+ def i(v):
2516
+ try: return int(v)
2517
+ except (TypeError, ValueError): return 0
2518
+ rec = {
2519
+ 'outcome': os.environ.get('_LOKI_CS_OUTCOME', ''),
2520
+ 'branch': os.environ.get('_LOKI_CS_BRANCH', ''),
2521
+ 'start_sha': os.environ.get('_LOKI_CS_START_SHA', ''),
2522
+ 'head_sha': os.environ.get('_LOKI_CS_HEAD_SHA', ''),
2523
+ 'files_changed': i(os.environ.get('_LOKI_CS_FILES')),
2524
+ 'insertions': i(os.environ.get('_LOKI_CS_INS')),
2525
+ 'deletions': i(os.environ.get('_LOKI_CS_DEL')),
2526
+ 'review_cmd': os.environ.get('_LOKI_CS_REVIEW', ''),
2527
+ 'delegate_branch': os.environ.get('_LOKI_CS_DELEGATE_BRANCH', ''),
2528
+ 'pr_url': os.environ.get('_LOKI_CS_PR_URL', ''),
2529
+ 'timestamp': os.environ.get('_LOKI_CS_TS', ''),
2530
+ }
2531
+ d = os.path.dirname(out)
2532
+ fd, tmp = tempfile.mkstemp(dir=d, suffix='.json')
2533
+ with os.fdopen(fd, 'w') as f:
2534
+ json.dump(rec, f, indent=2)
2535
+ os.replace(tmp, out)
2536
+ " 2>/dev/null || true
2537
+
2538
+ # ---- Short strings for the desktop notification --------------------------
2539
+ # Desktop body stays terse; full detail lives in COMPLETION.txt.
2540
+ _LOKI_SUMMARY_TITLE="$notify_title"
2541
+ _LOKI_SUMMARY_BODY="${outcome_label} on ${branch}: ${files_changed} files changed"
2542
+ if [ -n "$pr_url" ]; then
2543
+ _LOKI_SUMMARY_BODY="${_LOKI_SUMMARY_BODY}. PR: ${pr_url}"
2544
+ fi
2545
+ export _LOKI_SUMMARY_TITLE _LOKI_SUMMARY_BODY
2546
+ return 0
2547
+ }
2548
+
2549
+ #===============================================================================
2550
+ # emit_completion_summary <outcome> [urgency]
2551
+ #
2552
+ # The single entry point every terminal state calls. It ALWAYS writes the
2553
+ # durable summary files (state, not a notification) and then fires ONE desktop
2554
+ # notification gated by the existing LOKI_NOTIFICATIONS flag (send_notification
2555
+ # already short-circuits when disabled, so the gate is implicit but explicit
2556
+ # here for clarity). Centralizing this keeps the success-only PR side effect
2557
+ # (Slice 3) in one place and prevents duplicate notifications.
2558
+ #===============================================================================
2559
+ emit_completion_summary() {
2560
+ local outcome="${1:-complete}"
2561
+ local urgency="${2:-normal}"
2562
+ build_completion_summary "$outcome"
2563
+ send_notification "${_LOKI_SUMMARY_TITLE:-Run finished}" "${_LOKI_SUMMARY_BODY:-}" "$urgency"
2564
+ return 0
2565
+ }
2566
+
2567
+ #===============================================================================
2568
+ # on_run_complete (Slice 3: opt-in local git output on success)
2569
+ #
2570
+ # Called from every SUCCESS exit BEFORE emit_completion_summary so the PR url it
2571
+ # discovers is folded into the summary. Default behavior is a no-op: it only
2572
+ # acts when LOKI_DELEGATE_PR=1.
2573
+ #
2574
+ # LOKI_DELEGATE_PR=1 opens a LOCAL pull request from the user's machine, only if:
2575
+ # - this is a GitHub repo (gh + a github.com remote), AND
2576
+ # - `gh auth status` succeeds, AND
2577
+ # - the current branch is not main/master (never PR a default branch to itself)
2578
+ # It mirrors the proven pattern at autonomy/loki:5524-5527: push the branch,
2579
+ # then `gh pr create --head <branch>`. NO auto-merge. Every call is best-effort
2580
+ # (`|| true`); failures never block completion. This is a single sanctioned
2581
+ # local network call, never CI.
2582
+ #
2583
+ # Reconciliation with the existing GITHUB_PR path (run.sh create_github_pr,
2584
+ # invoked after run_autonomous returns when LOKI_GITHUB_PR=true): if GITHUB_PR
2585
+ # is already true we DEFER to that path and do nothing here, so a user who set
2586
+ # both knobs never gets a double PR.
2587
+ #===============================================================================
2588
+ on_run_complete() {
2589
+ # Default OFF.
2590
+ if [ "${LOKI_DELEGATE_PR:-0}" != "1" ]; then
2591
+ return 0
2592
+ fi
2593
+ # Defer to the existing dedicated PR path to avoid a double PR.
2594
+ if [ "${GITHUB_PR:-false}" = "true" ]; then
2595
+ return 0
2596
+ fi
2597
+ # Network-call timeout guard: a stalled network / auth prompt would
2598
+ # otherwise hang the completion path indefinitely in --bg. Run each network
2599
+ # call through `timeout 30` when available; fall back to the bare call if
2600
+ # timeout is not installed (a local wrapper keeps this set -u safe on bash
2601
+ # 3.2, where an empty array expansion would error). Keeps every existing
2602
+ # `|| true` non-fatal behavior.
2603
+ _loki_net() {
2604
+ if command -v timeout >/dev/null 2>&1; then
2605
+ timeout 30 "$@"
2606
+ else
2607
+ "$@"
2608
+ fi
2609
+ }
2610
+ # Require gh + auth.
2611
+ if ! command -v gh >/dev/null 2>&1; then
2612
+ return 0
2613
+ fi
2614
+ if ! (cd "${TARGET_DIR:-.}" && _loki_net gh auth status) >/dev/null 2>&1; then
2615
+ return 0
2616
+ fi
2617
+ # Require a GitHub remote (skip silently on non-GitHub repos).
2618
+ local remote_url
2619
+ remote_url="$( (cd "${TARGET_DIR:-.}" && git config --get remote.origin.url) 2>/dev/null || true )"
2620
+ case "$remote_url" in
2621
+ *github.com*) : ;;
2622
+ *) return 0 ;;
2623
+ esac
2624
+ # Resolve current branch; never PR a default branch to itself.
2625
+ local branch
2626
+ branch="$( (cd "${TARGET_DIR:-.}" && git rev-parse --abbrev-ref HEAD) 2>/dev/null || true )"
2627
+ case "$branch" in
2628
+ ""|main|master|HEAD) return 0 ;;
2629
+ esac
2630
+ log_info "LOKI_DELEGATE_PR=1: opening a local pull request for branch '$branch'..."
2631
+ # Push, then create. Non-interactive (no tty in --bg). Best-effort, each
2632
+ # network call bounded by the timeout guard above.
2633
+ (cd "${TARGET_DIR:-.}" && _loki_net git push -u origin "$branch") >/dev/null 2>&1 || true
2634
+ local pr_title
2635
+ pr_title="Loki Mode: ${branch}"
2636
+ local pr_url=""
2637
+ pr_url="$( (cd "${TARGET_DIR:-.}" && _loki_net gh pr create --title "$pr_title" --body "Opened by Loki Mode (delegate mode). Review locally before merge." --head "$branch") 2>/dev/null || true )"
2638
+ if [ -n "$pr_url" ]; then
2639
+ # Export so build_completion_summary folds the url into the summary.
2640
+ _LOKI_DELEGATE_PR_URL="$pr_url"
2641
+ export _LOKI_DELEGATE_PR_URL
2642
+ log_info "Pull request opened: $pr_url"
2643
+ else
2644
+ log_warn "LOKI_DELEGATE_PR=1: gh pr create did not return a URL (a PR may already exist for this branch)."
2645
+ fi
2646
+ return 0
2647
+ }
2648
+
2366
2649
  #===============================================================================
2367
2650
  # Parallel Workflow Functions (Git Worktrees)
2368
2651
  #===============================================================================
@@ -2491,6 +2774,72 @@ remove_worktree() {
2491
2774
  log_info "Removed worktree: $stream_name"
2492
2775
  }
2493
2776
 
2777
+ # Compute the effective parallel-session cap for the current scheduling pass.
2778
+ # Default-off contract: when LOKI_DYNAMIC_CONCURRENCY is not "1" this echoes
2779
+ # exactly MAX_PARALLEL_SESSIONS with zero file reads and zero subprocesses, so
2780
+ # the spawn decision is byte-identical to the pre-feature behavior.
2781
+ # When enabled, it starts from the configured ceiling and scales DOWN based on
2782
+ # .loki/state/resources.json. All reads are best-effort: a missing, empty, or
2783
+ # unparseable file (or non-numeric values) leaves the cap at the ceiling. The
2784
+ # result is always clamped to the range [1, ceiling] and never exceeds it.
2785
+ effective_session_cap() {
2786
+ # Fast default-off path: identical to today, no I/O, no subprocesses.
2787
+ if [ "${DYNAMIC_CONCURRENCY:-0}" != "1" ]; then
2788
+ echo "$MAX_PARALLEL_SESSIONS"
2789
+ return 0
2790
+ fi
2791
+
2792
+ # Ceiling is the upper bound when dynamic scaling is on.
2793
+ local ceiling="${MAX_PARALLEL_SESSIONS_CEILING:-$MAX_PARALLEL_SESSIONS}"
2794
+ # Guard against a non-numeric or sub-1 ceiling override.
2795
+ case "$ceiling" in
2796
+ ''|*[!0-9]*) ceiling="$MAX_PARALLEL_SESSIONS" ;;
2797
+ esac
2798
+ [ "$ceiling" -lt 1 ] 2>/dev/null && ceiling=1
2799
+
2800
+ local cap="$ceiling"
2801
+ local resources_file=".loki/state/resources.json"
2802
+
2803
+ # No resource data -> best-effort, leave at ceiling.
2804
+ if [ ! -f "$resources_file" ]; then
2805
+ echo "$cap"
2806
+ return 0
2807
+ fi
2808
+
2809
+ # Read usage and status best-effort. Defaults keep the cap at the ceiling
2810
+ # if the file is empty, malformed, or missing keys.
2811
+ local cpu_usage mem_usage status
2812
+ cpu_usage=$(python3 -c "import json; print(json.load(open('$resources_file')).get('cpu', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
2813
+ mem_usage=$(python3 -c "import json; print(json.load(open('$resources_file')).get('memory', {}).get('usage_percent', 0))" 2>/dev/null || echo "0")
2814
+ status=$(python3 -c "import json; print(json.load(open('$resources_file')).get('overall_status', 'ok'))" 2>/dev/null || echo "ok")
2815
+
2816
+ # usage_percent can be a float (e.g. 85.3). Reduce to an integer part for
2817
+ # comparison and fall back to 0 if anything is non-numeric.
2818
+ cpu_usage="${cpu_usage%%.*}"
2819
+ mem_usage="${mem_usage%%.*}"
2820
+ case "$cpu_usage" in ''|*[!0-9]*) cpu_usage=0 ;; esac
2821
+ case "$mem_usage" in ''|*[!0-9]*) mem_usage=0 ;; esac
2822
+
2823
+ local crit="${CONCURRENCY_CRITICAL_THRESHOLD:-95}"
2824
+ local cpu_thr="${CONCURRENCY_CPU_THRESHOLD:-85}"
2825
+ local mem_thr="${CONCURRENCY_MEM_THRESHOLD:-85}"
2826
+
2827
+ if [ "$cpu_usage" -ge "$crit" ] || [ "$mem_usage" -ge "$crit" ]; then
2828
+ # Critical pressure: drop to a single session.
2829
+ cap=1
2830
+ elif [ "$cpu_usage" -ge "$cpu_thr" ] || [ "$mem_usage" -ge "$mem_thr" ] || [ "$status" != "ok" ]; then
2831
+ # Elevated pressure or a non-ok overall status: halve (integer floor).
2832
+ cap=$(( ceiling / 2 ))
2833
+ fi
2834
+
2835
+ # Clamp to [1, ceiling]. Never runaway, never zero.
2836
+ [ "$cap" -lt 1 ] && cap=1
2837
+ [ "$cap" -gt "$ceiling" ] && cap="$ceiling"
2838
+
2839
+ echo "$cap"
2840
+ return 0
2841
+ }
2842
+
2494
2843
  # Spawn a Claude session in a worktree
2495
2844
  spawn_worktree_session() {
2496
2845
  local stream_name="$1"
@@ -2510,9 +2859,11 @@ spawn_worktree_session() {
2510
2859
  fi
2511
2860
  done
2512
2861
 
2513
- if [ "$active_count" -ge "$MAX_PARALLEL_SESSIONS" ]; then
2862
+ local session_cap
2863
+ session_cap=$(effective_session_cap)
2864
+ if [ "$active_count" -ge "$session_cap" ]; then
2514
2865
  # BUG-PAR-014: Max-sessions rejection queues spawn for retry
2515
- log_warn "Max parallel sessions reached ($MAX_PARALLEL_SESSIONS). Queuing $stream_name for retry."
2866
+ log_warn "Max parallel sessions reached ($session_cap). Queuing $stream_name for retry."
2516
2867
  mkdir -p "${TARGET_DIR:-.}/.loki/signals"
2517
2868
  echo "{\"stream\":\"$stream_name\",\"task\":\"$(echo "$task_prompt" | head -c 200)\",\"timestamp\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
2518
2869
  > "${TARGET_DIR:-.}/.loki/signals/SPAWN_QUEUED_${stream_name}"
@@ -2944,7 +3295,9 @@ run_parallel_orchestrator() {
2944
3295
  ((active_count++))
2945
3296
  fi
2946
3297
  done
2947
- if [ "$active_count" -lt "$MAX_PARALLEL_SESSIONS" ]; then
3298
+ local _session_cap
3299
+ _session_cap=$(effective_session_cap)
3300
+ if [ "$active_count" -lt "$_session_cap" ]; then
2948
3301
  for queued_signal in "${TARGET_DIR:-.}"/.loki/signals/SPAWN_QUEUED_*; do
2949
3302
  [ -f "$queued_signal" ] || continue
2950
3303
  local queued_stream
@@ -11473,6 +11826,28 @@ run_autonomous() {
11473
11826
  # file, which the gate treats as inconclusive (pass-through).
11474
11827
  local _start_sha_file=".loki/state/start-sha"
11475
11828
  mkdir -p ".loki/state"
11829
+
11830
+ # Delegate-then-notify (Slice 3): LOKI_DELEGATE_BRANCH=1 (default OFF)
11831
+ # isolates this run's work on a fresh branch loki/delegate-<timestamp> so the
11832
+ # user's working branch stays clean. Created IN-PROCESS (plain git, no
11833
+ # detached child) only on a genuine fresh run (ITERATION_COUNT==0) so a
11834
+ # resume does not spawn a new branch each time. Best-effort: a non-git repo,
11835
+ # dirty tree that blocks checkout, or any git failure leaves the run on the
11836
+ # current branch (default behavior preserved). Done BEFORE the start-sha
11837
+ # capture so the diff window baselines to the new branch HEAD.
11838
+ if [ "${LOKI_DELEGATE_BRANCH:-0}" = "1" ] && [ "${ITERATION_COUNT:-0}" -eq 0 ]; then
11839
+ if (cd "${TARGET_DIR:-.}" && git rev-parse --git-dir) >/dev/null 2>&1; then
11840
+ local _delegate_branch="loki/delegate-$(date +%Y%m%d-%H%M%S)"
11841
+ if (cd "${TARGET_DIR:-.}" && git checkout -b "$_delegate_branch") >/dev/null 2>&1; then
11842
+ _LOKI_DELEGATE_BRANCH_NAME="$_delegate_branch"
11843
+ export _LOKI_DELEGATE_BRANCH_NAME
11844
+ log_info "LOKI_DELEGATE_BRANCH=1: isolated work on new branch '$_delegate_branch'"
11845
+ else
11846
+ log_warn "LOKI_DELEGATE_BRANCH=1: could not create branch (dirty tree or git error); continuing on current branch."
11847
+ fi
11848
+ fi
11849
+ fi
11850
+
11476
11851
  if [ "${ITERATION_COUNT:-0}" -eq 0 ] || [ ! -s "$_start_sha_file" ]; then
11477
11852
  (cd "${TARGET_DIR:-.}" && git rev-parse HEAD 2>/dev/null) > "$_start_sha_file" 2>/dev/null || true
11478
11853
  fi
@@ -11556,6 +11931,13 @@ except Exception as exc:
11556
11931
  # Check max iterations before starting
11557
11932
  if check_max_iterations; then
11558
11933
  log_error "Max iterations already reached. Reset with: rm .loki/autonomy-state.json"
11934
+ # Delegate-then-notify: terminal state. Mirror the in-loop max-iterations
11935
+ # site so a detached (--bg) run still writes COMPLETION.txt + fires the
11936
+ # ping on this pre-loop exit. _LOKI_RUN_START_SHA is already exported
11937
+ # above (runner init), so the diff window is correct. This return is
11938
+ # mutually exclusive with the in-loop site (it returns before the loop),
11939
+ # so there is no double-emit.
11940
+ emit_completion_summary max_iterations
11559
11941
  return 1
11560
11942
  fi
11561
11943
 
@@ -11583,6 +11965,9 @@ except Exception as exc:
11583
11965
  # Check max iterations
11584
11966
  if check_max_iterations; then
11585
11967
  save_state $retry "max_iterations_reached" 0
11968
+ # Delegate-then-notify: terminal state, write summary + ping so a
11969
+ # detached run tells the user it stopped at the iteration cap.
11970
+ emit_completion_summary max_iterations
11586
11971
  return 0
11587
11972
  fi
11588
11973
 
@@ -12479,7 +12864,11 @@ if __name__ == "__main__":
12479
12864
  log_info "Council voted to stop (convergence detected + requirements verified)"
12480
12865
  log_info "Running memory consolidation..."
12481
12866
  run_memory_consolidation
12482
- notify_all_complete
12867
+ # Delegate-then-notify: optional local PR on success, then the
12868
+ # durable summary + desktop ping. on_run_complete is idempotent
12869
+ # and only opens a PR when LOKI_DELEGATE_PR=1 (default OFF).
12870
+ on_run_complete
12871
+ emit_completion_summary complete
12483
12872
  save_state $retry "council_approved" 0
12484
12873
  rm -f "$iter_output" 2>/dev/null
12485
12874
  return 0
@@ -12542,7 +12931,10 @@ if __name__ == "__main__":
12542
12931
  # Run memory consolidation on successful completion
12543
12932
  log_info "Running memory consolidation..."
12544
12933
  run_memory_consolidation
12545
- notify_all_complete
12934
+ # Delegate-then-notify: optional local PR on success, then the
12935
+ # durable summary + desktop ping (see on_run_complete).
12936
+ on_run_complete
12937
+ emit_completion_summary complete
12546
12938
  save_state $retry "completion_promise_fulfilled" 0
12547
12939
  rm -f "$iter_output" 2>/dev/null
12548
12940
  return 0
@@ -12636,6 +13028,9 @@ if __name__ == "__main__":
12636
13028
 
12637
13029
  log_error "Max retries ($MAX_RETRIES) exceeded"
12638
13030
  save_state $retry "failed" 1
13031
+ # Delegate-then-notify: terminal failure. critical urgency so the desktop
13032
+ # ping is louder; the summary file records where the partial work landed.
13033
+ emit_completion_summary failed critical
12639
13034
  return 1
12640
13035
  }
12641
13036
 
@@ -12778,11 +13173,19 @@ check_human_intervention() {
12778
13173
  log_warn "PAUSE file created by budget limit - NOT auto-clearing in perpetual mode"
12779
13174
  log_warn "Budget limit reached. Remove .loki/signals/BUDGET_EXCEEDED and .loki/PAUSE to continue."
12780
13175
  notify_intervention_needed "Budget limit reached - execution paused" 2>/dev/null || true
13176
+ # Genuinely blocking pause: write the durable intervention record
13177
+ # now (state-only; the ping above already fired). This is the
13178
+ # correct site for the durable file because the run actually halts
13179
+ # here until the operator clears the budget signal.
13180
+ build_completion_summary intervention 2>/dev/null || true
12781
13181
  local pause_result
12782
13182
  handle_pause
12783
13183
  pause_result=$?
12784
13184
  rm -f "$loki_dir/PAUSE"
12785
13185
  if [ "$pause_result" -eq 1 ]; then
13186
+ # STOP requested DURING the pause: relabel the durable record
13187
+ # as stopped (state-only; the user typed STOP and is aware).
13188
+ build_completion_summary stopped 2>/dev/null || true
12786
13189
  return 2
12787
13190
  fi
12788
13191
  return 1
@@ -12796,12 +13199,17 @@ check_human_intervention() {
12796
13199
  fi
12797
13200
  log_warn "PAUSE file detected - pausing execution"
12798
13201
  notify_intervention_needed "Execution paused via PAUSE file"
13202
+ # Genuinely blocking pause: write the durable intervention record now
13203
+ # (state-only; the ping above already fired).
13204
+ build_completion_summary intervention 2>/dev/null || true
12799
13205
  local pause_result
12800
13206
  handle_pause
12801
13207
  pause_result=$?
12802
13208
  rm -f "$loki_dir/PAUSE"
12803
13209
  if [ "$pause_result" -eq 1 ]; then
12804
- # STOP was requested during pause
13210
+ # STOP was requested during pause: relabel the durable record as
13211
+ # stopped (state-only; the user typed STOP and is aware).
13212
+ build_completion_summary stopped 2>/dev/null || true
12805
13213
  return 2
12806
13214
  fi
12807
13215
  return 1
@@ -12814,11 +13222,16 @@ check_human_intervention() {
12814
13222
  rm -f "$loki_dir/PAUSE_AT_CHECKPOINT"
12815
13223
  notify_intervention_needed "Execution paused at checkpoint"
12816
13224
  touch "$loki_dir/PAUSE"
13225
+ # Genuinely blocking pause: write the durable intervention record now
13226
+ # (state-only; the ping above already fired).
13227
+ build_completion_summary intervention 2>/dev/null || true
12817
13228
  local pause_result
12818
13229
  handle_pause
12819
13230
  pause_result=$?
12820
13231
  rm -f "$loki_dir/PAUSE"
12821
13232
  if [ "$pause_result" -eq 1 ]; then
13233
+ # STOP requested during pause: relabel as stopped (state-only).
13234
+ build_completion_summary stopped 2>/dev/null || true
12822
13235
  return 2
12823
13236
  fi
12824
13237
  return 1
@@ -12886,7 +13299,11 @@ check_human_intervention() {
12886
13299
  fi
12887
13300
  log_info "Running memory consolidation..."
12888
13301
  run_memory_consolidation
12889
- notify_all_complete
13302
+ # Delegate-then-notify: force-review approval is a real completion
13303
+ # (returns 2, which the run loop maps to a clean return 0). Treat it
13304
+ # like the other success exits: optional local PR + summary + ping.
13305
+ on_run_complete
13306
+ emit_completion_summary complete
12890
13307
  save_state ${RETRY_COUNT:-0} "council_force_approved" 0
12891
13308
  return 2 # Stop
12892
13309
  fi
@@ -12897,6 +13314,12 @@ check_human_intervention() {
12897
13314
  if [ -f "$loki_dir/STOP" ]; then
12898
13315
  log_warn "STOP file detected - stopping execution"
12899
13316
  rm -f "$loki_dir/STOP"
13317
+ # Delegate-then-notify: an explicit STOP file is a deliberate stop, but
13318
+ # a detached (--bg) user still benefits from a summary of partial work.
13319
+ # NOTE: the SIGTERM/`loki stop` group-kill path (cleanup handler near the
13320
+ # end of this file) is intentionally NOT notified: that user is at a
13321
+ # terminal issuing the stop and is already aware.
13322
+ emit_completion_summary stopped
12900
13323
  return 2
12901
13324
  fi
12902
13325
 
@@ -13364,6 +13787,9 @@ main() {
13364
13787
  echo -e " ${DIM}Logs:${NC} tail -f $log_file"
13365
13788
  echo -e " ${DIM}Status:${NC} cat .loki/STATUS.txt"
13366
13789
  echo ""
13790
+ echo -e "${GREEN}You will be notified when done (or if input is needed).${NC}"
13791
+ echo -e " ${DIM}Summary on completion:${NC} cat .loki/COMPLETION.txt"
13792
+ echo ""
13367
13793
 
13368
13794
  exit 0
13369
13795
  fi
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.21.0"
10
+ __version__ = "7.23.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.21.0
5
+ **Version:** v7.23.0
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var f8=Object.defineProperty;var u8=($)=>$;function c8($,Q){this[$]=u8.bind(null,Q)}var g=($,Q)=>{for(var Z in Q)f8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:c8.bind(Q,Z)})};var k=($,Q)=>()=>($&&(Q=$($=0)),Q);var X1=import.meta.require;var F$={};g(F$,{lokiDir:()=>P,homeLokiDir:()=>o1,findRepoRootForVersion:()=>d1,REPO_ROOT:()=>f});import{resolve as n,dirname as l1}from"path";import{fileURLToPath as p8}from"url";import{existsSync as L1}from"fs";import{homedir as l8}from"os";function d8(){let $=j$;for(let Q=0;Q<6;Q++){if(L1(n($,"VERSION"))&&L1(n($,"autonomy/run.sh")))return $;let Z=l1($);if(Z===$)break;$=Z}return n(j$,"..","..","..")}function d1($){let Q=$;for(let Z=0;Z<6;Z++){if(L1(n(Q,"VERSION"))&&L1(n(Q,"autonomy/run.sh")))return Q;let z=l1(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o1(){return n(l8(),".loki")}var j$,f;var y=k(()=>{j$=l1(p8(import.meta.url));f=d8()});import{readFileSync as o8}from"fs";import{resolve as n8,dirname as a8}from"path";import{fileURLToPath as s8}from"url";function k1(){if($1!==null)return $1;let $="7.21.0";if(typeof $==="string"&&$.length>0)return $1=$,$1;try{let Q=a8(s8(import.meta.url)),Z=d1(Q);$1=o8(n8(Z,"VERSION"),"utf-8").trim()}catch{$1="unknown"}return $1}var $1=null;var n1=k(()=>{y()});var E$={};g(E$,{runOrThrow:()=>t8,run:()=>j,commandVersion:()=>i8,commandExists:()=>v,ShellError:()=>a1});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,K;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}K=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[H,X,q]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:H,stderr:X,exitCode:q}}finally{if(z)clearTimeout(z);if(K)clearTimeout(K)}}async function t8($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function v($){let Q=r8($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function r8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function i8($,Q="--version"){if(!await v($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a1;var d=k(()=>{a1=class a1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return e8?"":$}var e8,T,N,_,KZ,A,R,h,J;var c=k(()=>{e8=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),N=a("\x1B[0;32m"),_=a("\x1B[1;33m"),KZ=a("\x1B[0;34m"),A=a("\x1B[0;36m"),R=a("\x1B[1m"),h=a("\x1B[2m"),J=a("\x1B[0m")});import{existsSync as U7}from"fs";async function Q1(){if(B1!==void 0)return B1;let $="/opt/homebrew/bin/python3.12";if(U7($))return B1=$,$;let Q=await v("python3.12");if(Q)return B1=Q,Q;let Z=await v("python3");return B1=Z,Z}async function Z1($,Q={}){let Z=await Q1();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B1;var H1=k(()=>{d()});var d$={};g(d$,{runStatus:()=>N7});import{existsSync as b,readFileSync as q1,readdirSync as v$,statSync as f$}from"fs";import{resolve as D,basename as P7}from"path";import{homedir as L7}from"os";async function j7(){if(await v("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var f8=Object.defineProperty;var u8=($)=>$;function c8($,Q){this[$]=u8.bind(null,Q)}var g=($,Q)=>{for(var Z in Q)f8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:c8.bind(Q,Z)})};var k=($,Q)=>()=>($&&(Q=$($=0)),Q);var X1=import.meta.require;var F$={};g(F$,{lokiDir:()=>P,homeLokiDir:()=>o1,findRepoRootForVersion:()=>d1,REPO_ROOT:()=>f});import{resolve as n,dirname as l1}from"path";import{fileURLToPath as p8}from"url";import{existsSync as L1}from"fs";import{homedir as l8}from"os";function d8(){let $=j$;for(let Q=0;Q<6;Q++){if(L1(n($,"VERSION"))&&L1(n($,"autonomy/run.sh")))return $;let Z=l1($);if(Z===$)break;$=Z}return n(j$,"..","..","..")}function d1($){let Q=$;for(let Z=0;Z<6;Z++){if(L1(n(Q,"VERSION"))&&L1(n(Q,"autonomy/run.sh")))return Q;let z=l1(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o1(){return n(l8(),".loki")}var j$,f;var y=k(()=>{j$=l1(p8(import.meta.url));f=d8()});import{readFileSync as o8}from"fs";import{resolve as n8,dirname as a8}from"path";import{fileURLToPath as s8}from"url";function k1(){if($1!==null)return $1;let $="7.23.0";if(typeof $==="string"&&$.length>0)return $1=$,$1;try{let Q=a8(s8(import.meta.url)),Z=d1(Q);$1=o8(n8(Z,"VERSION"),"utf-8").trim()}catch{$1="unknown"}return $1}var $1=null;var n1=k(()=>{y()});var E$={};g(E$,{runOrThrow:()=>t8,run:()=>j,commandVersion:()=>i8,commandExists:()=>v,ShellError:()=>a1});async function j($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,K;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}K=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[H,X,q]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:H,stderr:X,exitCode:q}}finally{if(z)clearTimeout(z);if(K)clearTimeout(K)}}async function t8($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function v($){let Q=r8($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function r8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function i8($,Q="--version"){if(!await v($))return null;let z=await j([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var a1;var d=k(()=>{a1=class a1 extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return e8?"":$}var e8,T,N,_,KZ,A,R,h,J;var c=k(()=>{e8=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),N=a("\x1B[0;32m"),_=a("\x1B[1;33m"),KZ=a("\x1B[0;34m"),A=a("\x1B[0;36m"),R=a("\x1B[1m"),h=a("\x1B[2m"),J=a("\x1B[0m")});import{existsSync as U7}from"fs";async function Q1(){if(B1!==void 0)return B1;let $="/opt/homebrew/bin/python3.12";if(U7($))return B1=$,$;let Q=await v("python3.12");if(Q)return B1=Q,Q;let Z=await v("python3");return B1=Z,Z}async function Z1($,Q={}){let Z=await Q1();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B1;var H1=k(()=>{d()});var d$={};g(d$,{runStatus:()=>N7});import{existsSync as b,readFileSync as q1,readdirSync as v$,statSync as f$}from"fs";import{resolve as D,basename as P7}from"path";import{homedir as L7}from"os";async function j7(){if(await v("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -787,4 +787,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
787
787
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
788
788
  `),process.stderr.write(v8),2}}g$();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var l3=await p3(Bun.argv.slice(2));process.exit(l3);
789
789
 
790
- //# debugId=C58800C688F6E31C64756E2164756E21
790
+ //# debugId=54BA5C5C5E9F714F64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.21.0'
60
+ __version__ = '7.23.0'