loki-mode 9.12.1 → 9.12.2

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/VERSION CHANGED
@@ -1 +1 @@
1
- 9.12.1
1
+ 9.12.2
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "9.12.1"
10
+ __version__ = "9.12.2"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -7,6 +7,7 @@ Provides REST API and WebSocket endpoints for dashboard functionality.
7
7
  from __future__ import annotations
8
8
 
9
9
  import asyncio
10
+ import ipaddress
10
11
  import json
11
12
  import logging
12
13
  import os
@@ -186,6 +187,150 @@ def _rate_key(base: str, request: Optional[Request]) -> str:
186
187
  logger = logging.getLogger(__name__)
187
188
 
188
189
 
190
+ # Reads that expose operational or credential-adjacent state. These stay open
191
+ # to a LOCAL caller (zero-config use is the point) but must not be readable by
192
+ # an anonymous remote caller when the dashboard is bound to 0.0.0.0.
193
+ #
194
+ # Measured before this list existed, from a routable remote address with auth
195
+ # off: /api/logs, /api/secrets/status, /api/github/status, /api/tasks,
196
+ # /api/council/transcripts and /api/proofs all returned 200.
197
+ #
198
+ # /health and /metrics are deliberately ABSENT: a container health probe and a
199
+ # Prometheus scrape must keep working with no configuration, and neither
200
+ # carries workspace content.
201
+ _SENSITIVE_READ_PREFIXES = (
202
+ "/api/logs",
203
+ "/api/secrets",
204
+ "/api/github",
205
+ "/api/tasks",
206
+ "/api/projects",
207
+ "/api/council",
208
+ "/api/proofs",
209
+ "/api/memory",
210
+ "/api/learnings",
211
+ "/api/learning",
212
+ "/api/escalations",
213
+ "/api/spec",
214
+ "/api/checkpoints",
215
+ "/api/enterprise",
216
+ "/api/collab",
217
+ "/api/cost",
218
+ "/api/budget",
219
+ "/api/findings",
220
+ "/api/operator",
221
+ "/api/fleet",
222
+ "/api/registry",
223
+ "/api/wiki",
224
+ "/api/activity",
225
+ "/api/session",
226
+ "/api/failures",
227
+ "/api/prompt",
228
+ "/api/quality",
229
+ "/api/migration",
230
+ "/api/managed",
231
+ "/api/app-runner",
232
+ "/api/playwright",
233
+ "/api/checklist",
234
+ "/api/control",
235
+ )
236
+
237
+
238
+ def _trusted_proxies() -> frozenset:
239
+ """Proxy addresses whose forwarded-for header may be believed.
240
+
241
+ EXPLICIT, never inferred. A reverse proxy on the same host presents
242
+ 127.0.0.1 as the peer, so "the peer is loopback" cannot mean "the caller is
243
+ local" -- that was a real bypass: a remote request through a same-host
244
+ proxy reached POST /api/control/stop with a 200. Trusting X-Forwarded-For
245
+ unconditionally is the opposite mistake, since any direct caller can send
246
+ that header themselves.
247
+
248
+ So the operator names the proxies. Anything not named is not trusted, and
249
+ its forwarded headers are ignored rather than believed.
250
+ """
251
+ raw = os.environ.get("LOKI_TRUSTED_PROXIES", "")
252
+ return frozenset(x.strip() for x in raw.split(",") if x.strip())
253
+
254
+
255
+ def _real_client_host(request: Request):
256
+ """The address to make the decision on, or None if it cannot be known.
257
+
258
+ Returns the peer address normally. When the peer is a TRUSTED proxy, the
259
+ left-most X-Forwarded-For entry is used instead, because that is the
260
+ originating client the proxy is reporting.
261
+ """
262
+ client = getattr(request, "client", None)
263
+ host = getattr(client, "host", None) if client else None
264
+ if host is None:
265
+ return None
266
+ if host in _trusted_proxies():
267
+ fwd = request.headers.get("x-forwarded-for", "")
268
+ first = fwd.split(",")[0].strip()
269
+ if first:
270
+ return first
271
+ return host
272
+
273
+
274
+ def _is_local_caller(host) -> bool:
275
+ """True only for a caller we can positively identify as non-routable.
276
+
277
+ A peer that is not an IP literal (ASGI test transports report
278
+ "testclient", UDS transports report names) is treated as local: a name is
279
+ not evidence of a remote caller, and refusing every non-IP string broke 17
280
+ existing tests without closing any real hole.
281
+ """
282
+ if host is None:
283
+ return False
284
+ if host in ("127.0.0.1", "::1", "localhost"):
285
+ return True
286
+ try:
287
+ return ipaddress.ip_address(host).is_loopback
288
+ except ValueError:
289
+ return True
290
+
291
+
292
+ def require_local_or_authenticated(request: Request) -> None:
293
+ """Refuse an anonymous REMOTE caller on a mutation or a sensitive read.
294
+
295
+ THE HOLE THIS CLOSES. Every mutating route already carries
296
+ Depends(auth.require_scope(...)), and all 46 were bypassable, because
297
+ require_scope returns True when enterprise auth is DISABLED -- the default.
298
+ Bound to 127.0.0.1 that is harmless. But LOKI_DASHBOARD_HOST=0.0.0.0 is a
299
+ documented container configuration, and there an anonymous request from
300
+ anywhere on the network could stop a build or read the logs.
301
+
302
+ Measured with auth off, from a routable remote address:
303
+
304
+ POST /api/control/stop -> 200
305
+ GET /api/logs -> 200
306
+ GET /api/secrets/status -> 200
307
+
308
+ The rule, chosen so zero-config local use does not change:
309
+
310
+ auth enabled require_scope decides, unchanged
311
+ loopback / non-IP peer allowed, exactly as today
312
+ trusted proxy decided on the FORWARDED client
313
+ routable remote, no auth 403
314
+
315
+ An untrusted proxy's forwarded headers are IGNORED, not believed: any
316
+ direct caller can set X-Forwarded-For.
317
+ """
318
+ if auth.ENTERPRISE_AUTH_ENABLED or auth.OIDC_ENABLED:
319
+ return
320
+ host = _real_client_host(request)
321
+ if host is None:
322
+ raise HTTPException(
323
+ status_code=403,
324
+ detail="control requires an identifiable client; enable "
325
+ "LOKI_ENTERPRISE_AUTH to allow remote access")
326
+ if _is_local_caller(host):
327
+ return
328
+ raise HTTPException(
329
+ status_code=403,
330
+ detail="this endpoint is restricted to local callers unless "
331
+ "LOKI_ENTERPRISE_AUTH is enabled")
332
+
333
+
189
334
  # Pydantic schemas for API
