loki-mode 7.7.32 → 7.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-to-product system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product via the RARV-C closure loop, with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.7.32
6
+ # Loki Mode v7.7.34
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -381,4 +381,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
381
381
 
382
382
  ---
383
383
 
384
- **v7.7.32 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.7.34 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.7.32
1
+ 7.7.34
package/autonomy/loki CHANGED
@@ -145,6 +145,48 @@ LEARNING_EMIT_SH="$SKILL_DIR/learning/emit.sh"
145
145
  LOKI_DIR="${LOKI_DIR:-.loki}"
146
146
  export LOKI_DIR
147
147
 
148
+ # v7.7.34: launch the autonomous runner as a SESSION/PROCESS-GROUP LEADER so the
149
+ # whole tree (orchestrator + the claude/codex/aider agent pipeline + monitors)
150
+ # shares one process group and can be killed atomically with `kill -- -PGID`.
151
+ # Without this, stopping the orchestrator pid orphans the agent child (it
152
+ # reparents to init and keeps editing files), which is the "dashboard says
153
+ # stopped but it keeps running" bug. macOS has no `setsid` binary, so prefer the
154
+ # real binary (Linux/Docker), then perl POSIX::setsid, then python3 os.setsid,
155
+ # then degrade to a plain exec (the cwd+sentinel agent sweep is the backstop).
156
+ # This execs (replaces the current process); callers use it exactly like `exec`.
157
+ #
158
+ # IMPORTANT: setsid detaches the new session from the controlling terminal, so a
159
+ # foreground INTERACTIVE `loki start` would then ignore Ctrl+C (breaking the
160
+ # documented pause/exit-on-Ctrl+C UX). In an interactive shell, job control
161
+ # ALREADY places the runner in its own process group, so the agent child shares
162
+ # it and group-kill on stop works WITHOUT setsid. We therefore only create a new
163
+ # session when stdin is NOT a tty (scripts / CI / detached launches), where the
164
+ # runner would otherwise inherit the caller's group and escape group-kill.
165
+ # Interactive runs keep Ctrl+C. Opt out entirely with LOKI_NO_NEW_SESSION=1;
166
+ # force it on (e.g. for testing) with LOKI_FORCE_NEW_SESSION=1.
167
+ _loki_new_session_exec() {
168
+ if [ "${LOKI_NO_NEW_SESSION:-}" = "1" ]; then
169
+ exec "$@"
170
+ elif [ -t 0 ] && [ "${LOKI_FORCE_NEW_SESSION:-}" != "1" ]; then
171
+ # Interactive terminal: keep the controlling tty so Ctrl+C still works.
172
+ # We do NOT setsid here, so the runner may SHARE the interactive shell's
173
+ # process group. Therefore we do NOT export LOKI_OWN_SESSION, and the
174
+ # runner will NOT record loki.pgid -- group-kill is unsafe on a shared
175
+ # shell group (it could kill the user's shell). Stop falls back to the
176
+ # cwd + [LOKI-AUTONOMY-AGENT] sentinel sweep, which is safe and reaps the
177
+ # agent by identity rather than by group.
178
+ exec "$@"
179
+ elif command -v setsid >/dev/null 2>&1; then
180
+ LOKI_OWN_SESSION=1 exec setsid "$@"
181
+ elif command -v perl >/dev/null 2>&1; then
182
+ LOKI_OWN_SESSION=1 exec perl -e 'use POSIX qw(setsid); setsid(); exec @ARGV or exit 127;' "$@"
183
+ elif command -v python3 >/dev/null 2>&1; then
184
+ LOKI_OWN_SESSION=1 exec python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$@"
185
+ else
186
+ exec "$@"
187
+ fi
188
+ }
189
+
148
190
  # Anonymous usage telemetry
149
191
  PROJECT_DIR="$SKILL_DIR"
150
192
  _TELEMETRY_SCRIPT="$SKILL_DIR/autonomy/telemetry.sh"
@@ -1624,7 +1666,7 @@ cmd_start() {
1624
1666
  # variable" under bash 3.2 (macOS default) + `set -u` when args is empty.
1625
1667
  # User report: `loki start` with no PRD on /Users/lokesh/git/anonima
1626
1668
  # crashed at this line. Safe expansion guards against empty arrays.
1627
- exec "$RUN_SH" ${args[@]+"${args[@]}"}
1669
+ _loki_new_session_exec "$RUN_SH" ${args[@]+"${args[@]}"}
1628
1670
  }
1629
1671
 
1630
1672
  # Check if session is running
