loki-mode 7.109.0 → 7.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.109.0
6
+ # Loki Mode v7.110.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -408,4 +408,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
408
408
 
409
409
  ---
410
410
 
411
- **v7.109.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
411
+ **v7.110.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.109.0
1
+ 7.110.0
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.109.0"
10
+ __version__ = "7.110.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -1180,6 +1180,16 @@ async def get_status() -> StatusResponse:
1180
1180
  if state_file.exists():
1181
1181
  try:
1182
1182
  state = _safe_json_read(state_file, {})
1183
+ # dashboard-state.json is agent/user-written and, under atomic-write
1184
+ # races or hand edits, its top level can be a list/string/number
1185
+ # rather than an object. state.get(...) would then raise
1186
+ # AttributeError, which the surrounding (JSONDecodeError, KeyError)
1187
+ # handler does NOT catch -> /api/status 500s and blanks the board.
1188
+ # Coerce to {} so a bad shape degrades to the idle payload. Placed
1189
+ # BEFORE the _has_dashboard_state flag so a truthy non-dict (e.g.
1190
+ # [1,2,3]) cannot flip it on with corrupt data.
1191
+ if not isinstance(state, dict):
1192
+ state = {}
1183
1193
  if state:
1184
1194
  _has_dashboard_state = True
1185
1195
  phase = state.get("phase", "")
@@ -1188,8 +1198,15 @@ async def get_status() -> StatusResponse:
1188
1198
  mode = state.get("mode", "")
1189
1199
  # Count only agents with alive PIDs (not raw array length)
1190
1200
  agents_list = state.get("agents", [])
1201
+ if not isinstance(agents_list, list):
1202
+ agents_list = []
1191
1203
  running_agents = 0
1192
1204
  for agent in agents_list:
1205
+ # agents entries can be bare strings/None in malformed state;
1206
+ # agent.get(...) would raise AttributeError. Skip non-dicts.
1207
+ if not isinstance(agent, dict):
1208
+ running_agents += 1 # legacy/opaque entry -> count as running
1209
+ continue
1193
1210
  agent_pid = agent.get("pid")
1194
1211
  if agent_pid:
1195
1212
  try:
@@ -1202,10 +1219,17 @@ async def get_status() -> StatusResponse:
1202
1219
  running_agents += 1
1203
1220
 
1204
1221
  tasks = state.get("tasks", {})
1205
- pending_tasks = len(tasks.get("pending", []))
1222
+ if not isinstance(tasks, dict):
1223
+ tasks = {}
1224
+ _pending = tasks.get("pending", [])
1225
+ pending_tasks = len(_pending) if isinstance(_pending, list) else 0
1206
1226
  in_progress = tasks.get("inProgress", [])
1207
- if in_progress:
1208
- current_task = in_progress[0].get("payload", {}).get("action", "")
1227
+ if not isinstance(in_progress, list):
1228
+ in_progress = []
1229
+ if in_progress and isinstance(in_progress[0], dict):
1230
+ _payload = in_progress[0].get("payload", {})
1231
+ if isinstance(_payload, dict):
1232
+ current_task = _payload.get("action", "")
1209
1233
  except (json.JSONDecodeError, KeyError):
1210
1234
  pass
1211
1235
 
