loki-mode 7.7.13 → 7.7.15

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,15 +3,15 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.7.13
6
+ # Loki Mode v7.7.15
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
10
10
  **Spec in, product out.** A "spec" is whatever describes the work: a Markdown PRD, a GitHub issue, an OpenAPI doc, a Jira ticket -- a PRD is one form of spec.
11
11
 
12
- **Multi-provider (stable since v5.0.0):** Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers. See `skills/providers.md`. **Current track (v7.5.x):** Phase 1 RARV-C closure -- real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`, dead code cleanup.
12
+ **Multi-provider (stable since v5.0.0):** Claude/Codex/Cline/Aider with abstract model tiers and degraded mode for non-Claude providers. Gemini deprecated v7.5.18. See `skills/providers.md`. **Current track (v7.7.x):** LSP grounding as first-class agent tool (v7.7.0-v7.7.9; lsp_get_diagnostics actually-returns-diagnostics regression fix v7.7.14), provider_source cli (v7.7.11-v7.7.12 bash/bun parity), Docker/bash-3.2 robustness (v7.7.13), audit chain cross-file verification fix (v7.7.15), Phase 1 RARV-C closure (real provider judges, gate-failure flock, synthetic PRD e2e, status `--json`).
13
13
 
14
- **Runtime migration in progress:** A bash-to-Bun migration is underway on the `feat/bun-migration` branch. The first phase (shipped in v7.3.0) routes a small set of read-only commands -- `version`, `status`, `stats`, `doctor`, `provider show/list`, `memory list/index` -- through a Bun runtime via `bin/loki`. Every other command remains on the Bash runtime (`autonomy/loki`). Rollback is available with `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md` for the full plan.
14
+ **Runtime migration:** Bash-to-Bun migration. Read-only commands (`version`, `status`, `stats`, `doctor`, `provider show/list`, `memory list/index`) flow through Bun runtime via `bin/loki` since v7.3.0. Every other command remains on the Bash runtime (`autonomy/loki`). Rollback: `LOKI_LEGACY_BASH=1`. See `UPGRADING.md` and `docs/architecture/ADR-001-runtime-migration.md`.
15
15
 
16
16
  ---
17
17
 
@@ -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.13 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
384
+ **v7.7.15 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.7.13
1
+ 7.7.15
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.7.13"
10
+ __version__ = "7.7.15"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -541,24 +541,47 @@ async def query_audit_logs(
541
541
 
542
542
  @router.get("/audit/verify", dependencies=[Depends(auth.require_scope("audit"))])
543
543
  async def verify_audit_integrity():
544
- """Verify audit log integrity across all log files."""
545
- if not audit.AUDIT_DIR.exists():
546
- return {"valid": True, "files_checked": 0, "results": []}
547
-
548
- log_files = sorted(audit.AUDIT_DIR.glob("audit-*.jsonl"))
544
+ """Verify audit log integrity across all log files.
545
+
546
+ v7.7.15 (council fix): delegates to `audit.verify_all_logs()` which
547
+ threads the chain hash across rotated daily files. The previous
548
+ per-file loop always started each file from genesis "0"*64, so any
549
+ log file beyond the first ever rotated false-negatived. Per-file
550
+ breakdown still returned alongside the aggregate verdict for
551
+ operator visibility.
552
+ """
553
+ aggregate = audit.verify_all_logs()
554
+
555
+ # Per-file breakdown for operator visibility (sorted by mtime
556
+ # to match the aggregate chain-walk order)
549
557
  results = []
550
- all_valid = True
551
-
552
- for log_file in log_files:
553
- result = audit.verify_log_integrity(str(log_file))
554
- result["file"] = log_file.name
555
- results.append(result)
556
- if not result["valid"]:
557
- all_valid = False
558
+ if audit.AUDIT_DIR.exists():
559
+ log_files = sorted(audit.AUDIT_DIR.glob("audit-*.jsonl"),
560
+ key=lambda p: p.stat().st_mtime)
561
+ prev_hash = "0" * 64
562
+ for log_file in log_files:
563
+ if not audit._file_has_integrity(str(log_file)):
564
+ results.append({
565
+ "file": log_file.name,
566
+ "valid": True,
567
+ "skipped_pre_integrity": True,
568
+ "entries_checked": 0,
569
+ })
570
+ continue
571
+ r = audit.verify_log_integrity(str(log_file), start_hash=prev_hash)
572
+ r["file"] = log_file.name
573
+ results.append(r)
574
+ if r.get("valid"):
575
+ prev_hash = r.get("last_hash", prev_hash)
558
576
 
559
577
  return {
560
- "valid": all_valid,
561
- "files_checked": len(log_files),
578
+ "valid": aggregate["valid"],
579
+ "files_checked": aggregate["files_checked"],
580
+ "files_skipped": aggregate.get("files_skipped", 0),
581
+ "entries_checked": aggregate.get("entries_checked", 0),
582
+ "genesis_file": aggregate.get("genesis_file"),
583
+ "first_tampered_file": aggregate.get("first_tampered_file"),
584
+ "first_tampered_line": aggregate.get("first_tampered_line"),
562
585
  "results": results,
563
586
  }
564
587
 
@@ -408,15 +408,27 @@ def is_audit_enabled() -> bool:
408
408
  return ENTERPRISE_AUDIT_ENABLED
409
409
 
410
410
 
411
- def verify_log_integrity(log_file: str) -> dict:
411
+ def verify_log_integrity(log_file: str, start_hash: Optional[str] = None) -> dict:
412
412
  """Verify the integrity chain of a JSONL audit log file.
413
413
 
414
- Reads each line, recomputes the chain hash from the genesis hash,
415
- and compares it to the stored _integrity_hash. If any entry has been
416
- tampered with, all subsequent hashes will also fail to match.
414
+ Reads each line, recomputes the chain hash, and compares to the
415
+ stored _integrity_hash. If any entry has been tampered with, all
416
+ subsequent hashes will also fail to match.
417
+
418
+ v7.7.15 fix: now accepts an optional `start_hash`. Audit logs rotate
419
+ daily and `_recover_last_hash()` carries the chain across file
420
+ boundaries at WRITE time. Without `start_hash`, verifying any log
421
+ file beyond the first-ever produces a false-negative (the file's
422
+ first entry was hashed against the PREVIOUS file's last hash, not
423
+ against the genesis "0"*64). Pass the previous file's final hash to
424
+ verify correctly, or use the new `verify_all_logs()` wrapper to
425
+ verify the entire chain across all rotated files.
417
426
 
418
427
  Args:
419
428
  log_file: Path to the JSONL audit log file to verify.
429
+ start_hash: Optional 64-hex starting hash for the chain. If
430
+ omitted, uses the genesis hash "0"*64 (correct only for the
431
+ very first audit log ever created on this machine).
420
432
 
421
433
  Returns:
422
434
  A dict with:
@@ -424,8 +436,10 @@ def verify_log_integrity(log_file: str) -> dict:
424
436
  - entries_checked (int): Number of entries verified.
425
437
  - first_tampered_line (int | None): 1-based line number of the
426
438
  first entry where the hash chain broke, or None if valid.
439
+ - last_hash (str): The final hash in this file (caller chains
440
+ this into the next file's verification).
427
441
  """
428
- prev_hash = "0" * 64 # Genesis hash
442
+ prev_hash = start_hash if start_hash is not None else ("0" * 64)
429
443
  entries_checked = 0
430
444
 
431
445
  try:
@@ -442,6 +456,7 @@ def verify_log_integrity(log_file: str) -> dict:
442
456
  "valid": False,
443
457
  "entries_checked": entries_checked,
444
458
  "first_tampered_line": line_num,
459
+ "last_hash": prev_hash,
445
460
  }
446
461
 
447
462
  stored_hash = entry.pop("_integrity_hash", None)
@@ -451,6 +466,7 @@ def verify_log_integrity(log_file: str) -> dict:
451
466
  "valid": False,
452
467
  "entries_checked": entries_checked,
453
468
  "first_tampered_line": line_num,
469
+ "last_hash": prev_hash,
454
470
  }
455
471
 
456
472
  entry_json = json.dumps(entry, sort_keys=True, default=str)
@@ -461,12 +477,108 @@ def verify_log_integrity(log_file: str) -> dict:
461
477
  "valid": False,
462
478
  "entries_checked": entries_checked,
463
479
  "first_tampered_line": line_num,
480
+ "last_hash": prev_hash,
464
481
  }
465
482
 
466
483
  prev_hash = stored_hash
467
484
  entries_checked += 1
468
485
 
469
486
  except FileNotFoundError:
470
- return {"valid": True, "entries_checked": 0, "first_tampered_line": None}
487
+ return {"valid": True, "entries_checked": 0, "first_tampered_line": None,
488
+ "last_hash": prev_hash}
489
+
490
+ # Normal exit (no rows or all rows passed): valid + carry last_hash forward
491
+ return {"valid": True, "entries_checked": entries_checked,
492
+ "first_tampered_line": None, "last_hash": prev_hash}
493
+
471
494
 
472
- return {"valid": True, "entries_checked": entries_checked, "first_tampered_line": None}
495
+ def _file_has_integrity(log_file: str) -> bool:
496
+ """Return True iff the first non-empty entry in `log_file` has an
497
+ `_integrity_hash` field. Used by `verify_all_logs` to skip
498
+ pre-integrity-era files entirely (integrity hashing was introduced
499
+ after some audit logs already existed)."""
500
+ try:
501
+ with open(log_file, "r") as f:
502
+ for line in f:
503
+ line = line.strip()
504
+ if not line:
505
+ continue
506
+ try:
507
+ entry = json.loads(line)
508
+ except json.JSONDecodeError:
509
+ return False
510
+ return "_integrity_hash" in entry
511
+ except OSError:
512
+ return False
513
+ return False
514
+
515
+
516
+ def verify_all_logs() -> dict:
517
+ """v7.7.15: verify the entire audit chain across all rotated log files.
518
+
519
+ Walks `AUDIT_DIR/audit-*.jsonl` in chronological order, threading
520
+ the chain hash from one file to the next via `start_hash`. Skips
521
+ files from the pre-integrity era (files whose first entry has no
522
+ `_integrity_hash` field, because integrity hashing was introduced
523
+ after some audit logs already existed).
524
+
525
+ Returns:
526
+ A dict with:
527
+ - valid (bool): True if the entire cross-file chain is intact.
528
+ - files_checked (int): Count of integrity-bearing files inspected.
529
+ - files_skipped (int): Count of pre-integrity files skipped.
530
+ - entries_checked (int): Total entries verified across all files.
531
+ - first_tampered_file (str | None): Path to the first file
532
+ whose chain broke, or None if valid.
533
+ - first_tampered_line (int | None): 1-based line number in
534
+ that file where the chain broke, or None if valid.
535
+ - genesis_file (str | None): Path to the first integrity-bearing
536
+ log file (the chain's genesis on this machine), or None if
537
+ no integrity-bearing files exist.
538
+ """
539
+ if not AUDIT_DIR.exists():
540
+ return {"valid": True, "files_checked": 0, "files_skipped": 0,
541
+ "entries_checked": 0, "first_tampered_file": None,
542
+ "first_tampered_line": None, "genesis_file": None}
543
+ # v7.7.15 council fix (Opus 2): rotated files have name shape
544
+ # `audit-YYYY-MM-DD.HHMMSS.jsonl` (from `_rotate_logs_if_needed` at
545
+ # line 167). Lexicographic sort puts `audit-2026-05-04.123456.jsonl`
546
+ # BEFORE `audit-2026-05-04.jsonl` (because `.1` < `.j` ASCII), which
547
+ # would break chain ordering and false-negative on any user who hit
548
+ # size-based rotation. Sort by mtime instead -- mirrors what
549
+ # `_cleanup_old_logs` already does at line 178.
550
+ files = sorted(AUDIT_DIR.glob("audit-*.jsonl"), key=lambda p: p.stat().st_mtime)
551
+ prev_hash = "0" * 64
552
+ total_entries = 0
553
+ files_checked = 0
554
+ files_skipped = 0
555
+ genesis_file = None
556
+ for log_file in files:
557
+ if genesis_file is None and not _file_has_integrity(str(log_file)):
558
+ files_skipped += 1
559
+ continue
560
+ if genesis_file is None:
561
+ genesis_file = str(log_file)
562
+ result = verify_log_integrity(str(log_file), start_hash=prev_hash)
563
+ files_checked += 1
564
+ total_entries += result.get("entries_checked", 0)
565
+ if not result.get("valid", False):
566
+ return {
567
+ "valid": False,
568
+ "files_checked": files_checked,
569
+ "files_skipped": files_skipped,
570
+ "entries_checked": total_entries,
571
+ "first_tampered_file": str(log_file),
572
+ "first_tampered_line": result.get("first_tampered_line"),
573
+ "genesis_file": genesis_file,
574
+ }
575
+ prev_hash = result.get("last_hash", prev_hash)
576
+ return {
577
+ "valid": True,
578
+ "files_checked": files_checked,
579
+ "files_skipped": files_skipped,
580
+ "entries_checked": total_entries,
581
+ "first_tampered_file": None,
582
+ "first_tampered_line": None,
583
+ "genesis_file": genesis_file,
584
+ }
@@ -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.13
5
+ **Version:** v7.7.15
6
6
 
7
7
  ---
8
8
 
@@ -34,6 +34,18 @@ So v1 keeps the N-process model and adds a publish/subscribe channel. v2 (out of
34
34
 
35
35
  ## 3. Prerequisite: fix the orphan `pending_diagnostics` reference
36
36
 
37
+ > **UPDATE 2026-05-27 (v7.7.14):** RESOLVED. The fix described below
38
+ > shipped in v7.7.14. `LSPClient` now spawns a dedicated daemon reader
39
+ > thread at end of `start()` that owns `proc.stdout`, routes responses
40
+ > by id to per-request `Queue`s, and routes `publishDiagnostics` into
41
+ > `self.pending_diagnostics`. `request()` parks on its Queue instead
42
+ > of reading stdout. Re-spawn after crash drains old reader cleanly.
43
+ > Reader-death drain pushes error sentinel to all pending waiters.
44
+ > See `mcp/lsp_proxy.py` and `tests/test-lsp-diagnostics-regression.sh`
45
+ > (5/5 PASS). The broadcast layer described in sections 4-13 can now
46
+ > be built on top of a working reader. The section below is kept for
47
+ > historical context.
48
+
37
49
  `lsp_proxy.py` line 867 (in `lsp_get_diagnostics`) reads:
38
50
 
39
51
  ```python