@@ -1874,6 +1916,62 @@ cmd_stop() {
1874
1916
  # Stop global session
1875
1917
  touch "$LOKI_DIR/STOP"
1876
1918
 
1919
+ # v7.7.34: group-kill first. The autonomous agent (claude/codex/aider)
1920
+ # shares the orchestrator's process group (the runner is launched as a
1921
+ # session leader), so signaling the whole group reaps the orchestrator
1922
+ # AND the agent child atomically. Killing only the orchestrator pid lets
1923
+ # the agent reparent to init and keep editing files -- the reported bug.
1924
+ # Guards: only a numeric pgid > 1 that is NOT this shell's own group.
1925
+ local _stop_pgid_file
1926
+ for _stop_pgid_file in "$LOKI_DIR/loki.pgid" "$LOKI_DIR/run.pgid"; do
1927
+ [ -f "$_stop_pgid_file" ] || continue
1928
+ local _spgid
1929
+ _spgid=$(cat "$_stop_pgid_file" 2>/dev/null | tr -d ' ')
1930
+ case "$_spgid" in ''|*[!0-9]*) continue ;; esac
1931
+ [ "$_spgid" -gt 1 ] 2>/dev/null || continue
1932
+ local _my_pgid
1933
+ _my_pgid=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')
1934
+ [ "$_spgid" = "$_my_pgid" ] && continue # never kill our own group
1935
+ # Collect protected pids (dashboard, app-runner, registered pids) so
1936
+ # a group-kill never takes down the dashboard if it happens to share
1937
+ # the orchestrator group. Mirrors the dashboard Python route.
1938
+ local _protected=" "
1939
+ local _pf
1940
+ if [ -d "$LOKI_DIR/pids" ]; then
1941
+ for _pf in "$LOKI_DIR/pids"/*.json; do
1942
+ [ -f "$_pf" ] || continue
1943
+ _protected="${_protected}$(basename "$_pf" .json) "
1944
+ done
1945
+ fi
1946
+ for _pf in "$LOKI_DIR/dashboard/dashboard.pid" "${HOME}/.loki/dashboard/dashboard.pid"; do
1947
+ [ -f "$_pf" ] && _protected="${_protected}$(cat "$_pf" 2>/dev/null | tr -d ' ') "
1948
+ done
1949
+ # Does any protected pid share this group?
1950
+ local _conflict=0 _gp
1951
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
1952
+ case "$_protected" in *" $_gp "*) _conflict=1; break ;; esac
1953
+ done
1954
+ if [ "$_conflict" = "1" ]; then
1955
+ # Per-pid kill of group members EXCLUDING protected pids.
1956
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
1957
+ case "$_protected" in *" $_gp "*) continue ;; esac
1958
+ [ "$_gp" = "$$" ] && continue
1959
+ kill -TERM "$_gp" 2>/dev/null || true
1960
+ done
1961
+ sleep 1
1962
+ for _gp in $(ps -axo pid=,pgid= 2>/dev/null | awk -v g="$_spgid" '$2==g{print $1}'); do
1963
+ case "$_protected" in *" $_gp "*) continue ;; esac
1964
+ [ "$_gp" = "$$" ] && continue
1965
+ kill -KILL "$_gp" 2>/dev/null || true
1966
+ done
1967
+ else
1968
+ kill -TERM -- -"$_spgid" 2>/dev/null || true
1969
+ sleep 1
1970
+ kill -KILL -- -"$_spgid" 2>/dev/null || true
1971
+ fi
1972
+ rm -f "$_stop_pgid_file" 2>/dev/null || true
1973
+ done
1974
+
1877
1975
  local killed_pid=""
1878
1976
  for pid_file in "$LOKI_DIR/loki.pid" "$LOKI_DIR/run.pid"; do
1879
1977
  if [ -f "$pid_file" ]; then
@@ -8861,8 +8959,9 @@ QPRDEOF
8861
8959
  exit 1
8862
8960
  fi
8863
8961
 
8864
- # Run the orchestrator with quick settings
8865
- exec "$RUN_SH" "$quick_prd"
8962
+ # Run the orchestrator with quick settings (session leader -- see
8963
+ # _loki_new_session_exec; enables atomic group-kill on stop).
8964
+ _loki_new_session_exec "$RUN_SH" "$quick_prd"
8866
8965
  }
8867
8966
 
8868
8967
  # Docker Compose monitoring with auto-fix (v6.67.0)
package/autonomy/run.sh CHANGED
@@ -12681,7 +12681,29 @@ main() {
12681
12681
  # CRITICAL: Unset LOKI_RUNNING_FROM_TEMP so the background process does its own self-copy
12682
12682
  # Otherwise it would run directly from the original file and the trap would delete it
12683
12683
  local original_script="$SCRIPT_DIR/run.sh"
12684
- LOKI_RUNNING_FROM_TEMP='' nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 &
12684
+ # v7.7.34: launch the backgrounded runner as its own session leader so
12685
+ # its agent tree shares one process group, killable atomically on stop.
12686
+ # Prefer setsid (Linux/Docker), then perl, then python3, then plain nohup.
12687
+ local _sess_launcher=""
12688
+ if [ "${LOKI_NO_NEW_SESSION:-}" != "1" ]; then
12689
+ if command -v setsid >/dev/null 2>&1; then _sess_launcher="setsid"
12690
+ elif command -v perl >/dev/null 2>&1; then _sess_launcher="perl-setsid"
12691
+ elif command -v python3 >/dev/null 2>&1; then _sess_launcher="python-setsid"; fi
12692
+ fi
12693
+ # Background mode is never interactive, so a new session is always safe
12694
+ # and desirable (detaches from the tty and gives a dedicated group for
12695
+ # stop). Export LOKI_OWN_SESSION=1 so the backgrounded runner records its
12696
+ # pgid.
12697
+ case "$_sess_launcher" in
12698
+ setsid)
12699
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup setsid "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
12700
+ perl-setsid)
12701
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup perl -e 'use POSIX qw(setsid); setsid(); exec @ARGV or exit 127;' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
12702
+ python-setsid)
12703
+ LOKI_RUNNING_FROM_TEMP='' LOKI_OWN_SESSION=1 nohup python3 -c 'import os,sys; os.setsid(); os.execvp(sys.argv[1], sys.argv[1:])' "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
12704
+ *)
12705
+ LOKI_RUNNING_FROM_TEMP='' nohup "$original_script" "${cmd_args[@]}" > "$log_file" 2>&1 & ;;
12706
+ esac
12685
12707
  local bg_pid=$!
12686
12708
  echo "$bg_pid" > "$pid_file"
12687
12709
  register_pid "$bg_pid" "background-session" "log=$log_file"
@@ -12829,6 +12851,21 @@ main() {
12829
12851
 
12830
12852
  # Write PID file for ALL modes (foreground + background)
12831
12853
  echo "$$" > "$pid_file"
12854
+ # v7.7.34: record the orchestrator's process-group id next to the pid so the
12855
+ # stop paths can `kill -- -PGID` the whole tree (orchestrator + agent +
12856
+ # monitors) atomically, closing the orphaned-agent hole.
12857
+ # CRITICAL SAFETY: only record the pgid when this runner is its OWN session
12858
+ # leader (LOKI_OWN_SESSION=1, set by the launcher when it setsid'd). If we
12859
+ # did NOT create a new session (interactive foreground, where we keep the
12860
+ # controlling tty for Ctrl+C), the runner may SHARE the user's shell process
12861
+ # group, and group-killing it would kill the user's shell. In that case we
12862
+ # leave loki.pgid absent and stop relies on the cwd+sentinel agent sweep.
12863
+ if [ "${LOKI_OWN_SESSION:-}" = "1" ]; then
12864
+ _loki_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
12865
+ if [ -n "$_loki_pgid" ]; then
12866
+ echo "$_loki_pgid" > "${pid_file%.pid}.pgid" 2>/dev/null || true
12867
+ fi
12868
+ fi
12832
12869
  # Store session ID in state for dashboard/status visibility
12833
12870
  if [ -n "${LOKI_SESSION_ID:-}" ]; then
12834
12871
  echo "${LOKI_SESSION_ID}" > ".loki/sessions/${LOKI_SESSION_ID}/session_id"
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.7.32"
10
+ __version__ = "7.7.34"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -10,6 +10,7 @@ import asyncio
10
10
  import json
11
11
  import logging
12
12
  import os
13
+ import subprocess
13
14
  import time
14
15
  from collections import defaultdict
15
16
  from dataclasses import asdict
@@ -2322,6 +2323,24 @@ async def stop_running_project(request: Request, body: RunningProjectStopRequest
2322
2323
  # pid already dead or unsignalable -- treat as stopped.
2323
2324
  stopped = True
2324
2325
 
2326
+ # v7.7.33: the registry pid can be stale (a crashed/restarted session leaves
2327
+ # an orphaned loki-run-*.sh under a new pid). Reap any orchestrator whose CWD
2328
+ # is this project's dir so a stale pid cannot yield a false "stopped". Scoped
2329
+ # by cwd to this project only.
2330
+ if loki_dir is not None:
2331
+ proj_dir = loki_dir.parent
2332
+ # v7.7.34: group-kill first (atomic; reaps the orphan-prone agent child),
2333
+ # then the cwd+sentinel reaper as backstop.
2334
+ _pgid2 = _read_pgid(loki_dir)
2335
+ if _pgid2 is not None:
2336
+ await asyncio.to_thread(_killpg_project, _pgid2, _collect_protected_pids(loki_dir))
2337
+ found_any, all_gone = await asyncio.to_thread(
2338
+ _reap_orchestrators_until_clear, proj_dir, str(proj_dir))
2339
+ if found_any:
2340
+ stopped = all_gone
2341
+ elif not all_gone:
2342
+ stopped = False
2343
+
2325
2344
  # Mark session.json stopped in that project's .loki.
2326
2345
  if loki_dir is not None:
2327
2346
  session_file = loki_dir / "session.json"
@@ -2581,6 +2600,351 @@ def _get_loki_dir() -> _Path:
2581
2600
  return _Path(".loki")
2582
2601
 
2583
2602
 
2603
+ def _find_orchestrator_pids_for_dir(project_dir: _Path) -> list[int]:
2604
+ """Find live Loki orchestrator PIDs whose working directory IS project_dir.
2605
+
2606
+ v7.7.33: the dashboard Stop button used to signal only loki.pid. When that
2607
+ pid file was stale (e.g. a crashed/restarted session left an orphaned
2608
+ `bash /tmp/loki-run-XXXXXX.sh` reparented to init under a NEW pid), Stop
2609
+ killed nothing live yet reported "stopped". The orchestrator temp-script
2610
+ name carries no project identity, so we map orchestrator -> project by the
2611
+ process CWD, which is reliably the project directory.
2612
+
2613
+ Strictly scoped: returns ONLY pids whose cwd resolves to project_dir, so a
2614
+ stop on one project never reaps another folder's runner. Best-effort: on any
2615
+ enumeration failure returns an empty list (callers still signal loki.pid).
2616
+ """
2617
+ pids: list[int] = []
2618
+ try:
2619
+ target = os.path.realpath(str(project_dir))
2620
+ except OSError:
2621
+ return pids
2622
+
2623
+ # Enumerate candidate orchestrator processes (the loki-run-*.sh temp script).
2624
+ # Anchor the pattern to the real temp-dir path prefix (mktemp writes the
2625
+ # runner to $TMPDIR or /tmp), so an unrelated process that merely mentions a
2626
+ # "loki-run-*.sh" string in its argv is far less likely to match. The cwd
2627
+ # equality check below is the authoritative scope guard regardless.
2628
+ # pgrep enumeration can transiently miss a live process (kernel proc-list
2629
+ # timing under load), which would let a still-running orphan slip past the
2630
+ # post-kill survivor check and yield a false "stopped". Union a few quick
2631
+ # passes to make the enumeration resilient. The cwd filter below still
2632
+ # guarantees scope correctness for whatever is enumerated.
2633
+ import time as _time
2634
+ # Two candidate patterns, BOTH cwd-filtered below (cwd is the authoritative
2635
+ # scope guard):
2636
+ # 1. /loki-run-*.sh -- the orchestrator temp script.
2637
+ # 2. [LOKI-AUTONOMY-AGENT] -- the sentinel Loki injects as the first line
2638
+ # of the agent's --append-system-prompt (v7.7.34). This matches the
2639
+ # claude/codex/aider AGENT process, which has no "loki-run" in its argv
2640
+ # and (critically) survives as an orphan (PPID 1) when the orchestrator
2641
+ # is killed -- the cause of "dashboard says stopped but it keeps
2642
+ # running". An interactive provider session never carries this sentinel,
2643
+ # so it is never matched.
2644
+ _patterns = [r"/loki-run-[^/ ]*\.sh", r"\[LOKI-AUTONOMY-AGENT\]"]
2645
+ candidate_set: set[int] = set()
2646
+ enumerated = False
2647
+ for _attempt in range(3):
2648
+ for _pat in _patterns:
2649
+ try:
2650
+ out = subprocess.run(
2651
+ ["pgrep", "-f", _pat],
2652
+ capture_output=True, text=True, timeout=5,
2653
+ )
2654
+ enumerated = True
2655
+ for line in out.stdout.split():
2656
+ try:
2657
+ candidate_set.add(int(line))
2658
+ except ValueError:
2659
+ pass
2660
+ except (OSError, subprocess.SubprocessError):
2661
+ pass
2662
+ if _attempt < 2:
2663
+ _time.sleep(0.15)
2664
+ if not enumerated:
2665
+ return pids
2666
+ candidates = list(candidate_set)
2667
+
2668
+ for pid in candidates:
2669
+ if _pid_is_gone(pid):
2670
+ continue # skip zombies / already-reaped
2671
+ cwd = _pid_cwd(pid)
2672
+ if cwd and os.path.realpath(cwd) == target:
2673
+ pids.append(pid)
2674
+ return pids
2675
+
2676
+
2677
+ def _pid_cwd(pid: int) -> Optional[str]:
2678
+ """Return a process's current working directory, or None.
2679
+
2680
+ Linux: read /proc/<pid>/cwd. macOS/BSD: fall back to `lsof -a -p <pid> -d cwd`.
2681
+ Best-effort and exception-safe; never raises.
2682
+ """
2683
+ # Linux fast path
2684
+ proc_cwd = f"/proc/{pid}/cwd"
2685
+ try:
2686
+ if os.path.isdir(f"/proc/{pid}"):
2687
+ return os.readlink(proc_cwd)
2688
+ except OSError:
2689
+ pass
2690
+ # macOS / BSD: lsof
2691
+ try:
2692
+ out = subprocess.run(
2693
+ ["lsof", "-a", "-p", str(pid), "-d", "cwd", "-Fn"],
2694
+ capture_output=True, text=True, timeout=5,
2695
+ )
2696
+ for line in out.stdout.splitlines():
2697
+ if line.startswith("n"):
2698
+ return line[1:]
2699
+ except (OSError, subprocess.SubprocessError):
2700
+ pass
2701
+ return None
2702
+
2703
+
2704
+ def _read_pgid(loki_dir: _Path) -> Optional[int]:
2705
+ """Read the orchestrator process-group id recorded at .loki/loki.pgid (or the
2706
+ per-session variant). Returns a valid pgid (>1) or None. Never raises."""
2707
+ for name in ("loki.pgid", "run.pgid"):
2708
+ f = loki_dir / name
2709
+ try:
2710
+ if f.exists():
2711
+ v = int(f.read_text().strip())
2712
+ if v > 1:
2713
+ return v
2714
+ except (ValueError, OSError):
2715
+ pass
2716
+ # per-session pgid files
2717
+ try:
2718
+ sess_dir = loki_dir / "sessions"
2719
+ if sess_dir.is_dir():
2720
+ for sd in sess_dir.iterdir():
2721
+ pf = sd / "loki.pgid"
2722
+ if pf.exists():
2723
+ v = int(pf.read_text().strip())
2724
+ if v > 1:
2725
+ return v
2726
+ except (ValueError, OSError):
2727
+ pass
2728
+ return None
2729
+
2730
+
2731
+ def _collect_protected_pids(loki_dir: _Path) -> set:
2732
+ """Pids that must NOT be killed by a group stop: the dashboard, app-runner,
2733
+ and anything registered under .loki/pids/ (filename is the pid). Plus this
2734
+ server process. Best-effort; never raises."""
2735
+ protected = {os.getpid()}
2736
+ try:
2737
+ protected.add(os.getppid())
2738
+ except OSError:
2739
+ pass
2740
+ try:
2741
+ pids_dir = loki_dir / "pids"
2742
+ if pids_dir.is_dir():
2743
+ for f in pids_dir.glob("*.json"):
2744
+ try:
2745
+ protected.add(int(f.stem))
2746
+ except ValueError:
2747
+ pass
2748
+ except OSError:
2749
+ pass
2750
+ # the standalone dashboard pid file
2751
+ for cand in (loki_dir / "dashboard" / "dashboard.pid",
2752
+ _Path.home() / ".loki" / "dashboard" / "dashboard.pid"):
2753
+ try:
2754
+ if cand.exists():
2755
+ protected.add(int(cand.read_text().strip()))
2756
+ except (ValueError, OSError):
2757
+ pass
2758
+ return protected
2759
+
2760
+
2761
+ def _killpg_project(pgid: Optional[int], protected_pids: Optional[set] = None) -> bool:
2762
+ """Atomically stop a project's whole process tree by signaling its process
2763
+ GROUP: SIGTERM, wait up to 5s, then SIGKILL. This is the v7.7.34 fix for the
2764
+ orphaned-agent bug -- killing only the orchestrator pid let the agent child
2765
+ reparent to init and keep running; a group kill reaps the orchestrator, the
2766
+ agent, and every monitor at once with no orphan window.
2767
+
2768
+ Guards (CRITICAL): refuse to signal an absent/0/1 pgid or this server's OWN
2769
+ process group (never commit suicide). If any protected pid (e.g. the shared
2770
+ dashboard registered in .loki/pids) shares the target pgid, fall back to
2771
+ per-pid kills of the group members EXCLUDING the protected pids, so the
2772
+ dashboard is never taken down. Best-effort; never raises. Returns True if a
2773
+ group signal (or scoped fallback) was issued."""
2774
+ import signal as _signal
2775
+ import time as _time
2776
+ if not isinstance(pgid, int) or pgid <= 1:
2777
+ return False
2778
+ try:
2779
+ if pgid == os.getpgrp():
2780
+ return False # never kill our own group
2781
+ except OSError:
2782
+ pass
2783
+ protected = protected_pids or set()
2784
+
2785
+ def _group_members(g: int) -> list:
2786
+ try:
2787
+ out = subprocess.run(["ps", "-axo", "pid=,pgid="],
2788
+ capture_output=True, text=True, timeout=5)
2789
+ except (OSError, subprocess.SubprocessError):
2790
+ return []
2791
+ members = []
2792
+ for line in out.stdout.splitlines():
2793
+ parts = line.split()
2794
+ if len(parts) >= 2:
2795
+ try:
2796
+ pid_i, pgid_i = int(parts[0]), int(parts[1])
2797
+ except ValueError:
2798
+ continue
2799
+ if pgid_i == g:
2800
+ members.append(pid_i)
2801
+ return members
2802
+
2803
+ members = _group_members(pgid)
2804
+ conflict = any(p in protected for p in members)
2805
+
2806
+ if conflict:
2807
+ # A protected pid (dashboard/app-runner) shares this group. Do NOT blast
2808
+ # the whole group; signal only the non-protected members per-pid.
2809
+ targets = [p for p in members if p not in protected and p != os.getpid()]
2810
+ for sig in (_signal.SIGTERM,):
2811
+ for p in targets:
2812
+ try:
2813
+ os.kill(p, sig)
2814
+ except OSError:
2815
+ pass
2816
+ _time.sleep(2.0)
2817
+ for p in targets:
2818
+ try:
2819
+ os.kill(p, _signal.SIGKILL)
2820
+ except OSError:
2821
+ pass
2822
+ return True
2823
+
2824
+ # Clean case: signal the whole group.
2825
+ try:
2826
+ os.killpg(pgid, _signal.SIGTERM)
2827
+ except (OSError, ProcessLookupError):
2828
+ return True # group already gone
2829
+ for _ in range(10):
2830
+ _time.sleep(0.5)
2831
+ if not _group_members(pgid):
2832
+ return True
2833
+ try:
2834
+ os.killpg(pgid, _signal.SIGKILL)
2835
+ except (OSError, ProcessLookupError):
2836
+ pass
2837
+ return True
2838
+
2839
+
2840
+ def _reap_orchestrators_until_clear(project_dir: _Path, expected_cwd: str,
2841
+ rounds: int = 6) -> tuple[bool, bool]:
2842
+ """Find and terminate orchestrators for project_dir, looping until the
2843
+ project is clear across a confirming re-scan. Returns (found_any, all_gone).
2844
+
2845
+ Robust against transient pgrep enumeration misses: a single find-then-scan
2846
+ could miss a live orphan and falsely report it gone. We repeat find+kill and
2847
+ require TWO consecutive empty scans before declaring all_gone, so a one-off
2848
+ enumeration miss cannot yield a false "stopped". Runs in a worker thread (the
2849
+ caller wraps it in asyncio.to_thread) so the blocking kills do not stall the
2850
+ event loop. Strictly cwd-scoped via _find_orchestrator_pids_for_dir.
2851
+ """
2852
+ import time as _time
2853
+ found_any = False
2854
+ consecutive_empty = 0
2855
+ for _round in range(rounds):
2856
+ found = _find_orchestrator_pids_for_dir(project_dir)
2857
+ if found:
2858
+ found_any = True
2859
+ consecutive_empty = 0
2860
+ for opid in found:
2861
+ _terminate_pid(opid, expected_cwd=expected_cwd)
2862
+ else:
2863
+ consecutive_empty += 1
2864
+ if consecutive_empty >= 2:
2865
+ return (found_any, True) # clear, confirmed twice
2866
+ _time.sleep(0.2) # brief pause before the confirming re-scan
2867
+ # Exhausted rounds: report gone only if the final scan is empty.
2868
+ return (found_any, not _find_orchestrator_pids_for_dir(project_dir))
2869
+
2870
+
2871
+ def _pid_is_gone(pid: int) -> bool:
2872
+ """True if pid no longer exists OR is a zombie/defunct (effectively dead,
2873
+ just not yet reaped by its parent). os.kill(pid,0) succeeds on a zombie, so
2874
+ we additionally consult `ps` for the process state. Never raises."""
2875
+ try:
2876
+ os.kill(pid, 0)
2877
+ except OSError:
2878
+ return True # no such process
2879
+ # alive per signal-0; check for zombie state (Z / defunct). Note: os.kill
2880
+ # above proved the pid exists, so EMPTY ps output is a transient race, NOT a
2881
+ # reap -- do not treat it as gone (that would falsely report a live
2882
+ # orchestrator stopped). Only an explicit Z/zombie state counts as gone.
2883
+ try:
2884
+ out = subprocess.run(["ps", "-o", "state=", "-p", str(pid)],
2885
+ capture_output=True, text=True, timeout=5)
2886
+ st = out.stdout.strip()
2887
+ if st.startswith("Z"):
2888
+ return True # zombie / defunct -- effectively dead
2889
+ except (OSError, subprocess.SubprocessError):
2890
+ pass
2891
+ return False
2892
+
2893
+
2894
+ def _terminate_pid(pid: int, timeout_s: float = 5.0,
2895
+ expected_cwd: Optional[str] = None) -> bool:
2896
+ """SIGTERM a pid, wait up to timeout_s, then SIGKILL. Return True if it is
2897
+ gone afterward. Reaps direct children first (pkill -P) so the provider/app
2898
+ child does not outlive the orchestrator. A zombie counts as gone.
2899
+ Best-effort, never raises.
2900
+
2901
+ If expected_cwd is given, re-verify the pid's cwd still matches it right
2902
+ before signaling (TOCTOU guard against pid reuse between enumeration and
2903
+ kill). If it no longer matches, do nothing and report the pid gone."""
2904
+ import time as _time
2905
+ if expected_cwd is not None:
2906
+ cwd = _pid_cwd(pid)
2907
+ # Only skip the kill when the cwd POSITIVELY differs (true pid reuse).
2908
+ # A failed/transient cwd lookup (cwd is None) must NOT cancel the kill:
2909
+ # the pid came from a cwd-matched enumeration moments ago, and treating a
2910
+ # transient lookup miss as "recycled" would skip killing a live
2911
+ # orchestrator and falsely report it stopped.
2912
+ if cwd:
2913
+ try:
2914
+ if os.path.realpath(cwd) != os.path.realpath(expected_cwd):
2915
+ return True # pid recycled to a different cwd -- do not kill
2916
+ except OSError:
2917
+ pass # fall through and kill the originally-matched pid
2918
+ try:
2919
+ # reap children first so a wedged child cannot keep the tree alive
2920
+ subprocess.run(["pkill", "-TERM", "-P", str(pid)],
2921
+ capture_output=True, timeout=5)
2922
+ except (OSError, subprocess.SubprocessError):
2923
+ pass
2924
+ try:
2925
+ os.kill(pid, 15)
2926
+ except (ProcessLookupError, OSError):
2927
+ return True # already gone
2928
+ deadline = timeout_s
2929
+ while deadline > 0:
2930
+ _time.sleep(0.5)
2931
+ deadline -= 0.5
2932
+ if _pid_is_gone(pid):
2933
+ return True
2934
+ # still alive -> SIGKILL the tree
2935
+ try:
2936
+ subprocess.run(["pkill", "-9", "-P", str(pid)],
2937
+ capture_output=True, timeout=5)
2938
+ except (OSError, subprocess.SubprocessError):
2939
+ pass
2940
+ try:
2941
+ os.kill(pid, 9)
2942
+ except (ProcessLookupError, OSError):
2943
+ return True
2944
+ _time.sleep(0.3)
2945
+ return _pid_is_gone(pid)
2946
+
2947
+
2584
2948
  _SAFE_ID_RE = re.compile(r'^[a-zA-Z0-9_-]+$')
2585
2949
 
2586
2950
 
@@ -3914,6 +4278,36 @@ async def stop_session(request: Request):
3914
4278
  except (ValueError, OSError, ProcessLookupError):
3915
4279
  process_stopped = True
3916
4280
 
4281
+ # v7.7.33: loki.pid alone is not authoritative. If it was stale (a crashed
4282
+ # or restarted session can leave an orphaned `loki-run-*.sh` under a new pid
4283
+ # reparented to init), the kill above is a no-op yet reports "stopped" while
4284
+ # the real orchestrator keeps running. Reap any orchestrator process whose
4285
+ # CWD is THIS project's directory. Strictly scoped by cwd, so a stop on one
4286
+ # project never touches another folder's runner.
4287
+ # v7.7.34: group-kill is the PRIMARY, atomic teardown. Signal the
4288
+ # orchestrator's whole process group so the agent child (which reparents to
4289
+ # init when only the orchestrator pid is killed) dies WITH it. Protected pids
4290
+ # (dashboard/app-runner) are spared. The cwd+sentinel reaper below is the
4291
+ # backstop for already-orphaned agents from pre-v7.7.34 sessions.
4292
+ _loki_dir_for_pg = _get_loki_dir()
4293
+ _pgid = _read_pgid(_loki_dir_for_pg)
4294
+ _protected = _collect_protected_pids(_loki_dir_for_pg)
4295
+ if _pgid is not None:
4296
+ await asyncio.to_thread(_killpg_project, _pgid, _protected)
4297
+ project_dir = _get_loki_dir().parent
4298
+ _proj = str(project_dir)
4299
+ found_any, all_gone = await asyncio.to_thread(
4300
+ _reap_orchestrators_until_clear, project_dir, _proj)
4301
+ # The orchestrator-survivor scan is authoritative over loki.pid. If any
4302
+ # orchestrator for this project was ever found, report stopped only when none
4303
+ # survive. If we found one (stale-pid case), the real outcome wins over the
4304
+ # loki.pid false positive. If the scan kept finding survivors, report not
4305
+ # stopped even if loki.pid claimed success.
4306
+ if found_any:
4307
+ process_stopped = all_gone
4308
+ elif not all_gone:
4309
+ process_stopped = False
4310
+
3917
4311
  # Mark session.json as stopped
3918
4312
  session_file = _get_loki_dir() / "session.json"
3919
4313
  if session_file.exists():
@@ -2,7 +2,7 @@
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:** v7.7.32
5
+ **Version:** v7.7.34
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var _7=Object.defineProperty;var I7=(K)=>K;function P7(K,$){this[K]=I7.bind(null,$)}var v=(K,$)=>{for(var Q in $)_7(K,Q,{get:$[Q],enumerable:!0,configurable:!0,set:P7.bind($,Q)})};var w=(K,$)=>()=>(K&&($=K(K=0)),$);var t=import.meta.require;var e1={};v(e1,{lokiDir:()=>L,homeLokiDir:()=>k1,findRepoRootForVersion:()=>S1,REPO_ROOT:()=>p});import{resolve as u,dirname as N1}from"path";import{fileURLToPath as L7}from"url";import{existsSync as J1}from"fs";import{homedir as R7}from"os";function E7(){let K=i1;for(let $=0;$<6;$++){if(J1(u(K,"VERSION"))&&J1(u(K,"autonomy/run.sh")))return K;let Q=N1(K);if(Q===K)break;K=Q}return u(i1,"..","..","..")}function S1(K){let $=K;for(let Q=0;Q<6;Q++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let X=N1($);if(X===$)break;$=X}return u(K,"..","..","..")}function L(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var g=w(()=>{i1=N1(L7(import.meta.url));p=E7()});import{readFileSync as w7}from"fs";import{resolve as x7,dirname as F7}from"path";import{fileURLToPath as N7}from"url";function G1(){if(o!==null)return o;let K="7.7.32";if(typeof K==="string"&&K.length>0)return o=K,o;try{let $=F7(N7(import.meta.url)),Q=S1($);o=w7(x7(Q,"VERSION"),"utf-8").trim()}catch{o="unknown"}return o}var o=null;var C1=w(()=>{g()});var $0={};v($0,{runOrThrow:()=>S7,run:()=>C,commandVersion:()=>C7,commandExists:()=>b,ShellError:()=>D1});async function C(K,$={}){let Q=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),X,Z;if($.timeoutMs&&$.timeoutMs>0)X=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[W,z,q]=await Promise.all([new Response(Q.stdout).text(),new Response(Q.stderr).text(),Q.exited]);return{stdout:W,stderr:z,exitCode:q}}finally{if(X)clearTimeout(X);if(Z)clearTimeout(Z)}}async function S7(K,$={}){let Q=await C(K,$);if(Q.exitCode!==0)throw new D1(`command failed (${Q.exitCode}): ${K.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function b(K){let $=k7(K),Q=await C(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function k7(K){if(!/^[A-Za-z0-9._/-]+$/.test(K))throw Error(`refused to shell-escape suspect token: ${K}`);return K}async function C7(K,$="--version"){if(!await b(K))return null;let X=await C([K,$],{timeoutMs:5000});if(X.exitCode!==0)return null;return((X.stdout||X.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var D1;var n=w(()=>{D1=class D1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,Q,X){super(K);this.message=K;this.exitCode=$;this.stdout=Q;this.stderr=X;this.name="ShellError"}}});function c(K){return D7?"":K}var D7,F,y,N,A6,A,D,S,H;var a=w(()=>{D7=(process.env.NO_COLOR??"").length>0;F=c("\x1B[0;31m"),y=c("\x1B[0;32m"),N=c("\x1B[1;33m"),A6=c("\x1B[0;34m"),A=c("\x1B[0;36m"),D=c("\x1B[1m"),S=c("\x1B[2m"),H=c("\x1B[0m")});import{existsSync as c7}from"fs";async function i(){if(Z1!==void 0)return Z1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return Z1=K,K;let $=await b("python3.12");if($)return Z1=$,$;let Q=await b("python3");return Z1=Q,Q}async function s(K,$={}){let Q=await i();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return C([Q,"-c",K],$)}var Z1;var z1=w(()=>{n()});var G0={};v(G0,{runStatus:()=>X5});import{existsSync as k,readFileSync as K1,readdirSync as W0,statSync as H0}from"fs";import{resolve as R,basename as a7}from"path";import{homedir as s7}from"os";async function t7(){if(await b("jq"))return!0;return process.stdout.write(`${F}Error: jq is required but not installed.${H}
2
+ var _7=Object.defineProperty;var I7=(K)=>K;function P7(K,$){this[K]=I7.bind(null,$)}var v=(K,$)=>{for(var Q in $)_7(K,Q,{get:$[Q],enumerable:!0,configurable:!0,set:P7.bind($,Q)})};var w=(K,$)=>()=>(K&&($=K(K=0)),$);var t=import.meta.require;var e1={};v(e1,{lokiDir:()=>L,homeLokiDir:()=>k1,findRepoRootForVersion:()=>S1,REPO_ROOT:()=>p});import{resolve as u,dirname as N1}from"path";import{fileURLToPath as L7}from"url";import{existsSync as J1}from"fs";import{homedir as R7}from"os";function E7(){let K=i1;for(let $=0;$<6;$++){if(J1(u(K,"VERSION"))&&J1(u(K,"autonomy/run.sh")))return K;let Q=N1(K);if(Q===K)break;K=Q}return u(i1,"..","..","..")}function S1(K){let $=K;for(let Q=0;Q<6;Q++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let X=N1($);if(X===$)break;$=X}return u(K,"..","..","..")}function L(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var g=w(()=>{i1=N1(L7(import.meta.url));p=E7()});import{readFileSync as w7}from"fs";import{resolve as x7,dirname as F7}from"path";import{fileURLToPath as N7}from"url";function G1(){if(o!==null)return o;let K="7.7.34";if(typeof K==="string"&&K.length>0)return o=K,o;try{let $=F7(N7(import.meta.url)),Q=S1($);o=w7(x7(Q,"VERSION"),"utf-8").trim()}catch{o="unknown"}return o}var o=null;var C1=w(()=>{g()});var $0={};v($0,{runOrThrow:()=>S7,run:()=>C,commandVersion:()=>C7,commandExists:()=>b,ShellError:()=>D1});async function C(K,$={}){let Q=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),X,Z;if($.timeoutMs&&$.timeoutMs>0)X=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}Z=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[W,z,q]=await Promise.all([new Response(Q.stdout).text(),new Response(Q.stderr).text(),Q.exited]);return{stdout:W,stderr:z,exitCode:q}}finally{if(X)clearTimeout(X);if(Z)clearTimeout(Z)}}async function S7(K,$={}){let Q=await C(K,$);if(Q.exitCode!==0)throw new D1(`command failed (${Q.exitCode}): ${K.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function b(K){let $=k7(K),Q=await C(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function k7(K){if(!/^[A-Za-z0-9._/-]+$/.test(K))throw Error(`refused to shell-escape suspect token: ${K}`);return K}async function C7(K,$="--version"){if(!await b(K))return null;let X=await C([K,$],{timeoutMs:5000});if(X.exitCode!==0)return null;return((X.stdout||X.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var D1;var n=w(()=>{D1=class D1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,Q,X){super(K);this.message=K;this.exitCode=$;this.stdout=Q;this.stderr=X;this.name="ShellError"}}});function c(K){return D7?"":K}var D7,F,y,N,A6,A,D,S,H;var a=w(()=>{D7=(process.env.NO_COLOR??"").length>0;F=c("\x1B[0;31m"),y=c("\x1B[0;32m"),N=c("\x1B[1;33m"),A6=c("\x1B[0;34m"),A=c("\x1B[0;36m"),D=c("\x1B[1m"),S=c("\x1B[2m"),H=c("\x1B[0m")});import{existsSync as c7}from"fs";async function i(){if(Z1!==void 0)return Z1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return Z1=K,K;let $=await b("python3.12");if($)return Z1=$,$;let Q=await b("python3");return Z1=Q,Q}async function s(K,$={}){let Q=await i();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return C([Q,"-c",K],$)}var Z1;var z1=w(()=>{n()});var G0={};v(G0,{runStatus:()=>X5});import{existsSync as k,readFileSync as K1,readdirSync as W0,statSync as H0}from"fs";import{resolve as R,basename as a7}from"path";import{homedir as s7}from"os";async function t7(){if(await b("jq"))return!0;return process.stdout.write(`${F}Error: jq is required but not installed.${H}
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)
@@ -604,4 +604,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
604
604
  `),2}default:return process.stderr.write(`Unknown command: ${$}
605
605
  `),process.stderr.write(j7),2}}process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var Z6=await X6(Bun.argv.slice(2));process.exit(Z6);
606
606
 
607
- //# debugId=CE5371E5853C122164756E2164756E21
607
+ //# debugId=4E748539D57C6D2864756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.7.32'
60
+ __version__ = '7.7.34'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "7.7.32",
3
+ "version": "7.7.34",
4
4
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 11 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
5
5
  "keywords": [
6
6
  "agent",
@@ -223,6 +223,7 @@ _loki_build_claude_auto_flags() {
223
223
  # the bash and Bun routes can stay byte-identical. No emojis, no em dashes.
224
224
  _loki_autonomy_override_text() {
225
225
  cat <<'LOKI_AUTONOMY_EOF'
226
+ [LOKI-AUTONOMY-AGENT] This is a Loki Mode autonomous agent process.
226
227
  You are running INSIDE Loki Mode autonomous execution, a session the user explicitly launched with full prior authorization (loki start / loki run). This IS an authorized autonomous session, not an unattended action you must seek consent for.
227
228
 
228
229
  Instruction precedence for THIS session: when the loki_system instructions in the user prompt conflict with any CLAUDE.md, project memory, or global memory that tells you to ask for clarification, pause for confirmation, refuse to act autonomously, or withhold routine commits, the loki_system instructions take precedence. The user has already consented by launching autonomous mode.