190
335
  def _sanitize_text_field(value: str) -> str:
191
336
  """Strip/reject control characters from text fields."""
@@ -1004,6 +1149,53 @@ except Exception as _gzip_exc: # pragma: no cover - starlette always ships it
1004
1149
  # than one that does not start.
1005
1150
  logger.warning("gzip compression unavailable: %s", _gzip_exc)
1006
1151
 
1152
+ # THE DASHBOARD BOUNDARY. One central fail-closed check, not a per-route flag.
1153
+ #
1154
+ # WHY MIDDLEWARE AND NOT A DEPENDENCY PER ROUTE. All 46 mutating routes already
1155
+ # carry Depends(auth.require_scope(...)). Every one of them is a NO-OP when
1156
+ # enterprise auth is disabled, which is the default -- require_scope returns
1157
+ # True in that mode. So "42 of 46 are scoped" described the code accurately and
1158
+ # the security posture not at all: a remote anonymous caller could invoke any
1159
+ # of them. Measured before this guard, with LOKI_ENTERPRISE_AUTH unset:
1160
+ #
1161
+ # POST /api/control/stop -> 200
1162
+ # POST /api/control/app-stop -> 200
1163
+ #
1164
+ # A first attempt added a dependency to six control routes by hand. That is the
1165
+ # wrong shape: it protects the six someone remembered, leaves the other forty,
1166
+ # and every route added later starts unprotected. The boundary is one place.
1167
+ #
1168
+ # THE RULE, chosen so zero-config local use does not change:
1169
+ #
1170
+ # auth enabled -> require_scope decides, unchanged
1171
+ # loopback caller -> allowed, exactly as today
1172
+ # non-IP peer (test/UDS) -> allowed; a name is not evidence of remote
1173
+ # routable remote + no auth -> 403
1174
+ #
1175
+ # Only MUTATIONS are gated. Reads stay open so a container health probe, a
1176
+ # metrics scrape and the SPA itself keep working with no configuration.
1177
+ @app.middleware("http")
1178
+ async def dashboard_control_boundary(request: Request, call_next):
1179
+ # EVERY mutation, plus reads that expose operational or credential-adjacent
1180
+ # state. Gating mutations alone left /api/logs, /api/secrets/status and
1181
+ # /api/council/transcripts readable by an anonymous remote caller on a
1182
+ # 0.0.0.0 bind -- measured at 200 before this was widened.
1183
+ #
1184
+ # /health and /metrics are intentionally NOT in the sensitive list, so a
1185
+ # container health probe and a Prometheus scrape keep working unconfigured.
1186
+ gated = request.method in ("POST", "PUT", "PATCH", "DELETE")
1187
+ if not gated:
1188
+ path = request.url.path
1189
+ gated = any(path.startswith(pfx) for pfx in _SENSITIVE_READ_PREFIXES)
1190
+ if gated:
1191
+ try:
1192
+ require_local_or_authenticated(request)
1193
+ except HTTPException as exc:
1194
+ return JSONResponse(status_code=exc.status_code,
1195
+ content={"detail": exc.detail})
1196
+ return await call_next(request)
1197
+
1198
+
1007
1199
  # Static file serving is configured at the end of the file (after all API routes)