@@ -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 b=(K,$)=>{for(var z in $)_7(K,z,{get:$[z],enumerable:!0,configurable:!0,set:P7.bind($,z)})};var R=(K,$)=>()=>(K&&($=K(K=0)),$);var V1=import.meta.require;var e1={};b(e1,{lokiDir:()=>P,homeLokiDir:()=>k1,findRepoRootForVersion:()=>N1,REPO_ROOT:()=>p});import{resolve as u,dirname as S1}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 z=S1(K);if(z===K)break;K=z}return u(i1,"..","..","..")}function N1(K){let $=K;for(let z=0;z<6;z++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let Q=S1($);if(Q===$)break;$=Q}return u(K,"..","..","..")}function P(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var y=R(()=>{i1=S1(L7(import.meta.url));p=E7()});import{readFileSync as x7}from"fs";import{resolve as F7,dirname as w7}from"path";import{fileURLToPath as S7}from"url";function G1(){if(n!==null)return n;let K="7.7.13";if(typeof K==="string"&&K.length>0)return n=K,n;try{let $=w7(S7(import.meta.url)),z=N1($);n=x7(F7(z,"VERSION"),"utf-8").trim()}catch{n="unknown"}return n}var n=null;var D1=R(()=>{y()});var $0={};b($0,{runOrThrow:()=>N7,run:()=>S,commandVersion:()=>D7,commandExists:()=>D,ShellError:()=>C1});async function S(K,$={}){let z=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),Q,X;if($.timeoutMs&&$.timeoutMs>0)Q=setTimeout(()=>{try{z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{z.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[H,Z,q]=await Promise.all([new Response(z.stdout).text(),new Response(z.stderr).text(),z.exited]);return{stdout:H,stderr:Z,exitCode:q}}finally{if(Q)clearTimeout(Q);if(X)clearTimeout(X)}}async function N7(K,$={}){let z=await S(K,$);if(z.exitCode!==0)throw new C1(`command failed (${z.exitCode}): ${K.join(" ")}`,z.exitCode,z.stdout,z.stderr);return z}async function D(K){let $=k7(K),z=await S(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(z.exitCode===0)return z.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 D7(K,$="--version"){if(!await D(K))return null;let Q=await S([K,$],{timeoutMs:5000});if(Q.exitCode!==0)return null;return((Q.stdout||Q.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var C1;var c=R(()=>{C1=class C1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,z,Q){super(K);this.message=K;this.exitCode=$;this.stdout=z;this.stderr=Q;this.name="ShellError"}}});function l(K){return C7?"":K}var C7,E,C,x,O6,O,k,F,W;var a=R(()=>{C7=(process.env.NO_COLOR??"").length>0;E=l("\x1B[0;31m"),C=l("\x1B[0;32m"),x=l("\x1B[1;33m"),O6=l("\x1B[0;34m"),O=l("\x1B[0;36m"),k=l("\x1B[1m"),F=l("\x1B[2m"),W=l("\x1B[0m")});import{existsSync as c7}from"fs";async function t(){if(z1!==void 0)return z1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return z1=K,K;let $=await D("python3.12");if($)return z1=$,$;let z=await D("python3");return z1=z,z}async function s(K,$={}){let z=await t();if(!z)return{stdout:"",stderr:"python3 not found",exitCode:127};return S([z,"-c",K],$)}var z1;var Q1=R(()=>{c()});var G0={};b(G0,{runStatus:()=>z5});import{existsSync as N,readFileSync as Z1,readdirSync as H0,statSync as W0}from"fs";import{resolve as w,basename as a7}from"path";async function r7(){if(await D("jq"))return!0;return process.stdout.write(`${E}Error: jq is required but not installed.${W}
2
+ var _7=Object.defineProperty;var I7=(K)=>K;function P7(K,$){this[K]=I7.bind(null,$)}var b=(K,$)=>{for(var z in $)_7(K,z,{get:$[z],enumerable:!0,configurable:!0,set:P7.bind($,z)})};var R=(K,$)=>()=>(K&&($=K(K=0)),$);var V1=import.meta.require;var e1={};b(e1,{lokiDir:()=>P,homeLokiDir:()=>k1,findRepoRootForVersion:()=>N1,REPO_ROOT:()=>p});import{resolve as u,dirname as S1}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 z=S1(K);if(z===K)break;K=z}return u(i1,"..","..","..")}function N1(K){let $=K;for(let z=0;z<6;z++){if(J1(u($,"VERSION"))&&J1(u($,"autonomy/run.sh")))return $;let Q=S1($);if(Q===$)break;$=Q}return u(K,"..","..","..")}function P(){return process.env.LOKI_DIR??u(process.cwd(),".loki")}function k1(){return u(R7(),".loki")}var i1,p;var y=R(()=>{i1=S1(L7(import.meta.url));p=E7()});import{readFileSync as x7}from"fs";import{resolve as F7,dirname as w7}from"path";import{fileURLToPath as S7}from"url";function G1(){if(n!==null)return n;let K="7.7.15";if(typeof K==="string"&&K.length>0)return n=K,n;try{let $=w7(S7(import.meta.url)),z=N1($);n=x7(F7(z,"VERSION"),"utf-8").trim()}catch{n="unknown"}return n}var n=null;var D1=R(()=>{y()});var $0={};b($0,{runOrThrow:()=>N7,run:()=>S,commandVersion:()=>D7,commandExists:()=>D,ShellError:()=>C1});async function S(K,$={}){let z=Bun.spawn({cmd:[...K],stdout:"pipe",stderr:"pipe",env:$.env?{...process.env,...$.env}:process.env,cwd:$.cwd}),Q,X;if($.timeoutMs&&$.timeoutMs>0)Q=setTimeout(()=>{try{z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{z.kill("SIGKILL")}catch{}},2000)},$.timeoutMs);try{let[H,Z,q]=await Promise.all([new Response(z.stdout).text(),new Response(z.stderr).text(),z.exited]);return{stdout:H,stderr:Z,exitCode:q}}finally{if(Q)clearTimeout(Q);if(X)clearTimeout(X)}}async function N7(K,$={}){let z=await S(K,$);if(z.exitCode!==0)throw new C1(`command failed (${z.exitCode}): ${K.join(" ")}`,z.exitCode,z.stdout,z.stderr);return z}async function D(K){let $=k7(K),z=await S(["sh","-c",`command -v ${$}`],{timeoutMs:5000});if(z.exitCode===0)return z.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 D7(K,$="--version"){if(!await D(K))return null;let Q=await S([K,$],{timeoutMs:5000});if(Q.exitCode!==0)return null;return((Q.stdout||Q.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var C1;var c=R(()=>{C1=class C1 extends Error{message;exitCode;stdout;stderr;constructor(K,$,z,Q){super(K);this.message=K;this.exitCode=$;this.stdout=z;this.stderr=Q;this.name="ShellError"}}});function l(K){return C7?"":K}var C7,E,C,x,O6,O,k,F,W;var a=R(()=>{C7=(process.env.NO_COLOR??"").length>0;E=l("\x1B[0;31m"),C=l("\x1B[0;32m"),x=l("\x1B[1;33m"),O6=l("\x1B[0;34m"),O=l("\x1B[0;36m"),k=l("\x1B[1m"),F=l("\x1B[2m"),W=l("\x1B[0m")});import{existsSync as c7}from"fs";async function t(){if(z1!==void 0)return z1;let K="/opt/homebrew/bin/python3.12";if(c7(K))return z1=K,K;let $=await D("python3.12");if($)return z1=$,$;let z=await D("python3");return z1=z,z}async function s(K,$={}){let z=await t();if(!z)return{stdout:"",stderr:"python3 not found",exitCode:127};return S([z,"-c",K],$)}var z1;var Q1=R(()=>{c()});var G0={};b(G0,{runStatus:()=>z5});import{existsSync as N,readFileSync as Z1,readdirSync as H0,statSync as W0}from"fs";import{resolve as w,basename as a7}from"path";async function r7(){if(await D("jq"))return!0;return process.stdout.write(`${E}Error: jq is required but not installed.${W}
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)
@@ -584,4 +584,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
584
584
  `),2}default:return process.stderr.write(`Unknown command: ${$}
585
585
  `),process.stderr.write(j7),2}}process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var z6=await $6(Bun.argv.slice(2));process.exit(z6);
586
586
 
587
- //# debugId=4CE70B20232D2D2D64756E2164756E21
587
+ //# debugId=E61F640A3343E84664756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.7.13'
60
+ __version__ = '7.7.15'
package/mcp/lsp_proxy.py CHANGED
@@ -43,6 +43,7 @@ import importlib.util
43
43
  import json
44
44
  import logging
45
45
  import os
46
+ import queue
46
47
  import shutil
47
48
  import signal
48
49
  import site
@@ -249,13 +250,54 @@ class LSPClient:
249
250
  self._opened_uris: set = set()
250
251
  self._lock = threading.Lock()
251
252
  self._initialized = False
253
+ # v7.7.14 LSP regression fix (was broken since v7.7.0):
254
+ # publishDiagnostics notifications were dropped by request()'s
255
+ # busy-read loop. Now a dedicated reader thread (spawned at end
256
+ # of start()) owns proc.stdout, routes responses to per-request
257
+ # Queues, and routes `textDocument/publishDiagnostics` into
258
+ # `pending_diagnostics`. See docs/plans/UT2-6-LSP-DIAGNOSTIC-
259
+ # BROADCAST.md section 3 for the prior root-cause analysis.
260
+ self.pending_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
261
+ self._response_queues: Dict[int, "queue.Queue[Dict[str, Any]]"] = {}
262
+ self._response_lock = threading.Lock()
263
+ self._reader_thread: Optional[threading.Thread] = None
264
+ self._reader_stop = threading.Event()
252
265
 
253
266
  def start(self) -> None:
254
267
  """Spawn the subprocess and perform the LSP `initialize` +
255
268
  `initialized` handshake. Idempotent: re-calling start() on an
256
- already-initialized client is a no-op."""
269
+ already-initialized client is a no-op. If the subprocess died,
270
+ re-spawn cleanly: stop and join the previous reader thread first
271
+ to avoid leaking threads against a dead pipe.
272
+ """
257
273
  if self._initialized and self.proc and self.proc.poll() is None:
258
274
  return
275
+ # v7.7.14 (Opus 2 council fix): re-spawn after crash must not leak
276
+ # the previous reader thread. Signal stop + join with timeout, then
277
+ # reset routing state so the new reader starts clean.
278
+ if self._reader_thread and self._reader_thread.is_alive():
279
+ self._reader_stop.set()
280
+ # Close stale stdout to unblock the reader's _read_lsp() if any
281
+ try:
282
+ if self.proc and self.proc.stdout:
283
+ self.proc.stdout.close()
284
+ except OSError:
285
+ pass
286
+ self._reader_thread.join(timeout=1.0)
287
+ self._reader_thread = None
288
+ # Drain any pending response waiters from the previous incarnation;
289
+ # they would otherwise hang for the full request() timeout.
290
+ with self._response_lock:
291
+ for waiter in self._response_queues.values():
292
+ try:
293
+ waiter.put_nowait({'error': {'message': 'LSP restarted; request abandoned'}})
294
+ except queue.Full:
295
+ pass
296
+ self._response_queues.clear()
297
+ with self._lock:
298
+ self.pending_diagnostics.clear()
299
+ self._opened_uris.clear()
300
+ self._initialized = False
259
301
  cmd = [self.binary_path] + self.extra_args
260
302
  self.proc = subprocess.Popen(
261
303
  cmd,
@@ -299,8 +341,89 @@ class LSPClient:
299
341
  'params': {},
300
342
  })
301
343
  self._initialized = True
344
+ # v7.7.14 fix: spawn the notification-reader thread AFTER the
345
+ # synchronous initialize handshake completes. From this point on,
346
+ # all reads from proc.stdout go through `_reader_loop`; request()
347
+ # parks on a per-request Queue keyed by request id.
348
+ self._reader_stop.clear()
349
+ self._reader_thread = threading.Thread(
350
+ target=self._reader_loop,
351
+ name=f"lsp-reader-{self.language}",
352
+ daemon=True,
353
+ )
354
+ self._reader_thread.start()
302
355
  _record_pid_to_disk(self.language, self.proc.pid)
303
356
 
357
+ def _reader_loop(self) -> None:
358
+ """v7.7.14 fix: dedicated reader thread that owns proc.stdout.
359
+ Routes JSON-RPC responses to per-request Queues keyed by id;
360
+ routes `textDocument/publishDiagnostics` notifications into
361
+ `self.pending_diagnostics`. Exits cleanly on EOF or stop signal.
362
+
363
+ v7.7.14 council fix (Opus 2): on exit, drain pending request
364
+ waiters with an error sentinel so they fail fast instead of
365
+ hanging for the full request() timeout. Log the exit reason so
366
+ a silently-dead reader does not silently break the whole proxy.
367
+ """
368
+ exit_reason = "stop signal"
369
+ try:
370
+ while not self._reader_stop.is_set():
371
+ try:
372
+ if not self.proc or not self.proc.stdout:
373
+ exit_reason = "proc/stdout missing"
374
+ break
375
+ msg = _read_lsp(self.proc.stdout)
376
+ except Exception as exc:
377
+ exit_reason = f"read exception: {type(exc).__name__}: {exc}"
378
+ break
379
+ if msg is None:
380
+ exit_reason = "EOF (subprocess closed stdout)"
381
+ break
382
+ msg_id = msg.get('id')
383
+ method = msg.get('method')
384
+ if msg_id is not None and method is None:
385
+ # Response to a prior request: hand off to waiter
386
+ with self._response_lock:
387
+ waiter = self._response_queues.get(msg_id)
388
+ if waiter is not None:
389
+ try:
390
+ waiter.put_nowait(msg)
391
+ except queue.Full:
392
+ pass
393
+ elif method == 'textDocument/publishDiagnostics':
394
+ params = msg.get('params') or {}
395
+ uri = params.get('uri')
396
+ diags = params.get('diagnostics') or []
397
+ if uri:
398
+ with self._lock:
399
+ self.pending_diagnostics[uri] = diags
400
+ # Other notifications (window/logMessage, $/progress, etc.)
401
+ # silently ignored. Server-to-client requests are not handled
402
+ # (we declared no capabilities, so servers should not send any).
403
+ finally:
404
+ # Drain any outstanding request waiters with an error sentinel.
405
+ # Without this, request() callers hang the full timeout (5s+) on
406
+ # reader death; downstream tools surface as silent slow paths.
407
+ with self._response_lock:
408
+ waiters = list(self._response_queues.items())
409
+ self._response_queues.clear()
410
+ for rid, waiter in waiters:
411
+ try:
412
+ waiter.put_nowait({'error': {
413
+ 'message': f'LSP reader thread exited: {exit_reason}',
414
+ 'request_id': rid,
415
+ }})
416
+ except queue.Full:
417
+ pass
418
+ try:
419
+ logger.warning(
420
+ "LSP reader thread for language=%s exited: %s "
421
+ "(drained %d pending waiters)",
422
+ self.language, exit_reason, len(waiters),
423
+ )
424
+ except Exception:
425
+ pass
426
+
304
427
  def _next_request_id(self) -> int:
305
428
  rid = self._next_id
306
429
  self._next_id += 1
@@ -335,29 +458,41 @@ class LSPClient:
335
458
  self._opened_uris.add(uri)
336
459
 
337
460
  def request(self, method: str, params: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]:
338
- """Send a JSON-RPC request and block until its response (or
339
- timeout / EOF) arrives. Returns the decoded LSP response dict
340
- (which has 'result' on success and 'error' on failure)."""
461
+ """Send a JSON-RPC request and block until its response arrives.
462
+
463
+ v7.7.14: parks on a per-request Queue instead of reading stdout
464
+ directly (which would race the reader thread). The reader thread
465
+ spawned in start() routes responses to this Queue by request id.
466
+ """
341
467
  rid = self._next_request_id()
342
- _write_lsp(self.proc.stdin, {
343
- 'jsonrpc': '2.0',
344
- 'id': rid,
345
- 'method': method,
346
- 'params': params,
347
- })
348
- deadline = time.time() + timeout
349
- while time.time() < deadline:
350
- msg = _read_lsp(self.proc.stdout)
351
- if msg is None:
352
- return {'error': {'message': 'LSP EOF before response'}}
353
- if msg.get('id') == rid:
354
- return msg
355
- # Ignore notifications and unrelated request responses.
356
- return {'error': {'message': f'LSP timeout after {timeout}s'}}
468
+ wait_q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=1)
469
+ with self._response_lock:
470
+ self._response_queues[rid] = wait_q
471
+ try:
472
+ try:
473
+ _write_lsp(self.proc.stdin, {
474
+ 'jsonrpc': '2.0',
475
+ 'id': rid,
476
+ 'method': method,
477
+ 'params': params,
478
+ })
479
+ except (BrokenPipeError, OSError) as exc:
480
+ return {'error': {'message': f'LSP I/O failure on write: {exc}'}}
481
+ try:
482
+ return wait_q.get(timeout=timeout)
483
+ except queue.Empty:
484
+ return {'error': {'message': f'LSP timeout after {timeout}s'}}
485
+ finally:
486
+ with self._response_lock:
487
+ self._response_queues.pop(rid, None)
357
488
 
358
489
  def shutdown(self) -> None:
359
490
  """Send `shutdown` + `exit`, then SIGTERM after a 2s grace and
360
491
  SIGKILL after another 1s if still alive."""
492
+ # v7.7.14: signal the reader thread to stop. It will also exit on
493
+ # its own once proc.stdout closes (EOF), but the flag is belt-and-
494
+ # suspenders for the kill path below.
495
+ self._reader_stop.set()
361
496
  if not self.proc or self.proc.poll() is not None:
362
497
  return
363
498
  try:
@@ -859,21 +994,20 @@ async def lsp_get_diagnostics(file: str) -> str:
859
994
  client.did_open(abs_file)
860
995
  except (BrokenPipeError, OSError) as exc:
861
996
  return json.dumps({'error': f'LSP I/O failure on didOpen: {exc}', 'language': language})
862
- # The LSPClient implementation buffers received notifications; if the
863
- # client doesn't expose a buffer, we fall back to a synthetic empty list.
864
- # This is intentional: callers see no false errors when the buffer is
865
- # absent; integration test asserts the elapsed-ms budget either way.
997
+ # v7.7.14: reader thread populates client.pending_diagnostics on
998
+ # textDocument/publishDiagnostics notifications. Poll for up to ~1s
999
+ # (matches docstring "waits up to 1 second"). Pyright cold-pass on
1000
+ # a small file lands diagnostics in 100-400ms; gopls/rust-analyzer
1001
+ # vary. If the buffer never fills, return empty (no false errors).
866
1002
  diagnostics: List[Dict[str, Any]] = []
867
- if hasattr(client, 'pending_diagnostics'):
868
- target_uri = _path_to_uri(abs_file)
869
- # Drain whatever has arrived so far (up to ~250ms wait if empty).
870
- for _ in range(5):
871
- with getattr(client, '_lock', threading.Lock()):
872
- buf = getattr(client, 'pending_diagnostics', {})
873
- if isinstance(buf, dict) and target_uri in buf:
874
- diagnostics = list(buf.get(target_uri) or [])
875
- break
876
- time.sleep(0.05)
1003
+ target_uri = _path_to_uri(abs_file)
1004
+ for _ in range(20):
1005
+ with client._lock:
1006
+ buf = client.pending_diagnostics
1007
+ if target_uri in buf:
1008
+ diagnostics = list(buf.get(target_uri) or [])
1009
+ break
1010
+ time.sleep(0.05)
877
1011
  err_count = sum(1 for d in diagnostics if d.get('severity') == 1)
878
1012
  warn_count = sum(1 for d in diagnostics if d.get('severity') == 2)
879
1013
  return json.dumps({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "7.7.13",
3
+ "version": "7.7.15",
4
4
  "description": "Loki Mode by Autonomi. Multi-agent autonomous SDLC framework. Spec to deployed app: PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief. 4 AI providers (Claude Code, OpenAI Codex, Cline, Aider). 11 quality gates.",
5
5
  "keywords": [
6
6
  "agent",