loki-mode 7.41.5 → 7.42.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.
@@ -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.41.5
5
+ **Version:** v7.42.0
6
6
 
7
7
  ---
8
8
 
@@ -63,6 +63,7 @@ review verdict, evidence-related parses) so determinism is never affected.
63
63
  - [VS Code Extension (Deprecated)](#vs-code-extension-deprecated)
64
64
  - [Sandbox Mode](#sandbox-mode)
65
65
  - [Multi-Provider Support](#multi-provider-support)
66
+ - [Environment Variables](#environment-variables)
66
67
  - [Claude Code (CLI)](#claude-code-cli)
67
68
  - [Claude.ai (Web)](#claudeai-web)
68
69
  - [Anthropic API Console](#anthropic-api-console)
@@ -367,6 +368,74 @@ When using `codex`, `cline`, or `aider` providers, Loki Mode operates in **degra
367
368
 
368
369
  ---
369
370
 
371
+ ## Environment Variables
372
+
373
+ Loki Mode is designed to run with zero configuration: the trust-layer and
374
+ quality features below are default-on and decide intelligently by inspecting
375
+ the work. The environment variables here are opt-out escape hatches for power
376
+ users, not required setup. Set the documented value to disable a feature; leave
377
+ the variable unset to keep the intelligent default.
378
+
379
+ ### Trust-gate and completion knobs (default-on)
380
+
381
+ These are read by the orchestrator (`autonomy/run.sh`) on every run.
382
+
383
+ - `LOKI_REVIEW_INCONCLUSIVE_BLOCK` (default `1`) -- when a code-review cycle
384
+ returns zero usable verdicts (every reviewer produced empty output), the
385
+ review is treated as INCONCLUSIVE and the gate BLOCKS, because an all-empty
386
+ review proves nothing. A bounded one-shot retry runs first
387
+ (`LOKI_REVIEW_RETRY`, default `1`). Set `LOKI_REVIEW_INCONCLUSIVE_BLOCK=0` to
388
+ record the inconclusive result without blocking.
389
+
390
+ - `LOKI_COMPLETION_TEST_CAPTURE` (default `1`) -- before the verified-completion
391
+ evidence gate runs, Loki captures a fresh `test-results.json` so the gate
392
+ scores on real PASS/FAIL test results instead of a stale or missing file. It
393
+ reuses this iteration's results if already fresh, and never crashes the
394
+ completion path on red tests (the gate is the decider). Set
395
+ `LOKI_COMPLETION_TEST_CAPTURE=0` to opt out.
396
+
397
+ - `LOKI_AUTO_DOCS` (default `true`) -- auto-generates the `.loki/docs/` suite
398
+ before the documentation gate evaluates, so the gate scores on real generated
399
+ docs instead of nagging you to run `loki docs generate` by hand. Bounded:
400
+ runs at most once per run when docs are missing, and again only when existing
401
+ docs are substantially stale; best-effort, never fails the iteration loop.
402
+ Set `LOKI_AUTO_DOCS=false` to opt out.
403
+
404
+ ### Output-token compressor (caveman, Claude-only)
405
+
406
+ Loki integrates [caveman](https://github.com/JuliusBrussee/caveman), an optional
407
+ Claude Code skill that compresses the model's OUTPUT tokens only (keeping all
408
+ technical substance). It activates on free-form generation (the main RARV dev
409
+ loop) and is HARD-SUPPRESSED on every trust-gate subcall (council votes, code
410
+ review verdicts, evidence-related parses) so determinism is never affected. It
411
+ is Claude-provider-only; runs are byte-identical on Codex / Cline / Aider. These
412
+ variables are read in `autonomy/lib/claude-flags.sh`.
413
+
414
+ - `LOKI_CAVEMAN` (default on) -- set `LOKI_CAVEMAN=0` to disable the compressor.
415
+ Suppression on trust-gate subcalls is unconditional and applies even when
416
+ caveman is globally installed but `LOKI_CAVEMAN=0`, so trust gates are never
417
+ exposed to compression.
418
+
419
+ - `LOKI_CAVEMAN_LEVEL` (default `full`) -- the compression level for free-form
420
+ activation. When you do NOT set this, the level is inferred per-invocation
421
+ from the run's RARV tier (planning -> `lite`, development/fast -> `full`); the
422
+ auto path never selects `ultra`. Setting `LOKI_CAVEMAN_LEVEL` explicitly
423
+ overrides the inference entirely (the opt-out escape hatch).
424
+
425
+ - `LOKI_CAVEMAN_VERSION` (default `1.9.0`) -- the pinned caveman version used by
426
+ the one-time bootstrap. Bump only to upgrade the compressor.
427
+
428
+ ### RARV-C closure knobs (default-on)
429
+
430
+ The Phase 1 / RARV-C closure loop (findings injection, override council,
431
+ learnings writer, handoff doc) is default-on and documented in detail at the
432
+ top of this guide under [Phase 1 RARV-C closure](#phase-1-rarv-c-closure-shipped-v750-default-on-as-of-v753):
433
+ `LOKI_INJECT_FINDINGS`, `LOKI_OVERRIDE_COUNCIL`, `LOKI_AUTO_LEARNINGS`, and
434
+ `LOKI_HANDOFF_MD` (each opt out with `=0`). For the full schema and
435
+ reachability notes, see `skills/quality-gates.md`.
436
+
437
+ ---
438
+
370
439
  ## Claude Code (CLI)
371
440
 
372
441
  Loki Mode can be installed as a skill in three ways:
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.41.5";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});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,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))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 a$;var d=L(()=>{a$=class a$ 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 WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
2
+ var n6=Object.defineProperty;var a6=($)=>$;function s6($,Q){this[$]=a6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)n6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:s6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var S1={};h(S1,{lokiDir:()=>P,homeLokiDir:()=>o$,findRepoRootForVersion:()=>d$,REPO_ROOT:()=>m});import{resolve as n,dirname as l$}from"path";import{fileURLToPath as t6}from"url";import{existsSync as P$}from"fs";import{homedir as r6}from"os";function i6(){let $=N1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=l$($);if(Z===$)break;$=Z}return n(N1,"..","..","..")}function d$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=l$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function o$(){return n(r6(),".loki")}var N1,m;var C=L(()=>{N1=l$(t6(import.meta.url));m=i6()});import{readFileSync as e6}from"fs";import{resolve as $Q,dirname as QQ}from"path";import{fileURLToPath as ZQ}from"url";function F$(){if($$!==null)return $$;let $="7.42.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=QQ(ZQ(import.meta.url)),Z=d$(Q);$$=e6($Q(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var n$=L(()=>{C()});var C1={};h(C1,{runOrThrow:()=>zQ,run:()=>j,commandVersion:()=>KQ,commandExists:()=>f,ShellError:()=>a$});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,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[W,K,U]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:W,stderr:K,exitCode:U}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function zQ($,Q={}){let Z=await j($,Q);if(Z.exitCode!==0)throw new a$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=XQ($),Z=await j(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function XQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function KQ($,Q="--version"){if(!await f($))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 a$;var d=L(()=>{a$=class a$ 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 WQ?"":$}var WQ,T,S,I,TZ,w,R,y,q;var c=L(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),I=a("\x1B[1;33m"),TZ=a("\x1B[0;34m"),w=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),q=a("\x1B[0m")});import{existsSync as TQ}from"fs";async function Q$(){if(B$!==void 0)return B$;let $="/opt/homebrew/bin/python3.12";if(TQ($))return B$=$,$;let Q=await f("python3.12");if(Q)return B$=Q,Q;let Z=await f("python3");return B$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return j([Z,"-c",$],Q)}var B$;var W$=L(()=>{d()});var t1={};h(t1,{runStatus:()=>gQ});import{existsSync as v,readFileSync as U$,readdirSync as l1,statSync as d1}from"fs";import{resolve as D,basename as xQ}from"path";import{homedir as NQ}from"os";async function DQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${q}
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)
@@ -789,4 +789,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
789
789
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
790
790
  `),process.stderr.write(o6),2}}p1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var ZZ=await QZ(Bun.argv.slice(2));process.exit(ZZ);
791
791
 
792
- //# debugId=BBB074C842B2B95764756E2164756E21
792
+ //# debugId=D7F92E946CD3E45564756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.41.5'
60
+ __version__ = '7.42.0'
package/mcp/lsp_proxy.py CHANGED
@@ -9,8 +9,12 @@ language's LSP is not detected, tools targeting that language return a
9
9
  structured `{"error": ...}` payload but the server itself stays up.
10
10
 
11
11
  Architecture:
12
- - Stdlib only (json, subprocess, shutil, threading, os, pathlib,
13
- atexit, signal, time, urllib). NO new pip dependencies.
12
+ - Stdlib for the LSP wire + lifecycle (json, subprocess, shutil,
13
+ threading, os, pathlib, atexit, signal, time, urllib). The only
14
+ non-stdlib import is anyio (a transitive MCP-SDK dependency), used
15
+ solely for the threading bridge that keeps the async tool handlers
16
+ off the event loop; it is guarded so the module still imports on the
17
+ no-SDK degrade path.
14
18
  - Lazy lifecycle: an `LSPClient` is spawned on the first tool call
15
19
  that needs a given language. Spawn does the LSP `initialize`
16
20
  handshake and caches `didOpen` per file URI.
@@ -43,6 +47,7 @@ Usage:
43
47
  from __future__ import annotations
44
48
 
45
49
  import atexit
50
+ import functools
46
51
  import json
47
52
  import logging
48
53
  import os
@@ -54,9 +59,43 @@ import sys
54
59
  import threading
55
60
  import time
56
61
  from pathlib import Path
57
- from typing import Any, Dict, List, Optional, Tuple
62
+ from typing import Any, Callable, Dict, List, Optional, Tuple
58
63
  from urllib.parse import quote
59
64
 
65
+ # v7.x async-safety fix: the MCP SDK dispatches @mcp.tool() handlers
66
+ # concurrently on its anyio event loop via start_soon. Every handler in this
67
+ # module does blocking I/O (LSP handshake up to 10s, request waits up to 5s,
68
+ # diagnostics poll sleeps) with no await, so each one stalls the whole loop.
69
+ # We offload the blocking body to a worker thread via anyio.to_thread.run_sync.
70
+ #
71
+ # anyio is a transitive dependency of the MCP SDK, so it is present whenever
72
+ # the proxy is actually runnable. But this module must still IMPORT cleanly on
73
+ # the _NoopFastMCP degrade path (no SDK, possibly no anyio), so the import is
74
+ # guarded and `_to_thread` falls back to an inline call when anyio is absent.
75
+ # The inline fallback only runs in degrade/test envs that have no event loop
76
+ # to stall, so it is safe there.
77
+ try:
78
+ import anyio.to_thread as _anyio_to_thread # noqa: E402
79
+
80
+ _HAS_ANYIO = True
81
+ except ImportError: # pragma: no cover - degrade path without anyio
82
+ _anyio_to_thread = None # type: ignore[assignment]
83
+ _HAS_ANYIO = False
84
+
85
+
86
+ async def _to_thread(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
87
+ """Run a blocking callable off the event loop.
88
+
89
+ Uses anyio.to_thread.run_sync (the MCP SDK's own threading bridge) when
90
+ anyio is available; otherwise calls inline (degrade/test path, no loop to
91
+ stall). anyio's run_sync is positional-only, so kwargs are bound via
92
+ functools.partial before handing the callable to the worker thread."""
93
+ if _HAS_ANYIO and _anyio_to_thread is not None:
94
+ if kwargs:
95
+ func = functools.partial(func, **kwargs)
96
+ return await _anyio_to_thread.run_sync(func, *args)
97
+ return func(*args, **kwargs)
98
+
60
99
 
61
100
  # ============================================================
62
101
  # LOGGING (stderr; stdio transport reserves stdout for JSON-RPC)
@@ -249,8 +288,38 @@ class LSPClient:
249
288
  self.extra_args = list(extra_args)
250
289
  self.proc: Optional[subprocess.Popen] = None
251
290
  self._next_id = 1
252
- self._opened_uris: set = set()
291
+ # v7.x staleness fix: track per-URI document version + on-disk mtime
292
+ # (st_mtime_ns) instead of a plain "opened" set. On a second open of a
293
+ # file whose mtime changed, we send textDocument/didChange with an
294
+ # incremented version (full-sync) so the LSP re-analyzes the new
295
+ # content; without this, did_open early-returned and every query (and
296
+ # diagnostics) saw the first-open snapshot forever. mtime is used
297
+ # (not a content hash) because it is content-independent: it always
298
+ # bumps on save, sidestepping the same-size-edit blind spot that bit
299
+ # the non-git codebase signature (see task #569).
300
+ # _doc_versions: uri -> last LSP document version we sent
301
+ # _doc_mtimes: uri -> st_mtime_ns at the time we last (re)opened
302
+ self._doc_versions: Dict[str, int] = {}
303
+ self._doc_mtimes: Dict[str, int] = {}
253
304
  self._lock = threading.Lock()
305
+ # v7.x async-safety fix: with handlers now offloaded to worker threads
306
+ # (anyio.to_thread), two tool calls can run did_open()/request() in
307
+ # parallel. _write_lsp() does write(header)+write(body)+flush(); if two
308
+ # threads interleave there, the Content-Length framing on the LSP's
309
+ # stdin is corrupted. _write_lock serializes ONLY the stdin write
310
+ # sequences (and the did_open check-and-record), never the response
311
+ # wait or the diagnostics poll, so concurrency is preserved everywhere
312
+ # that matters.
313
+ self._write_lock = threading.Lock()
314
+ # v7.x async-safety fix: _next_request_id() is a read-modify-write on
315
+ # _next_id. With handlers offloaded to worker threads, two concurrent
316
+ # request() calls on the same-language client can allocate the SAME id
317
+ # (the GIL can switch between the load and the store), which collides
318
+ # in _response_queues -- one waiter is overwritten and hangs, or a
319
+ # response is routed to the wrong caller. This leaf lock (it never
320
+ # calls out while held, so no ordering concern with the other locks)
321
+ # makes id allocation atomic.
322
+ self._id_lock = threading.Lock()
254
323
  self._initialized = False
255
324
  # v7.7.14 LSP regression fix (was broken since v7.7.0):
256
325
  # publishDiagnostics notifications were dropped by request()'s
@@ -298,7 +367,9 @@ class LSPClient:
298
367
  self._response_queues.clear()
299
368
  with self._lock:
300
369
  self.pending_diagnostics.clear()
301
- self._opened_uris.clear()
370
+ with self._write_lock:
371
+ self._doc_versions.clear()
372
+ self._doc_mtimes.clear()
302
373
  self._initialized = False
303
374
  cmd = [self.binary_path] + self.extra_args
304
375
  self.proc = subprocess.Popen(
@@ -427,37 +498,93 @@ class LSPClient:
427
498
  pass
428
499
 
429
500
  def _next_request_id(self) -> int:
430
- rid = self._next_id
431
- self._next_id += 1
432
- return rid
501
+ # Atomic id allocation: see _id_lock note in __init__. Called from
502
+ # request() and shutdown(), which may run concurrently once handlers
503
+ # are offloaded to worker threads.
504
+ with self._id_lock:
505
+ rid = self._next_id
506
+ self._next_id += 1
507
+ return rid
433
508
 
434
509
  def did_open(self, file_path: str) -> None:
435
- """Send `textDocument/didOpen` for `file_path` if not already
436
- opened. Reads file contents from disk; silently no-ops if the
437
- file is unreadable (subsequent request will fail with a clean
438
- LSP error rather than crashing the proxy)."""
510
+ """Ensure the LSP has the CURRENT content of `file_path` open.
511
+
512
+ First call for a URI: sends `textDocument/didOpen` at version 1 and
513
+ records the file's on-disk mtime. Subsequent call for an already-open
514
+ URI whose on-disk mtime CHANGED since the last (re)open: sends
515
+ `textDocument/didChange` (full document sync) with an incremented
516
+ version so the server re-analyzes the edited file, and clears any
517
+ stale published diagnostics for that URI so the next poll waits for a
518
+ fresh publish instead of returning the pre-edit snapshot. Unchanged
519
+ files early-return (no redundant didChange), preserving the original
520
+ no-op optimization.
521
+
522
+ Reads file contents from disk; silently no-ops if the file is
523
+ unreadable (a subsequent request will fail with a clean LSP error
524
+ rather than crashing the proxy)."""
439
525
  uri = _path_to_uri(file_path)
440
- if uri in self._opened_uris:
526
+ try:
527
+ mtime = os.stat(file_path).st_mtime_ns
528
+ except OSError:
441
529
  return
530
+ # Fast path: already open and on-disk content unchanged since last
531
+ # (re)open. No write, no lock contention beyond the dict read.
532
+ with self._write_lock:
533
+ prev_mtime = self._doc_mtimes.get(uri)
534
+ already_open = uri in self._doc_versions
535
+ if already_open and prev_mtime == mtime:
536
+ return
442
537
  try:
443
538
  with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
444
539
  text = fh.read()
445
540
  except OSError:
446
541
  return
447
542
  lsp_id, _suffixes, _bin, _args = LANG_MAP[self.language]
448
- _write_lsp(self.proc.stdin, {
449
- 'jsonrpc': '2.0',
450
- 'method': 'textDocument/didOpen',
451
- 'params': {
452
- 'textDocument': {
453
- 'uri': uri,
454
- 'languageId': lsp_id,
455
- 'version': 1,
456
- 'text': text,
543
+ with self._write_lock:
544
+ # Re-read under the lock so two racing threads agree on state.
545
+ prev_version = self._doc_versions.get(uri)
546
+ if prev_version is None:
547
+ # First open of this URI.
548
+ _write_lsp(self.proc.stdin, {
549
+ 'jsonrpc': '2.0',
550
+ 'method': 'textDocument/didOpen',
551
+ 'params': {
552
+ 'textDocument': {
553
+ 'uri': uri,
554
+ 'languageId': lsp_id,
555
+ 'version': 1,
556
+ 'text': text,
557
+ },
558
+ },
559
+ })
560
+ self._doc_versions[uri] = 1
561
+ self._doc_mtimes[uri] = mtime
562
+ return
563
+ if self._doc_mtimes.get(uri) == mtime:
564
+ # Another thread already refreshed to this mtime; nothing to do.
565
+ return
566
+ # File changed on disk since we last opened it: full-sync
567
+ # didChange with an incremented version.
568
+ new_version = prev_version + 1
569
+ _write_lsp(self.proc.stdin, {
570
+ 'jsonrpc': '2.0',
571
+ 'method': 'textDocument/didChange',
572
+ 'params': {
573
+ 'textDocument': {
574
+ 'uri': uri,
575
+ 'version': new_version,
576
+ },
577
+ 'contentChanges': [{'text': text}],
457
578
  },
458
- },
459
- })
460
- self._opened_uris.add(uri)
579
+ })
580
+ self._doc_versions[uri] = new_version
581
+ self._doc_mtimes[uri] = mtime
582
+ # Drop the stale published diagnostics for this URI (outside the
583
+ # write lock, under _lock which guards pending_diagnostics) so that
584
+ # lsp_get_diagnostics polls for the re-published set instead of
585
+ # immediately returning the pre-edit snapshot.
586
+ with self._lock:
587
+ self.pending_diagnostics.pop(uri, None)
461
588
 
462
589
  def request(self, method: str, params: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]:
463
590
  """Send a JSON-RPC request and block until its response arrives.
@@ -472,12 +599,17 @@ class LSPClient:
472
599
  self._response_queues[rid] = wait_q
473
600
  try:
474
601
  try:
475
- _write_lsp(self.proc.stdin, {
476
- 'jsonrpc': '2.0',
477
- 'id': rid,
478
- 'method': method,
479
- 'params': params,
480
- })
602
+ # Serialize the stdin write sequence against did_open() and
603
+ # other concurrent request() calls so the Content-Length
604
+ # framing is never interleaved. The lock is held ONLY for the
605
+ # write, never across the wait below.
606
+ with self._write_lock:
607
+ _write_lsp(self.proc.stdin, {
608
+ 'jsonrpc': '2.0',
609
+ 'id': rid,
610
+ 'method': method,
611
+ 'params': params,
612
+ })
481
613
  except (BrokenPipeError, OSError) as exc:
482
614
  return {'error': {'message': f'LSP I/O failure on write: {exc}'}}
483
615
  try:
@@ -498,7 +630,11 @@ class LSPClient:
498
630
  if not self.proc or self.proc.poll() is not None:
499
631
  return
500
632
  try:
501
- with self._lock:
633
+ # Serialize against concurrent request()/did_open() stdin writes
634
+ # via _write_lock so the shutdown + exit frames are not interleaved
635
+ # with another in-flight write. (Switched from _lock, which guards
636
+ # pending_diagnostics, to the dedicated stdin write lock.)
637
+ with self._write_lock:
502
638
  _write_lsp(self.proc.stdin, {
503
639
  'jsonrpc': '2.0',
504
640
  'id': self._next_request_id(),
@@ -560,7 +696,23 @@ def _get_or_spawn_client(language: str) -> Optional[LSPClient]:
560
696
  logger.warning("LSP spawn failed for %s: %s", language, exc)
561
697
  return None
562
698
  with _clients_lock:
563
- _clients[language] = client
699
+ # Double-check after re-acquiring: a concurrent first-call for the same
700
+ # language (now reachable because handlers run in worker threads via
701
+ # _to_thread) may have spawned and registered a live client while we
702
+ # were handshaking. If so, keep the cached one and shut down our
703
+ # duplicate so we never leak an orphaned LSP subprocess.
704
+ existing = _clients.get(language)
705
+ if existing is not None and existing.proc and existing.proc.poll() is None:
706
+ losing = client
707
+ client = existing
708
+ else:
709
+ losing = None
710
+ _clients[language] = client
711
+ if losing is not None:
712
+ try:
713
+ losing.shutdown()
714
+ except Exception as exc: # best-effort reap; never fail the caller
715
+ logger.warning("LSP duplicate-client shutdown failed for %s: %s", language, exc)
564
716
  return client
565
717
 
566
718
 
@@ -776,7 +928,8 @@ async def lsp_find_references(file: str, line: int, character: int,
776
928
  JSON-encoded string. Success: {"result": [...], "language": ...}.
777
929
  Error: {"error": "..."}.
778
930
  """
779
- result = _dispatch_lsp(
931
+ result = await _to_thread(
932
+ _dispatch_lsp,
780
933
  file, line, character, 'textDocument/references',
781
934
  extra_params={'context': {'includeDeclaration': bool(include_declaration)}},
782
935
  )
@@ -797,7 +950,8 @@ async def lsp_go_to_definition(file: str, line: int, character: int) -> str:
797
950
  JSON-encoded string with `result` (LSP Location | Location[] |
798
951
  LocationLink[]) on success or `error` on failure.
799
952
  """
800
- result = _dispatch_lsp(
953
+ result = await _to_thread(
954
+ _dispatch_lsp,
801
955
  file, line, character, 'textDocument/definition',
802
956
  )
803
957
  return json.dumps(result)
@@ -818,7 +972,8 @@ async def lsp_symbol_at_position(file: str, line: int, character: int) -> str:
818
972
  JSON-encoded string with `result` (LSP Hover) on success or
819
973
  `error` on failure.
820
974
  """
821
- result = _dispatch_lsp(
975
+ result = await _to_thread(
976
+ _dispatch_lsp,
822
977
  file, line, character, 'textDocument/hover',
823
978
  )
824
979
  return json.dumps(result)
@@ -894,30 +1049,11 @@ def _workspace_symbol_request(language: str, query: str, limit: int = 50) -> Dic
894
1049
  return {'result': result[:limit], 'language': language}
895
1050
 
896
1051
 
897
- @mcp.tool()
898
- async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
899
- language: Optional[str] = None) -> str:
900
- """Cheap existence check for a symbol in the current workspace.
901
-
902
- The single most useful grounding primitive: an agent about to write
903
- `flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
904
- first. If false, it means LSP could not find that name anywhere in
905
- the workspace; the agent should resolve via find / grep / read
906
- before writing the call.
907
-
908
- Args:
909
- symbol: Symbol name to look for (substring match per LSP spec).
910
- kind: Optional filter: 'function', 'class', 'method', 'variable',
911
- etc. If provided, only symbols whose LSP SymbolKind matches
912
- are counted.
913
- language: Optional language override. If None, auto-detected
914
- from workspace markers (package.json, requirements.txt, etc.).
915
-
916
- Returns:
917
- JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
918
- "language": "...", "elapsed_ms": float}. On no-LSP-available:
919
- {"error": "...", "exists": null}.
920
- """
1052
+ def _lsp_check_exists_blocking(symbol: str, kind: Optional[str] = None,
1053
+ language: Optional[str] = None) -> str:
1054
+ """Blocking implementation of lsp_check_exists. Runs on a worker thread
1055
+ via _to_thread so the spawn handshake + workspace/symbol request never
1056
+ stall the MCP event loop."""
921
1057
  import time as _t
922
1058
  t0 = _t.perf_counter()
923
1059
  if not symbol or not isinstance(symbol, str):
@@ -960,22 +1096,36 @@ async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
960
1096
 
961
1097
 
962
1098
  @mcp.tool()
963
- async def lsp_get_diagnostics(file: str) -> str:
964
- """Return current LSP diagnostics (errors + warnings) for a file.
1099
+ async def lsp_check_exists(symbol: str, kind: Optional[str] = None,
1100
+ language: Optional[str] = None) -> str:
1101
+ """Cheap existence check for a symbol in the current workspace.
965
1102
 
966
- Diagnostics are published asynchronously by LSP servers via
967
- `textDocument/publishDiagnostics`. This tool opens the file (if not
968
- already open) and waits up to 1 second for diagnostics to arrive,
969
- then returns whatever has been published.
1103
+ The single most useful grounding primitive: an agent about to write
1104
+ `flightApi.getStatus()` should call `lsp_check_exists("getStatus")`
1105
+ first. If false, it means LSP could not find that name anywhere in
1106
+ the workspace; the agent should resolve via find / grep / read
1107
+ before writing the call.
970
1108
 
971
1109
  Args:
972
- file: Absolute or cwd-relative path to the source file.
1110
+ symbol: Symbol name to look for (substring match per LSP spec).
1111
+ kind: Optional filter: 'function', 'class', 'method', 'variable',
1112
+ etc. If provided, only symbols whose LSP SymbolKind matches
1113
+ are counted.
1114
+ language: Optional language override. If None, auto-detected
1115
+ from workspace markers (package.json, requirements.txt, etc.).
973
1116
 
974
1117
  Returns:
975
- JSON: {"diagnostics": [{severity, message, range, source}, ...],
976
- "count_errors": N, "count_warnings": M, "language": "...",
977
- "elapsed_ms": float}.
1118
+ JSON-encoded string: {"exists": bool, "matches": N, "samples": [...],
1119
+ "language": "...", "elapsed_ms": float}. On no-LSP-available:
1120
+ {"error": "...", "exists": null}.
978
1121
  """
1122
+ return await _to_thread(_lsp_check_exists_blocking, symbol, kind, language)
1123
+
1124
+
1125
+ def _lsp_get_diagnostics_blocking(file: str) -> str:
1126
+ """Blocking implementation of lsp_get_diagnostics. Runs on a worker
1127
+ thread via _to_thread; the spawn handshake, didOpen/didChange and the
1128
+ ~1s diagnostics poll (time.sleep loop) must not stall the event loop."""
979
1129
  import time as _t
980
1130
  t0 = _t.perf_counter()
981
1131
  abs_file = os.path.abspath(file)
@@ -1020,24 +1170,30 @@ async def lsp_get_diagnostics(file: str) -> str:
1020
1170
 
1021
1171
 
1022
1172
  @mcp.tool()
1023
- async def lsp_workspace_symbols(query: str, limit: int = 20,
1024
- language: Optional[str] = None) -> str:
1025
- """Fuzzy-search symbols across the entire workspace.
1173
+ async def lsp_get_diagnostics(file: str) -> str:
1174
+ """Return current LSP diagnostics (errors + warnings) for a file.
1026
1175
 
1027
- Use when an agent is hunting for the right name (knows the
1028
- function/class is about "config loading" but isn't sure of the
1029
- actual identifier). Returns LSP workspace/symbol results scoped to
1030
- the detected language (or the language override).
1176
+ Diagnostics are published asynchronously by LSP servers via
1177
+ `textDocument/publishDiagnostics`. This tool opens the file (if not
1178
+ already open, or re-syncs it via didChange if edited since first open)
1179
+ and waits up to 1 second for diagnostics to arrive, then returns
1180
+ whatever has been published.
1031
1181
 
1032
1182
  Args:
1033
- query: Symbol query (substring or fuzzy per LSP server impl).
1034
- limit: Max results to return (default 20, hard cap 100).
1035
- language: Optional language override.
1183
+ file: Absolute or cwd-relative path to the source file.
1036
1184
 
1037
1185
  Returns:
1038
- JSON: {"matches": [...], "count": N, "language": "...",
1186
+ JSON: {"diagnostics": [{severity, message, range, source}, ...],
1187
+ "count_errors": N, "count_warnings": M, "language": "...",
1039
1188
  "elapsed_ms": float}.
1040
1189
  """
1190
+ return await _to_thread(_lsp_get_diagnostics_blocking, file)
1191
+
1192
+
1193
+ def _lsp_workspace_symbols_blocking(query: str, limit: int = 20,
1194
+ language: Optional[str] = None) -> str:
1195
+ """Blocking implementation of lsp_workspace_symbols. Runs on a worker
1196
+ thread via _to_thread (spawn handshake + workspace/symbol request)."""
1041
1197
  import time as _t
1042
1198
  t0 = _t.perf_counter()
1043
1199
  if not isinstance(query, str):
@@ -1070,20 +1226,31 @@ async def lsp_workspace_symbols(query: str, limit: int = 20,
1070
1226
 
1071
1227
 
1072
1228
  @mcp.tool()
1073
- async def lsp_find_definition_by_name(symbol: str,
1074
- language: Optional[str] = None) -> str:
1075
- """Find where a named symbol is defined, without needing a file
1076
- position upfront. Convenience wrapper: runs workspace/symbol then
1077
- returns the first result's location.
1229
+ async def lsp_workspace_symbols(query: str, limit: int = 20,
1230
+ language: Optional[str] = None) -> str:
1231
+ """Fuzzy-search symbols across the entire workspace.
1232
+
1233
+ Use when an agent is hunting for the right name (knows the
1234
+ function/class is about "config loading" but isn't sure of the
1235
+ actual identifier). Returns LSP workspace/symbol results scoped to
1236
+ the detected language (or the language override).
1078
1237
 
1079
1238
  Args:
1080
- symbol: Symbol name to find.
1239
+ query: Symbol query (substring or fuzzy per LSP server impl).
1240
+ limit: Max results to return (default 20, hard cap 100).
1081
1241
  language: Optional language override.
1082
1242
 
1083
1243
  Returns:
1084
- JSON: {"location": {uri, range} | null, "name": str | null,
1085
- "language": "...", "elapsed_ms": float}.
1244
+ JSON: {"matches": [...], "count": N, "language": "...",
1245
+ "elapsed_ms": float}.
1086
1246
  """
1247
+ return await _to_thread(_lsp_workspace_symbols_blocking, query, limit, language)
1248
+
1249
+
1250
+ def _lsp_find_definition_by_name_blocking(symbol: str,
1251
+ language: Optional[str] = None) -> str:
1252
+ """Blocking implementation of lsp_find_definition_by_name. Runs on a
1253
+ worker thread via _to_thread (spawn handshake + workspace/symbol)."""
1087
1254
  import time as _t
1088
1255
  t0 = _t.perf_counter()
1089
1256
  root = _resolve_workspace_root()
@@ -1115,6 +1282,24 @@ async def lsp_find_definition_by_name(symbol: str,
1115
1282
  })
1116
1283
 
1117
1284
 
1285
+ @mcp.tool()
1286
+ async def lsp_find_definition_by_name(symbol: str,
1287
+ language: Optional[str] = None) -> str:
1288
+ """Find where a named symbol is defined, without needing a file
1289
+ position upfront. Convenience wrapper: runs workspace/symbol then
1290
+ returns the first result's location.
1291
+
1292
+ Args:
1293
+ symbol: Symbol name to find.
1294
+ language: Optional language override.
1295
+
1296
+ Returns:
1297
+ JSON: {"location": {uri, range} | null, "name": str | null,
1298
+ "language": "...", "elapsed_ms": float}.
1299
+ """
1300
+ return await _to_thread(_lsp_find_definition_by_name_blocking, symbol, language)
1301
+
1302
+
1118
1303
  # ============================================================
1119
1304
  # MAIN
1120
1305
  # ============================================================
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.41.5",
4
+ "version": "7.42.0",
5
5
  "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).",
6
6
  "keywords": [
7
7
  "agent",