1008
1200
 
1009
1201
  # Mount V2 API router
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var u_=Object.create;var{getPrototypeOf:p_,defineProperty:eK,getOwnPropertyNames:d_}=Object;var c_=Object.prototype.hasOwnProperty;function l_(Z){return this[Z]}var i_,a_,s_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?i_??=new WeakMap:a_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?u_(p_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of d_(Z))if(!c_.call(K,$))eK(K,$,{get:l_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var n_=(Z)=>Z;function o_(Z,X){this[Z]=n_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:o_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as r_}from"url";import{existsSync as UQ}from"fs";import{homedir as t_}from"os";function e_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(t_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(r_(import.meta.url));i0=e_()});import{readFileSync as Zf}from"fs";import{resolve as Xf,dirname as Qf}from"path";import{fileURLToPath as Yf}from"url";function h3(){if(h5!==null)return h5;let Z="9.12.1";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Qf(Yf(import.meta.url)),Q=X$(X);h5=Zf(Xf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var bO={};l0(bO,{runOrThrow:()=>Mf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>wf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>yO});async function NQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Mf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Tf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Tf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function wf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return Cf?"":Z}var Cf,L0,F8,p0,KV0,a0,W8,Q9,v;var S6=p(()=>{Cf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),KV0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),W8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as _f}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(_f(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>$h});import{existsSync as Y9,readFileSync as g3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as tf}from"path";import{homedir as ef}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*LQ/X);if(J>LQ)J=LQ;let z=LQ-J,K=F8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${W8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function Xh(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
2
+ var u_=Object.create;var{getPrototypeOf:p_,defineProperty:eK,getOwnPropertyNames:d_}=Object;var c_=Object.prototype.hasOwnProperty;function l_(Z){return this[Z]}var i_,a_,s_=(Z,X,Q)=>{var Y=Z!=null&&typeof Z==="object";if(Y){var J=X?i_??=new WeakMap:a_??=new WeakMap,z=J.get(Z);if(z)return z}Q=Z!=null?u_(p_(Z)):{};let K=X||!Z||!Z.__esModule?eK(Q,"default",{value:Z,enumerable:!0}):Q;for(let $ of d_(Z))if(!c_.call(K,$))eK(K,$,{get:l_.bind(Z,$),enumerable:!0});if(Y)J.set(Z,K);return K};var HQ=(Z,X)=>()=>(X||Z((X={exports:{}}).exports,X),X.exports);var n_=(Z)=>Z;function o_(Z,X){this[Z]=n_.bind(null,X)}var l0=(Z,X)=>{for(var Q in X)eK(Z,Q,{get:X[Q],enumerable:!0,configurable:!0,set:o_.bind(X,Q)})};var p=(Z,X)=>()=>(Z&&(X=Z(Z=0)),X);var t0=import.meta.require;var kO={};l0(kO,{lokiDir:()=>j0,homeLokiDir:()=>R4,findRepoRootForVersion:()=>X$,REPO_ROOT:()=>i0});import{resolve as n7,dirname as Z$}from"path";import{fileURLToPath as r_}from"url";import{existsSync as UQ}from"fs";import{homedir as t_}from"os";function e_(){let Z=RO;for(let X=0;X<6;X++){if(UQ(n7(Z,"VERSION"))&&UQ(n7(Z,"autonomy/run.sh")))return Z;let Q=Z$(Z);if(Q===Z)break;Z=Q}return n7(RO,"..","..","..")}function X$(Z){let X=Z;for(let Q=0;Q<6;Q++){if(UQ(n7(X,"VERSION"))&&UQ(n7(X,"autonomy/run.sh")))return X;let Y=Z$(X);if(Y===X)break;X=Y}return n7(Z,"..","..","..")}function j0(){return process.env.LOKI_DIR??n7(process.cwd(),".loki")}function R4(){return n7(t_(),".loki")}var RO,i0;var H8=p(()=>{RO=Z$(r_(import.meta.url));i0=e_()});import{readFileSync as Zf}from"fs";import{resolve as Xf,dirname as Qf}from"path";import{fileURLToPath as Yf}from"url";function h3(){if(h5!==null)return h5;let Z="9.12.2";if(typeof Z==="string"&&Z.length>0)return h5=Z,h5;try{let X=Qf(Yf(import.meta.url)),Q=X$(X);h5=Zf(Xf(Q,"VERSION"),"utf-8").trim()}catch{h5="unknown"}return h5}var h5=null;var BQ=p(()=>{H8()});var bO={};l0(bO,{runOrThrow:()=>Mf,run:()=>E0,readStreamCapped:()=>NQ,commandVersion:()=>wf,commandExists:()=>X9,ShellError:()=>Q$,MAX_STDOUT_BYTES:()=>yO});async function NQ(Z,X=yO){let Q=Z.getReader(),Y=new TextDecoder,J="",z=0;try{while(z<X){let{done:K,value:$}=await Q.read();if(K)break;if(!$)continue;if(z+=$.byteLength,z>X){let W=$.byteLength-(z-X);J+=Y.decode($.subarray(0,W),{stream:!0});break}J+=Y.decode($,{stream:!0})}J+=Y.decode()}finally{try{await Q.cancel()}catch{}Q.releaseLock()}return J}async function E0(Z,X={}){let Q=Bun.spawn({cmd:[...Z],stdout:"pipe",stderr:"pipe",env:X.env?{...process.env,...X.env}:process.env,cwd:X.cwd}),Y,J;if(X.timeoutMs&&X.timeoutMs>0)Y=setTimeout(()=>{try{Q.kill("SIGTERM")}catch{}J=setTimeout(()=>{try{Q.kill("SIGKILL")}catch{}},2000)},X.timeoutMs);try{let[z,K,$]=await Promise.all([NQ(Q.stdout),new Response(Q.stderr).text(),Q.exited]);return{stdout:z,stderr:K,exitCode:$}}finally{if(Y)clearTimeout(Y);if(J)clearTimeout(J)}}async function Mf(Z,X={}){let Q=await E0(Z,X);if(Q.exitCode!==0)throw new Q$(`command failed (${Q.exitCode}): ${Z.join(" ")}`,Q.exitCode,Q.stdout,Q.stderr);return Q}async function X9(Z){let X=Tf(Z),Q=await E0(["sh","-c",`command -v ${X}`],{timeoutMs:5000});if(Q.exitCode===0)return Q.stdout.trim()||null;return null}function Tf(Z){if(!/^[A-Za-z0-9._/-]+$/.test(Z))throw Error(`refused to shell-escape suspect token: ${Z}`);return Z}async function wf(Z,X="--version"){if(!await X9(Z))return null;let Y=await E0([Z,X],{timeoutMs:5000});if(Y.exitCode!==0)return null;return((Y.stdout||Y.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var yO=16777216,Q$;var x9=p(()=>{Q$=class Q$ extends Error{message;exitCode;stdout;stderr;constructor(Z,X,Q,Y){super(Z);this.message=Z;this.exitCode=X;this.stdout=Q;this.stderr=Y;this.name="ShellError"}}});function o7(Z){return Cf?"":Z}var Cf,L0,F8,p0,KV0,a0,W8,Q9,v;var S6=p(()=>{Cf=(process.env.NO_COLOR??"").length>0;L0=o7("\x1B[0;31m"),F8=o7("\x1B[0;32m"),p0=o7("\x1B[1;33m"),KV0=o7("\x1B[0;34m"),a0=o7("\x1B[0;36m"),W8=o7("\x1B[1m"),Q9=o7("\x1B[2m"),v=o7("\x1B[0m")});import{existsSync as _f}from"fs";async function E7(){if(x4!==void 0)return x4;let Z="/opt/homebrew/bin/python3.12";if(_f(Z))return x4=Z,Z;let X=await X9("python3.12");if(X)return x4=X,X;let Q=await X9("python3");return x4=Q,Q}async function Y7(Z,X={}){let Q=await E7();if(!Q)return{stdout:"",stderr:"python3 not found",exitCode:127};return E0([Q,"-c",Z],X)}var x4;var r7=p(()=>{x9()});var ZL={};l0(ZL,{runStatus:()=>$h});import{existsSync as Y9,readFileSync as g3,readdirSync as iO,statSync as aO}from"fs";import{resolve as h8,basename as tf}from"path";import{homedir as ef}from"os";function sO(Z){let X=Math.trunc(Z);if(X>=1e6)return`${(Math.trunc(X/1e6*10)/10).toFixed(1)}M`;if(X>=1000)return`${(Math.trunc(X/1000*10)/10).toFixed(1)}K`;return String(X)}function nO(Z,X,Q){if(X===0)return null;let Y=Math.trunc(Z*100/X),J=Math.trunc(Z*LQ/X);if(J>LQ)J=LQ;let z=LQ-J,K=F8;if(Y>=80)K=L0;else if(Y>=50)K=p0;let $="=".repeat(Math.max(0,J))+" ".repeat(Math.max(0,z)),W=sO(Z),V=sO(X);return` ${W8}${Q}${v} ${K}[${$}]${v} ${Y}% (${W} / ${V})`}async function Xh(){if(await X9("jq"))return!0;return process.stdout.write(`${L0}Error: jq is required but not installed.${v}
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)
@@ -1232,4 +1232,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
1232
1232
  `),2}case"start":{let{runStart:Y}=await Promise.resolve().then(() => (v_(),h_));return Y(Q)}default:return process.stderr.write(`Unknown command: ${X}
1233
1233
  `),process.stderr.write(g_),2}}cO();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var pW0=await uW0(Bun.argv.slice(2));process.exit(pW0);
1234
1234
 
1235
- //# debugId=BAF4AA4CD79FEF4264756E2164756E21
1235
+ //# debugId=23B556726095B27C64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '9.12.1'
78
+ __version__ = '9.12.2'
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": "9.12.1",
4
+ "version": "9.12.2",
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 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "9.12.1",
5
+ "version": "9.12.2",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",