@@ -1783,6 +1807,13 @@ async def list_tasks(
1783
1807
  if state_file.exists():
1784
1808
  try:
1785
1809
  state = json.loads(state_file.read_text())
1810
+ # dashboard-state.json top level can itself be a list/string/number
1811
+ # (agent/user-written, atomic-write race). state.get(...) would then
1812
+ # raise AttributeError BEFORE the task_groups guard below, and the
1813
+ # surrounding (JSONDecodeError, KeyError) handler does not catch it
1814
+ # -> /api/tasks 500s and blanks the board. Coerce state first.
1815
+ if not isinstance(state, dict):
1816
+ state = {}
1786
1817
  task_groups = state.get("tasks", {})
1787
1818
  # v7.104.4: dashboard-state.json is user/agent-written and can be
1788
1819
  # malformed or partially written. If "tasks" is not a dict, or a
@@ -6621,6 +6652,11 @@ def _compute_cost_snapshot() -> dict:
6621
6652
  if budget_file.exists():
6622
6653
  try:
6623
6654
  budget_data = json.loads(budget_file.read_text())
6655
+ # budget.json can be a bare number/list/string; guard before .get()
6656
+ # so the cost-summary endpoint degrades instead of raising an
6657
+ # AttributeError uncaught by (JSONDecodeError, KeyError).
6658
+ if not isinstance(budget_data, dict):
6659
+ budget_data = {}
6624
6660
  budget_limit = budget_data.get("limit")
6625
6661
  if budget_limit is not None:
6626
6662
  budget_used = estimated_cost
@@ -6664,6 +6700,12 @@ async def get_budget():
6664
6700
  if budget_file.exists():
6665
6701
  try:
6666
6702
  budget_data = json.loads(budget_file.read_text())
6703
+ # budget.json can be a bare number/list/string (agent/user-written
6704
+ # or atomic-write race), so budget_data.get(...) would raise
6705
+ # AttributeError, uncaught by (JSONDecodeError, KeyError) -> 500.
6706
+ # Coerce to {} so the endpoint degrades to no-limit defaults.
6707
+ if not isinstance(budget_data, dict):
6708
+ budget_data = {}
6667
6709
  budget_limit = budget_data.get("limit") or budget_data.get("budget_limit")
6668
6710
  budget_used = budget_data.get("budget_used", 0.0)
6669
6711
  exceeded = budget_data.get("exceeded", False)
@@ -6687,6 +6729,9 @@ async def get_budget():
6687
6729
  if exceeded_at is None:
6688
6730
  try:
6689
6731
  sig_data = json.loads(signal_file.read_text())
6732
+ # Signal file may parse to a non-dict; guard before .get().
6733
+ if not isinstance(sig_data, dict):
6734
+ sig_data = {}
6690
6735
  exceeded_at = sig_data.get("timestamp")
6691
6736
  except (json.JSONDecodeError, KeyError):
6692
6737
  pass
@@ -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.109.0
5
+ **Version:** v7.110.0
6
6
 
7
7
  ---
8
8
 
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var X8=Object.defineProperty;var K8=($)=>$;function q8($,Q){this[$]=K8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)X8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:q8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var v1={};b(v1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as r,dirname as i$}from"path";import{fileURLToPath as J8}from"url";import{existsSync as x$}from"fs";import{homedir as V8}from"os";function W8(){let $=y1;for(let Q=0;Q<6;Q++){if(x$(r($,"VERSION"))&&x$(r($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return r(y1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(x$(r(Q,"VERSION"))&&x$(r(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return r($,"..","..","..")}function P(){return process.env.LOKI_DIR??r(process.cwd(),".loki")}function T$(){return r(V8(),".loki")}var y1,h;var C=L(()=>{y1=i$(J8(import.meta.url));h=W8()});import{readFileSync as U8}from"fs";import{resolve as H8,dirname as G8}from"path";import{fileURLToPath as B8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.109.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=G8(B8(import.meta.url)),Z=e$(Q);Q$=U8(H8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var c1={};b(c1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>u1,commandVersion:()=>S8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>f1});async function u1($,Q=f1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,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[q,K,W]=await Promise.all([u1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=N8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function N8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function S8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var f1=16777216,Q1;var d=L(()=>{Q1=class Q1 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 t($){return D8?"":$}var D8,T,S,_,nZ,I,k,y,J;var c=L(()=>{D8=(process.env.NO_COLOR??"").length>0;T=t("\x1B[0;31m"),S=t("\x1B[0;32m"),_=t("\x1B[1;33m"),nZ=t("\x1B[0;34m"),I=t("\x1B[0;36m"),k=t("\x1B[1m"),y=t("\x1B[2m"),J=t("\x1B[0m")});import{existsSync as c8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(c8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var J0={};b(J0,{runStatus:()=>U3});import{existsSync as v,readFileSync as U$,readdirSync as e1,statSync as $0}from"fs";import{resolve as D,basename as Q3}from"path";import{homedir as Z3}from"os";function Q0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Q0($),U=Q0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function X3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
2
+ var X8=Object.defineProperty;var K8=($)=>$;function q8($,Q){this[$]=K8.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)X8($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:q8.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var J$=import.meta.require;var v1={};b(v1,{lokiDir:()=>P,homeLokiDir:()=>T$,findRepoRootForVersion:()=>e$,REPO_ROOT:()=>h});import{resolve as r,dirname as i$}from"path";import{fileURLToPath as J8}from"url";import{existsSync as x$}from"fs";import{homedir as V8}from"os";function W8(){let $=y1;for(let Q=0;Q<6;Q++){if(x$(r($,"VERSION"))&&x$(r($,"autonomy/run.sh")))return $;let Z=i$($);if(Z===$)break;$=Z}return r(y1,"..","..","..")}function e$($){let Q=$;for(let Z=0;Z<6;Z++){if(x$(r(Q,"VERSION"))&&x$(r(Q,"autonomy/run.sh")))return Q;let z=i$(Q);if(z===Q)break;Q=z}return r($,"..","..","..")}function P(){return process.env.LOKI_DIR??r(process.cwd(),".loki")}function T$(){return r(V8(),".loki")}var y1,h;var C=L(()=>{y1=i$(J8(import.meta.url));h=W8()});import{readFileSync as U8}from"fs";import{resolve as H8,dirname as G8}from"path";import{fileURLToPath as B8}from"url";function N$(){if(Q$!==null)return Q$;let $="7.110.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=G8(B8(import.meta.url)),Z=e$(Q);Q$=U8(H8(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var $1=L(()=>{C()});var c1={};b(c1,{runOrThrow:()=>x8,run:()=>F,readStreamCapped:()=>u1,commandVersion:()=>S8,commandExists:()=>f,ShellError:()=>Q1,MAX_STDOUT_BYTES:()=>f1});async function u1($,Q=f1){let Z=$.getReader(),z=new TextDecoder,X="",q=0;try{while(q<Q){let{done:K,value:W}=await Z.read();if(K)break;if(!W)continue;if(q+=W.byteLength,q>Q){let V=W.byteLength-(q-Q);X+=z.decode(W.subarray(0,V),{stream:!0});break}X+=z.decode(W,{stream:!0})}X+=z.decode()}finally{try{await Z.cancel()}catch{}Z.releaseLock()}return X}async function F($,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[q,K,W]=await Promise.all([u1(Z.stdout),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function x8($,Q={}){let Z=await F($,Q);if(Z.exitCode!==0)throw new Q1(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=N8($),Z=await F(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function N8($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function S8($,Q="--version"){if(!await f($))return null;let z=await F([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var f1=16777216,Q1;var d=L(()=>{Q1=class Q1 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 t($){return D8?"":$}var D8,T,S,_,nZ,I,k,y,J;var c=L(()=>{D8=(process.env.NO_COLOR??"").length>0;T=t("\x1B[0;31m"),S=t("\x1B[0;32m"),_=t("\x1B[1;33m"),nZ=t("\x1B[0;34m"),I=t("\x1B[0;36m"),k=t("\x1B[1m"),y=t("\x1B[2m"),J=t("\x1B[0m")});import{existsSync as c8}from"fs";async function Z$(){if(A$!==void 0)return A$;let $="/opt/homebrew/bin/python3.12";if(c8($))return A$=$,$;let Q=await f("python3.12");if(Q)return A$=Q,Q;let Z=await f("python3");return A$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return F([Z,"-c",$],Q)}var A$;var V$=L(()=>{d()});var J0={};b(J0,{runStatus:()=>U3});import{existsSync as v,readFileSync as U$,readdirSync as e1,statSync as $0}from"fs";import{resolve as D,basename as Q3}from"path";import{homedir as Z3}from"os";function Q0($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function Z0($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*D$/Q);if(X>D$)X=D$;let q=D$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),V=Q0($),U=Q0(Q);return` ${k}${Z}${J} ${K}[${W}]${J} ${z}% (${V} / ${U})`}async function X3(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${J}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -805,4 +805,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
805
805
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
806
806
  `),process.stderr.write(z8),2}}i1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var EZ=await RZ(Bun.argv.slice(2));process.exit(EZ);
807
807
 
808
- //# debugId=68DF9A47D3B818A364756E2164756E21
808
+ //# debugId=F98EFE21E86DB91C64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.109.0'
60
+ __version__ = '7.110.0'
package/mcp/lsp_proxy.py CHANGED
@@ -1478,6 +1478,95 @@ def write_diagnostics_artifact(root: Optional[str] = None,
1478
1478
  'count_warnings': count_warnings}
1479
1479
 
1480
1480
 
1481
+ def _run_http_transport(port: int) -> None:
1482
+ """Run the LSP-proxy MCP server over Streamable HTTP, bound to loopback.
1483
+
1484
+ Mirrors mcp/server.py:_run_http_transport for consistency. FastMCP has no
1485
+ 'http' transport literal (it uses 'streamable-http') and run() takes no
1486
+ port= kwarg, so the old mcp.run(transport='http', port=...) call never
1487
+ worked (TypeError). We build the Streamable-HTTP ASGI app ourselves, bind
1488
+ 127.0.0.1 explicitly, and run it via uvicorn -- the same supported path.
1489
+
1490
+ Two hardening properties over a bare FastMCP HTTP launch:
1491
+
1492
+ 1. Explicit loopback bind. We set host='127.0.0.1' ourselves and run the
1493
+ ASGI app via uvicorn rather than relying on any implicit default, so a
1494
+ reader of `lsof`/`ss` sees 127.0.0.1 unambiguously and a future SDK
1495
+ default change cannot silently widen the bind to 0.0.0.0.
1496
+
1497
+ 2. Optional bearer-token auth. When LOKI_MCP_AUTH_TOKEN is set in the
1498
+ environment, a Starlette middleware requires every HTTP request to
1499
+ carry `Authorization: Bearer <token>` (rejecting mismatches with 401).
1500
+ When the env var is unset or empty, NO middleware is installed at all,
1501
+ so the request path is exactly the unauthenticated FastMCP behavior.
1502
+
1503
+ hasattr-guarded against the whole supported mcp 1.x range: if the installed
1504
+ SDK lacks streamable_http_app, we fail with a clear message rather than an
1505
+ AttributeError.
1506
+ """
1507
+ if not hasattr(mcp, "streamable_http_app"):
1508
+ logger.error(
1509
+ "Installed MCP SDK does not support Streamable HTTP transport "
1510
+ "(no FastMCP.streamable_http_app). Upgrade the 'mcp' package "
1511
+ "(pip install -U mcp) or use the default stdio transport."
1512
+ )
1513
+ sys.exit(1)
1514
+
1515
+ host = "127.0.0.1"
1516
+
1517
+ # Pin host/port on the SDK settings too, so any code that reads them (and
1518
+ # the SDK's own transport-security allowed_hosts, which defaults to
1519
+ # 127.0.0.1/localhost) stays consistent with what uvicorn actually binds.
1520
+ try:
1521
+ if hasattr(mcp, "settings"):
1522
+ if hasattr(mcp.settings, "host"):
1523
+ mcp.settings.host = host
1524
+ if hasattr(mcp.settings, "port"):
1525
+ mcp.settings.port = port
1526
+ except Exception as _set_err: # pragma: no cover - defensive
1527
+ logger.warning("Could not pin MCP host/port settings: %s", _set_err)
1528
+
1529
+ app = mcp.streamable_http_app()
1530
+
1531
+ token = os.environ.get("LOKI_MCP_AUTH_TOKEN", "")
1532
+ if token:
1533
+ # Only import the middleware pieces when auth is actually requested, so
1534
+ # the unauthenticated path has zero new dependencies or behavior.
1535
+ from starlette.middleware.base import BaseHTTPMiddleware
1536
+ from starlette.responses import JSONResponse
1537
+
1538
+ expected_header = "Bearer " + token
1539
+
1540
+ async def _require_bearer(request, call_next):
1541
+ # BaseHTTPMiddleware auto-forwards non-http scopes (lifespan etc.),
1542
+ # so the StreamableHTTPSessionManager lifespan still starts.
1543
+ provided = request.headers.get("authorization", "")
1544
+ # Constant-time compare to avoid leaking the token via timing.
1545
+ import hmac
1546
+ if not hmac.compare_digest(provided, expected_header):
1547
+ return JSONResponse(
1548
+ {"error": "unauthorized"},
1549
+ status_code=401,
1550
+ headers={"WWW-Authenticate": "Bearer"},
1551
+ )
1552
+ return await call_next(request)
1553
+
1554
+ app.add_middleware(BaseHTTPMiddleware, dispatch=_require_bearer)
1555
+ logger.info(
1556
+ "MCP HTTP auth enabled: bearer token required "
1557
+ "(LOKI_MCP_AUTH_TOKEN is set)."
1558
+ )
1559
+ else:
1560
+ logger.info(
1561
+ "MCP HTTP auth disabled: no LOKI_MCP_AUTH_TOKEN set "
1562
+ "(loopback-only, unauthenticated)."
1563
+ )
1564
+
1565
+ import uvicorn
1566
+ logger.info("MCP Streamable HTTP listening on http://%s:%d/mcp", host, port)
1567
+ uvicorn.run(app, host=host, port=port, log_level="info")
1568
+
1569
+
1481
1570
  def main() -> None:
1482
1571
  import argparse
1483
1572
  parser = argparse.ArgumentParser(
@@ -1536,7 +1625,11 @@ def main() -> None:
1536
1625
  detected = _detect_lsps()
1537
1626
  logger.info("Detected LSPs: %s", sorted(detected.keys()) or 'none')
1538
1627
  if args.transport == 'http':
1539
- mcp.run(transport='http', port=args.port)
1628
+ # Explicit loopback bind + optional bearer-token auth. FastMCP has no
1629
+ # 'http' transport literal (it uses 'streamable-http') and run() takes
1630
+ # no port= kwarg, so the old mcp.run(transport='http', port=...) call
1631
+ # never worked (TypeError); _run_http_transport is the supported path.
1632
+ _run_http_transport(args.port)
1540
1633
  else:
1541
1634
  mcp.run(transport='stdio')
1542
1635
 
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.109.0",
4
+ "version": "7.110.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 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": "7.109.0",
5
+ "version": "7.110.0",
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",
@@ -234,21 +234,29 @@ provider_invoke_with_tier() {
234
234
  *) model="$PROVIDER_MODEL_DEVELOPMENT" ;;
235
235
  esac
236
236
 
237
- local extra_flags=()
237
+ # --search is a TOP-LEVEL `codex` flag, not a `codex exec` flag. On codex
238
+ # 0.141.0 `codex exec ... --search` aborts with "unexpected argument
239
+ # '--search' found", silently breaking LOKI_CODEX_WEB_SEARCH. It must be
240
+ # placed before the `exec` subcommand. --output-last-message (-o) IS an
241
+ # `codex exec` flag and stays after `exec`. Keep the two in separate arrays.
242
+ local pre_exec_flags=()
238
243
  if [ "${LOKI_CODEX_WEB_SEARCH:-false}" = "true" ]; then
239
- extra_flags+=(--search)
244
+ pre_exec_flags+=(--search)
240
245
  fi
246
+ local extra_flags=()
241
247
  if [ "${LOKI_CODEX_OUTPUT_LAST:-true}" != "false" ] && [ -n "${LOKI_LOG_FILE:-}" ]; then
242
248
  extra_flags+=(--output-last-message "${LOKI_LOG_FILE}.last-message")
243
249
  fi
244
250
 
245
251
  LOKI_CODEX_REASONING_EFFORT="$effort" \
246
252
  CODEX_MODEL_REASONING_EFFORT="$effort" \
247
- # Guard the extra_flags array expansion: with no web-search / output-last
248
- # knobs the array is empty, and a bare "${arr[@]}" under `set -u` aborts with
253
+ # Guard the array expansions: with no web-search / output-last knobs an
254
+ # array is empty, and a bare "${arr[@]}" under `set -u` aborts with
249
255
  # "unbound variable" on bash 3.2 (stock macOS /bin/bash). ${arr[@]+...}
250
256
  # expands to nothing when empty and preserves spaced elements otherwise.
251
- codex exec \
257
+ codex \
258
+ "${pre_exec_flags[@]+"${pre_exec_flags[@]}"}" \
259
+ exec \
252
260
  --sandbox workspace-write \
253
261
  --skip-git-repo-check \
254
262
  --model "$model" \
@@ -8,7 +8,7 @@
8
8
  #
9
9
  # Usage:
10
10
  # source providers/models.sh
11
- # model=$(loki_latest_model claude planning) # -> claude-opus-4-7
11
+ # model=$(loki_latest_model claude planning) # -> claude-opus-4-8
12
12
  #
13
13
  # Env override order: LOKI_<PROVIDER>_MODEL_<TIER> > LOKI_<PROVIDER>_MODEL > catalog latest.
14
14